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

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

View File

@@ -6,14 +6,6 @@
circle
@click="emit('update:collapsed', !collapsed)"
/>
<button
v-if="collapsed"
class="indicator-collapsed-trigger"
type="button"
@click="emit('update:collapsed', false)"
>
{{ collapsedLabel }}
</button>
<div v-show="!collapsed" class="indicator-panel-body">
<SteadyIndicatorTree
:key="selectorResetKey"
@@ -49,8 +41,6 @@ const emit = defineEmits<{
refresh: []
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
}>()
const collapsedLabel = '\u7a33\u6001\u6307\u6807'
</script>
<style scoped lang="scss">
@@ -60,12 +50,12 @@ const collapsedLabel = '\u7a33\u6001\u6307\u6807'
right: 12px;
bottom: 12px;
z-index: 2;
width: 360px;
width: 300px;
transition: width 0.2s ease;
}
.indicator-floating-panel.is-collapsed {
width: 44px;
width: 0;
}
.indicator-toggle {
@@ -85,33 +75,13 @@ const collapsedLabel = '\u7a33\u6001\u6307\u6807'
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);
.indicator-floating-panel.is-collapsed .indicator-panel-body {
display: none;
}
@media (max-width: 1360px) {
.indicator-floating-panel {
width: 320px;
width: 280px;
}
}
</style>

View File

@@ -96,7 +96,6 @@ watch(
padding: 12px;
}
.panel-header,
.tree-node {
display: flex;
align-items: center;
@@ -104,6 +103,13 @@ watch(
gap: 8px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 14px;
}
.panel-title {
font-size: 14px;
font-weight: 600;

View File

@@ -34,8 +34,8 @@
<span class="node-name">{{ data.name }}</span>
</span>
<span class="node-count">
<template v-if="Number(data.deviceCount) || Number(data.lineCount)">
{{ Number(data.deviceCount || 0) }} / {{ Number(data.lineCount || 0) }}
<template v-if="shouldShowLedgerCount(data)">
{{ resolveLedgerCountText(data) }}
</template>
</span>
</div>
@@ -91,6 +91,18 @@ const resolveLedgerIcon = (value: unknown) => {
return ledgerIcons[normalizeLedgerLevel(value)]
}
const shouldShowLedgerCount = (data: SteadyDataView.SteadyLedgerNode) => {
return Number(data.level) < 3 && (Number(data.deviceCount) > 0 || Number(data.lineCount) > 0)
}
const resolveLedgerCountText = (data: SteadyDataView.SteadyLedgerNode) => {
if (normalizeLedgerLevel(data.level) === 2) {
return String(Number(data.lineCount || 0))
}
return `${Number(data.deviceCount || 0)} / ${Number(data.lineCount || 0)}`
}
const handleKeywordChange = (value: string) => {
emit('search', value)
}

View File

@@ -1,11 +1,8 @@
<template>
<section class="card trend-chart-panel" v-loading="loading">
<div class="panel-header">
<span class="panel-title">趋势图</span>
<div v-if="trendResult" class="panel-header">
<span class="panel-meta">
<template v-if="trendResult">
{{ trendResult.bucket || '-' }} / {{ trendResult.displayPointCount || 0 }}
</template>
{{ trendResult.bucket || '-' }} / {{ trendResult.displayPointCount || 0 }}
</span>
</div>
@@ -41,6 +38,7 @@ const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResu
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 12px;
}
@@ -48,16 +46,11 @@ const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResu
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 10px;
margin-bottom: 10px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
}
.panel-meta {
color: var(--el-text-color-secondary);
font-size: 12px;

View File

@@ -118,7 +118,7 @@ const handleTimeBaseDateChange = (value: Date) => {
<style scoped lang="scss">
.trend-toolbar {
display: grid;
grid-template-columns: minmax(312px, 1.4fr) repeat(2, minmax(178px, 0.8fr)) auto;
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
gap: 10px;
align-items: center;
padding: 12px;
@@ -132,7 +132,7 @@ const handleTimeBaseDateChange = (value: Date) => {
}
.toolbar-field--time {
min-width: 312px;
min-width: 0;
}
.toolbar-field__label {
@@ -149,15 +149,16 @@ const handleTimeBaseDateChange = (value: Date) => {
.trend-toolbar__time {
flex: 1 1 0;
min-width: 260px;
min-width: 0;
}
.harmonic-select {
grid-column: span 2;
grid-column: auto;
}
.toolbar-actions {
display: flex;
grid-column: 5;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -107,6 +107,7 @@ const indicatorPanelCollapsedProxy = computed({
.selector-column {
display: grid;
min-height: 0;
overflow: hidden;
}
.trend-main {
@@ -115,12 +116,14 @@ const indicatorPanelCollapsedProxy = computed({
gap: 12px;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.trend-content {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.trend-content :deep(.trend-chart-panel) {

View File

@@ -0,0 +1,91 @@
/* eslint-env node */
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const utilsFile = path.join(currentDir, '..', 'utils', 'ledgerTree.ts')
const pageFile = path.join(currentDir, '..', 'index.vue')
const ledgerTreeComponentFile = path.join(currentDir, '..', 'components', 'SteadyLedgerTree.vue')
const read = file => fs.readFileSync(file, 'utf8')
if (!fs.existsSync(utilsFile)) {
console.error('steadyDataView ledger tree normalize contract failed:')
console.error('- ledgerTree utility should exist')
process.exit(1)
}
const source = read(utilsFile)
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true
}
}).outputText
const moduleContext = { exports: {} }
const sandbox = {
exports: moduleContext.exports,
module: moduleContext,
require
}
vm.runInNewContext(compiled, sandbox, { filename: utilsFile })
const { normalizeSteadyLedgerTree } = moduleContext.exports
if (typeof normalizeSteadyLedgerTree !== 'function') {
console.error('steadyDataView ledger tree normalize contract failed:')
console.error('- normalizeSteadyLedgerTree should be exported')
process.exit(1)
}
const flatNodes = [
{ id: 'line-1', parentId: 'device-1', name: '监测点_1', level: 3, lineCount: 1, selectable: true },
{ id: 'line-disabled', parentId: 'device-1', name: '不可选监测点', level: 3, lineCount: 1, selectable: false },
{ id: 'engineering-1', name: '工程', level: 0, deviceCount: 1, lineCount: 2 },
{ id: 'device-1', parentId: 'project-1', name: '设备', level: 2, lineCount: 2 },
{ id: 'project-1', parentId: 'engineering-1', name: '项目', level: 1, deviceCount: 1, lineCount: 2 },
{ id: 'line-2', parentId: 'device-1', name: '监测点_2', level: 3, lineCount: 1, selectable: true }
]
const normalized = normalizeSteadyLedgerTree(flatNodes)
const expectedPath = normalized[0]?.children?.[0]?.children?.[0]?.children?.map(item => item.name)
const expectations = [
['flat nodes rebuild to one root engineering node', normalized.length === 1 && normalized[0].id === 'engineering-1'],
['project is nested under engineering', normalized[0]?.children?.[0]?.id === 'project-1'],
['device is nested under project', normalized[0]?.children?.[0]?.children?.[0]?.id === 'device-1'],
['lines are nested under device', JSON.stringify(expectedPath) === JSON.stringify(['监测点_1', '监测点_2'])],
[
'line nodes keep selectable query identity',
normalized[0]?.children?.[0]?.children?.[0]?.children?.every(item => item.selectable && item.level === 3)
],
[
'unselectable line nodes are removed from steady query tree',
!normalized[0]?.children?.[0]?.children?.[0]?.children?.some(item => item.id === 'line-disabled')
],
[
'ledger tree component hides count text on monitor point leaves',
/shouldShowLedgerCount[\s\S]*Number\(data\.level\)\s*<\s*3/.test(read(ledgerTreeComponentFile))
],
['page uses normalized ledger tree data', /normalizeSteadyLedgerTree/.test(read(pageFile))]
]
const failures = expectations.filter(([, matched]) => !matched)
if (failures.length) {
console.error('steadyDataView ledger tree normalize contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView ledger tree normalize contract passed')

View File

@@ -45,6 +45,8 @@ const expectations = [
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
['API exposes trend query endpoint', /\/steady\/data-view\/trend\/query/],
['API exposes trend day endpoint', /\/steady\/data-view\/trend\/day/],
['API disables global loading for trend query', /querySteadyTrend[\s\S]*\/steady\/data-view\/trend\/query[\s\S]*loading:\s*false/],
['API disables global loading for trend day query', /querySteadyTrendDay[\s\S]*\/steady\/data-view\/trend\/day[\s\S]*loading:\s*false/],
['API does not expose trend summary endpoint', /\/steady\/data-view\/trend\/summary/],
['interfaces define trend query params', /interface\s+SteadyTrendQueryParams/],
['interfaces define trend series', /interface\s+SteadyTrendSeries/],
@@ -89,6 +91,8 @@ const sourceByExpectation = [
apiSource,
apiSource,
apiSource,
apiSource,
apiSource,
interfaceSource,
interfaceSource,
interfaceSource,

View File

@@ -17,6 +17,11 @@ const componentSource = fs.existsSync(componentDir)
.map(file => fs.readFileSync(path.join(componentDir, file), 'utf8'))
.join('\n')
: ''
const readComponent = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
const toolbarSource = readComponent('SteadyTrendToolbar.vue')
const chartPanelSource = readComponent('SteadyTrendChartPanel.vue')
const floatingPanelSource = readComponent('SteadyIndicatorFloatingPanel.vue')
const indicatorTreeSource = readComponent('SteadyIndicatorTree.vue')
const viewSource = `${source}\n${componentSource}`
const apiSource = fs.readFileSync(apiFile, 'utf8')
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
@@ -40,7 +45,10 @@ const forbiddenPatterns = [
'trend summary types are removed',
/SteadyTrendSummary|SteadyTrendSummaryItem/,
interfaceSource
]
],
['chart panel title text is removed', /panel-title/, chartPanelSource],
['collapsed indicator vertical trigger is removed', /indicator-collapsed-trigger/, floatingPanelSource],
['collapsed indicator label is removed', /collapsedLabel/, floatingPanelSource]
]
const requiredPatterns = [
@@ -52,7 +60,17 @@ const requiredPatterns = [
['components keep right floating indicator panel', /indicator-floating-panel/, viewSource],
['indicator panel defaults expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/, source],
['indicator panel supports collapsed state', /is-collapsed/, viewSource],
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource]
['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/,
toolbarSource
],
['trend toolbar keeps actions after four search columns', /grid-column:\s*5/, 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]
]
const failures = [

View File

@@ -38,6 +38,7 @@ import {
resolveAvailableStats,
validateTrendSelection
} from './utils/selectionRules'
import { normalizeSteadyLedgerTree } from './utils/ledgerTree'
import { buildSteadyTrendQueryPayload, defaultTrendFormState } from './utils/trendPayload'
defineOptions({
@@ -80,7 +81,8 @@ const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
loading.ledger = true
try {
const response = await getSteadyTrendLedgerTree(keyword ? { keyword } : undefined)
ledgerTree.value = unwrapData(response) || []
// 台账树接口在搜索场景可能返回扁平节点,前端统一恢复工程、项目、设备、监测点层级。
ledgerTree.value = normalizeSteadyLedgerTree(unwrapData(response) || [])
const firstLedgerNode = findFirstSelectableLedgerNode(ledgerTree.value)
// 台账树首次加载后默认选中第一个可查询监测点,避免趋势查询初始状态为空。
selectedLedgerNodes.value = firstLedgerNode ? [firstLedgerNode] : []
@@ -184,6 +186,7 @@ onMounted(() => {
<style scoped lang="scss">
.steady-data-view-page {
height: 100%;
min-height: 0;
overflow: hidden;
}

View File

@@ -0,0 +1,170 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
type LedgerLevel = SteadyDataView.SteadyLedgerNode['level']
type RawLedgerNode = Partial<SteadyDataView.SteadyLedgerNode> & Record<string, unknown>
type IndexedLedgerNode = SteadyDataView.SteadyLedgerNode & {
parentIds?: string
__order: number
}
const resolveText = (data: RawLedgerNode, ...keys: string[]) => {
for (const key of keys) {
const value = data[key]
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
const resolveNumber = (data: RawLedgerNode, ...keys: string[]) => {
for (const key of keys) {
const value = data[key]
if (value === null || value === undefined || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
const resolveBoolean = (data: RawLedgerNode, ...keys: string[]) => {
for (const key of keys) {
const value = data[key]
if (value === null || value === undefined || value === '') continue
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
const text = String(value).trim().toLowerCase()
if (text === 'true' || text === '1') return true
if (text === 'false' || text === '0') return false
}
return undefined
}
const normalizeLevel = (value: unknown): LedgerLevel => {
const level = Number(value)
if (level === 0 || level === 1 || level === 2 || level === 3) {
return level
}
return 0
}
const splitParentIds = (parentIds?: string) => {
if (!parentIds) return []
return parentIds
.split(/[,\s/|>]+/)
.map(item => item.trim())
.filter(Boolean)
}
const flattenLedgerNodes = (
nodes: SteadyDataView.SteadyLedgerNode[],
output: IndexedLedgerNode[] = [],
inheritedParentId = ''
) => {
nodes.forEach(node => {
const rawNode = node as RawLedgerNode
const children = Array.isArray(node.children) ? node.children : []
const id = resolveText(rawNode, 'id', 'Id')
const parentId = resolveText(rawNode, 'parentId', 'pid', 'Pid') || inheritedParentId
const level = normalizeLevel(rawNode.level ?? rawNode.Level)
const rawSelectable = resolveBoolean(rawNode, 'selectable', 'Selectable')
if (!id) return
if (level === 3 && rawSelectable === false) return
output.push({
id,
parentId,
parentIds: resolveText(rawNode, 'parentIds', 'pids', 'Pids'),
name: resolveText(rawNode, 'name', 'Name') || id,
level,
sort: resolveNumber(rawNode, 'sort', 'Sort'),
deviceCount: resolveNumber(rawNode, 'deviceCount', 'DeviceCount'),
lineCount: resolveNumber(rawNode, 'lineCount', 'LineCount'),
selectable: level === 3 ? rawSelectable !== false : rawSelectable === true,
children: [],
__order: output.length
})
flattenLedgerNodes(children, output, id)
})
return output
}
const resolveExpectedParentId = (node: IndexedLedgerNode, nodeMap: Map<string, IndexedLedgerNode>) => {
if (node.level === 0) return ''
const parentNode = node.parentId ? nodeMap.get(node.parentId) : undefined
if (parentNode && parentNode.level === node.level - 1) {
return parentNode.id
}
// 后端搜索可能返回扁平节点或错误嵌套,优先按 parentIds 中的上一层节点恢复固定台账层级。
const parentIds = splitParentIds(node.parentIds).reverse()
const matchedParent = parentIds.map(id => nodeMap.get(id)).find(item => item && item.level === node.level - 1)
return matchedParent?.id || node.parentId || ''
}
const sortLedgerNodes = (left: IndexedLedgerNode, right: IndexedLedgerNode) => {
const leftSort = Number.isFinite(left.sort) ? Number(left.sort) : left.__order
const rightSort = Number.isFinite(right.sort) ? Number(right.sort) : right.__order
if (leftSort !== rightSort) return leftSort - rightSort
return left.__order - right.__order
}
const stripInternalFields = (node: IndexedLedgerNode): SteadyDataView.SteadyLedgerNode => {
return {
id: node.id,
parentId: node.parentId,
name: node.name,
level: node.level,
sort: node.sort,
deviceCount: node.deviceCount,
lineCount: node.lineCount,
selectable: node.selectable,
children: node.children?.map(item => stripInternalFields(item as IndexedLedgerNode))
}
}
export const normalizeSteadyLedgerTree = (
nodes: SteadyDataView.SteadyLedgerNode[] = []
): SteadyDataView.SteadyLedgerNode[] => {
const flatNodes = flattenLedgerNodes(nodes)
const nodeMap = new Map(flatNodes.map(node => [node.id, node]))
const roots: IndexedLedgerNode[] = []
flatNodes.forEach(node => {
node.children = []
})
flatNodes.forEach(node => {
const parentId = resolveExpectedParentId(node, nodeMap)
const parentNode = parentId ? nodeMap.get(parentId) : undefined
if (!parentNode || parentNode.id === node.id) {
roots.push(node)
return
}
node.parentId = parentNode.id
parentNode.children = [...(parentNode.children || []), node]
})
const sortChildren = (items: IndexedLedgerNode[]) => {
items.sort(sortLedgerNodes)
items.forEach(item => sortChildren((item.children || []) as IndexedLedgerNode[]))
}
sortChildren(roots)
return roots.map(stripInternalFields)
}

View File

@@ -3,11 +3,15 @@ import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
export const MAX_TREND_SERIES_COUNT = 24
export const MAX_HARMONIC_ORDER_COUNT = 6
const isSelectableLineNode = (node: SteadyDataView.SteadyLedgerNode) => {
return node.level === 3 && node.selectable !== false
}
export const collectSelectedLineIds = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
const lineIds = new Set<string>()
const collect = (node: SteadyDataView.SteadyLedgerNode) => {
if (node.level === 3 || node.selectable) {
if (isSelectableLineNode(node)) {
lineIds.add(node.id)
}
@@ -42,7 +46,7 @@ export const findFirstSelectableLedgerNode = (
nodes: SteadyDataView.SteadyLedgerNode[]
): SteadyDataView.SteadyLedgerNode | null => {
for (const node of nodes) {
if (node.level === 3 || node.selectable) {
if (isSelectableLineNode(node)) {
return node
}