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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,560 @@
### Welcome to the InfluxDB configuration file.
# The values in this file override the default values used by the system if
# a config option is not specified. The commented out lines are the configuration
# field and the default value used. Uncommenting a line and changing the value
# will change the value used at runtime when the process is restarted.
# Once every 24 hours InfluxDB will report usage data to usage.influxdata.com
# The data includes a random ID, os, arch, version, the number of series and other
# usage data. No data from user databases is ever transmitted.
# Change this option to true to disable reporting.
# reporting-disabled = false
# Bind address to use for the RPC service for backup and restore.
# bind-address = "127.0.0.1:8088"
###
### [meta]
###
### Controls the parameters for the Raft consensus group that stores metadata
### about the InfluxDB cluster.
###
[meta]
# Where the metadata/raft database is stored
dir = "/var/lib/influxdb/meta"
# Automatically create a default retention policy when creating a database.
# retention-autocreate = true
# If log messages are printed for the meta service
# logging-enabled = true
###
### [data]
###
### Controls where the actual shard data for InfluxDB lives and how it is
### flushed from the WAL. "dir" may need to be changed to a suitable place
### for your system, but the WAL settings are an advanced configuration. The
### defaults should work for most systems.
###
[data]
# The directory where the TSM storage engine stores TSM files.
dir = "/var/lib/influxdb/data"
# The directory where the TSM storage engine stores WAL files.
wal-dir = "/var/lib/influxdb/wal"
# The amount of time that a write will wait before fsyncing. A duration
# greater than 0 can be used to batch up multiple fsync calls. This is useful for slower
# disks or when WAL write contention is seen. A value of 0s fsyncs every write to the WAL.
# Values in the range of 0-100ms are recommended for non-SSD disks.
# wal-fsync-delay = "0s"
# The type of shard index to use for new shards. The default is an in-memory index that is
# recreated at startup. A value of "tsi1" will use a disk based index that supports higher
# cardinality datasets.
# index-version = "inmem"
# Trace logging provides more verbose output around the tsm engine. Turning
# this on can provide more useful output for debugging tsm engine issues.
# trace-logging-enabled = false
# Whether queries should be logged before execution. Very useful for troubleshooting, but will
# log any sensitive data contained within a query.
# query-log-enabled = true
# Validates incoming writes to ensure keys only have valid unicode characters.
# This setting will incur a small overhead because every key must be checked.
# validate-keys = false
# Settings for the TSM engine
# CacheMaxMemorySize is the maximum size a shard's cache can
# reach before it starts rejecting writes.
# Valid size suffixes are k, m, or g (case insensitive, 1024 = 1k).
# Values without a size suffix are in bytes.
# cache-max-memory-size = "1g"
# CacheSnapshotMemorySize is the size at which the engine will
# snapshot the cache and write it to a TSM file, freeing up memory
# Valid size suffixes are k, m, or g (case insensitive, 1024 = 1k).
# Values without a size suffix are in bytes.
# cache-snapshot-memory-size = "25m"
# CacheSnapshotWriteColdDuration is the length of time at
# which the engine will snapshot the cache and write it to
# a new TSM file if the shard hasn't received writes or deletes
# cache-snapshot-write-cold-duration = "10m"
# CompactFullWriteColdDuration is the duration at which the engine
# will compact all TSM files in a shard if it hasn't received a
# write or delete
# compact-full-write-cold-duration = "4h"
# The maximum number of concurrent full and level compactions that can run at one time. A
# value of 0 results in 50% of runtime.GOMAXPROCS(0) used at runtime. Any number greater
# than 0 limits compactions to that value. This setting does not apply
# to cache snapshotting.
# max-concurrent-compactions = 0
# CompactThroughput is the rate limit in bytes per second that we
# will allow TSM compactions to write to disk. Note that short bursts are allowed
# to happen at a possibly larger value, set by CompactThroughputBurst
# compact-throughput = "48m"
# CompactThroughputBurst is the rate limit in bytes per second that we
# will allow TSM compactions to write to disk.
# compact-throughput-burst = "48m"
# The threshold, in bytes, when an index write-ahead log file will compact
# into an index file. Lower sizes will cause log files to be compacted more
# quickly and result in lower heap usage at the expense of write throughput.
# Higher sizes will be compacted less frequently, store more series in-memory,
# and provide higher write throughput.
# Valid size suffixes are k, m, or g (case insensitive, 1024 = 1k).
# Values without a size suffix are in bytes.
# max-index-log-file-size = "1m"
# The maximum series allowed per database before writes are dropped. This limit can prevent
# high cardinality issues at the database level. This limit can be disabled by setting it to
# 0.
# max-series-per-database = 1000000
# The maximum number of tag values per tag that are allowed before writes are dropped. This limit
# can prevent high cardinality tag values from being written to a measurement. This limit can be
# disabled by setting it to 0.
# max-values-per-tag = 100000
# If true, then the mmap advise value MADV_WILLNEED will be provided to the kernel with respect to
# TSM files. This setting has been found to be problematic on some kernels, and defaults to off.
# It might help users who have slow disks in some cases.
# tsm-use-madv-willneed = false
###
### [coordinator]
###
### Controls the clustering service configuration.
###
[coordinator]
# The default time a write request will wait until a "timeout" error is returned to the caller.
# write-timeout = "10s"
# The maximum number of concurrent queries allowed to be executing at one time. If a query is
# executed and exceeds this limit, an error is returned to the caller. This limit can be disabled
# by setting it to 0.
# max-concurrent-queries = 0
# The maximum time a query will is allowed to execute before being killed by the system. This limit
# can help prevent run away queries. Setting the value to 0 disables the limit.
# query-timeout = "0s"
# The time threshold when a query will be logged as a slow query. This limit can be set to help
# discover slow or resource intensive queries. Setting the value to 0 disables the slow query logging.
# log-queries-after = "0s"
# The maximum number of points a SELECT can process. A value of 0 will make
# the maximum point count unlimited. This will only be checked every second so queries will not
# be aborted immediately when hitting the limit.
# max-select-point = 0
# The maximum number of series a SELECT can run. A value of 0 will make the maximum series
# count unlimited.
# max-select-series = 0
# The maxium number of group by time bucket a SELECT can create. A value of zero will max the maximum
# number of buckets unlimited.
# max-select-buckets = 0
###
### [retention]
###
### Controls the enforcement of retention policies for evicting old data.
###
[retention]
# Determines whether retention policy enforcement enabled.
# enabled = true
# The interval of time when retention policy enforcement checks run.
# check-interval = "30m"
###
### [shard-precreation]
###
### Controls the precreation of shards, so they are available before data arrives.
### Only shards that, after creation, will have both a start- and end-time in the
### future, will ever be created. Shards are never precreated that would be wholly
### or partially in the past.
[shard-precreation]
# Determines whether shard pre-creation service is enabled.
# enabled = true
# The interval of time when the check to pre-create new shards runs.
# check-interval = "10m"
# The default period ahead of the endtime of a shard group that its successor
# group is created.
# advance-period = "30m"
###
### Controls the system self-monitoring, statistics and diagnostics.
###
### The internal database for monitoring data is created automatically if
### if it does not already exist. The target retention within this database
### is called 'monitor' and is also created with a retention period of 7 days
### and a replication factor of 1, if it does not exist. In all cases the
### this retention policy is configured as the default for the database.
[monitor]
# Whether to record statistics internally.
# store-enabled = true
# The destination database for recorded statistics
# store-database = "_internal"
# The interval at which to record statistics
# store-interval = "10s"
###
### [http]
###
### Controls how the HTTP endpoints are configured. These are the primary
### mechanism for getting data into and out of InfluxDB.
###
[http]
# Determines whether HTTP endpoint is enabled.
# enabled = true
# Determines whether the Flux query endpoint is enabled.
# flux-enabled = false
# The bind address used by the HTTP service.
bind-address = ":18086"
# Determines whether user authentication is enabled over HTTP/HTTPS.
# auth-enabled = false
# The default realm sent back when issuing a basic auth challenge.
# realm = "InfluxDB"
# Determines whether HTTP request logging is enabled.
# log-enabled = true
# Determines whether the HTTP write request logs should be suppressed when the log is enabled.
# suppress-write-log = false
# When HTTP request logging is enabled, this option specifies the path where
# log entries should be written. If unspecified, the default is to write to stderr, which
# intermingles HTTP logs with internal InfluxDB logging.
#
# If influxd is unable to access the specified path, it will log an error and fall back to writing
# the request log to stderr.
# access-log-path = ""
# Filters which requests should be logged. Each filter is of the pattern NNN, NNX, or NXX where N is
# a number and X is a wildcard for any number. To filter all 5xx responses, use the string 5xx.
# If multiple filters are used, then only one has to match. The default is to have no filters which
# will cause every request to be printed.
# access-log-status-filters = []
# Determines whether detailed write logging is enabled.
# write-tracing = false
# Determines whether the pprof endpoint is enabled. This endpoint is used for
# troubleshooting and monitoring.
# pprof-enabled = true
# Enables a pprof endpoint that binds to localhost:6060 immediately on startup.
# This is only needed to debug startup issues.
# debug-pprof-enabled = false
# Determines whether HTTPS is enabled.
# https-enabled = false
# The SSL certificate to use when HTTPS is enabled.
# https-certificate = "/etc/ssl/influxdb.pem"
# Use a separate private key location.
# https-private-key = ""
# The JWT auth shared secret to validate requests using JSON web tokens.
# shared-secret = ""
# The default chunk size for result sets that should be chunked.
# max-row-limit = 0
# The maximum number of HTTP connections that may be open at once. New connections that
# would exceed this limit are dropped. Setting this value to 0 disables the limit.
# max-connection-limit = 0
# Enable http service over unix domain socket
# unix-socket-enabled = false
# The path of the unix domain socket.
# bind-socket = "/var/run/influxdb.sock"
# The maximum size of a client request body, in bytes. Setting this value to 0 disables the limit.
# max-body-size = 25000000
# The maximum number of writes processed concurrently.
# Setting this to 0 disables the limit.
# max-concurrent-write-limit = 0
# The maximum number of writes queued for processing.
# Setting this to 0 disables the limit.
# max-enqueued-write-limit = 0
# The maximum duration for a write to wait in the queue to be processed.
# Setting this to 0 or setting max-concurrent-write-limit to 0 disables the limit.
# enqueued-write-timeout = 0
###
### [logging]
###
### Controls how the logger emits logs to the output.
###
[logging]
# Determines which log encoder to use for logs. Available options
# are auto, logfmt, and json. auto will use a more a more user-friendly
# output format if the output terminal is a TTY, but the format is not as
# easily machine-readable. When the output is a non-TTY, auto will use
# logfmt.
# format = "auto"
# Determines which level of logs will be emitted. The available levels
# are error, warn, info, and debug. Logs that are equal to or above the
# specified level will be emitted.
# level = "info"
# Suppresses the logo output that is printed when the program is started.
# The logo is always suppressed if STDOUT is not a TTY.
# suppress-logo = false
###
### [subscriber]
###
### Controls the subscriptions, which can be used to fork a copy of all data
### received by the InfluxDB host.
###
[subscriber]
# Determines whether the subscriber service is enabled.
# enabled = true
# The default timeout for HTTP writes to subscribers.
# http-timeout = "30s"
# Allows insecure HTTPS connections to subscribers. This is useful when testing with self-
# signed certificates.
# insecure-skip-verify = false
# The path to the PEM encoded CA certs file. If the empty string, the default system certs will be used
# ca-certs = ""
# The number of writer goroutines processing the write channel.
# write-concurrency = 40
# The number of in-flight writes buffered in the write channel.
# write-buffer-size = 1000
###
### [[graphite]]
###
### Controls one or many listeners for Graphite data.
###
[[graphite]]
# Determines whether the graphite endpoint is enabled.
# enabled = false
# database = "graphite"
# retention-policy = ""
# bind-address = ":2003"
# protocol = "tcp"
# consistency-level = "one"
# These next lines control how batching works. You should have this enabled
# otherwise you could get dropped metrics or poor performance. Batching
# will buffer points in memory if you have many coming in.
# Flush if this many points get buffered
# batch-size = 5000
# number of batches that may be pending in memory
# batch-pending = 10
# Flush at least this often even if we haven't hit buffer limit
# batch-timeout = "1s"
# UDP Read buffer size, 0 means OS default. UDP listener will fail if set above OS max.
# udp-read-buffer = 0
### This string joins multiple matching 'measurement' values providing more control over the final measurement name.
# separator = "."
### Default tags that will be added to all metrics. These can be overridden at the template level
### or by tags extracted from metric
# tags = ["region=us-east", "zone=1c"]
### Each template line requires a template pattern. It can have an optional
### filter before the template and separated by spaces. It can also have optional extra
### tags following the template. Multiple tags should be separated by commas and no spaces
### similar to the line protocol format. There can be only one default template.
# templates = [
# "*.app env.service.resource.measurement",
# # Default template
# "server.*",
# ]
###
### [collectd]
###
### Controls one or many listeners for collectd data.
###
[[collectd]]
# enabled = false
# bind-address = ":25826"
# database = "collectd"
# retention-policy = ""
#
# The collectd service supports either scanning a directory for multiple types
# db files, or specifying a single db file.
# typesdb = "/usr/local/share/collectd"
#
# security-level = "none"
# auth-file = "/etc/collectd/auth_file"
# These next lines control how batching works. You should have this enabled
# otherwise you could get dropped metrics or poor performance. Batching
# will buffer points in memory if you have many coming in.
# Flush if this many points get buffered
# batch-size = 5000
# Number of batches that may be pending in memory
# batch-pending = 10
# Flush at least this often even if we haven't hit buffer limit
# batch-timeout = "10s"
# UDP Read buffer size, 0 means OS default. UDP listener will fail if set above OS max.
# read-buffer = 0
# Multi-value plugins can be handled two ways.
# "split" will parse and store the multi-value plugin data into separate measurements
# "join" will parse and store the multi-value plugin as a single multi-value measurement.
# "split" is the default behavior for backward compatability with previous versions of influxdb.
# parse-multivalue-plugin = "split"
###
### [opentsdb]
###
### Controls one or many listeners for OpenTSDB data.
###
[[opentsdb]]
# enabled = false
# bind-address = ":4242"
# database = "opentsdb"
# retention-policy = ""
# consistency-level = "one"
# tls-enabled = false
# certificate= "/etc/ssl/influxdb.pem"
# Log an error for every malformed point.
# log-point-errors = true
# These next lines control how batching works. You should have this enabled
# otherwise you could get dropped metrics or poor performance. Only points
# metrics received over the telnet protocol undergo batching.
# Flush if this many points get buffered
# batch-size = 1000
# Number of batches that may be pending in memory
# batch-pending = 5
# Flush at least this often even if we haven't hit buffer limit
# batch-timeout = "1s"
###
### [[udp]]
###
### Controls the listeners for InfluxDB line protocol data via UDP.
###
[[udp]]
# enabled = false
# bind-address = ":8089"
# database = "udp"
# retention-policy = ""
# InfluxDB precision for timestamps on received points ("" or "n", "u", "ms", "s", "m", "h")
# precision = ""
# These next lines control how batching works. You should have this enabled
# otherwise you could get dropped metrics or poor performance. Batching
# will buffer points in memory if you have many coming in.
# Flush if this many points get buffered
# batch-size = 5000
# Number of batches that may be pending in memory
# batch-pending = 10
# Will flush at least this often even if we haven't hit buffer limit
# batch-timeout = "1s"
# UDP Read buffer size, 0 means OS default. UDP listener will fail if set above OS max.
# read-buffer = 0
###
### [continuous_queries]
###
### Controls how continuous queries are run within InfluxDB.
###
[continuous_queries]
# Determines whether the continuous query service is enabled.
# enabled = true
# Controls whether queries are logged when executed by the CQ service.
# log-enabled = true
# Controls whether queries are logged to the self-monitoring data store.
# query-stats-enabled = false
# interval for how often continuous queries will be checked if they need to run
# run-interval = "1s"
###
### [tls]
###
### Global configuration settings for TLS in InfluxDB.
###
[tls]
# Determines the available set of cipher suites. See https://golang.org/pkg/crypto/tls/#pkg-constants
# for a list of available ciphers, which depends on the version of Go (use the query
# SHOW DIAGNOSTICS to see the version of Go used to build InfluxDB). If not specified, uses
# the default settings from Go's crypto/tls package.
# ciphers = [
# "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305",
# "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
# ]
# Minimum version of the tls protocol that will be negotiated. If not specified, uses the
# default settings from Go's crypto/tls package.
# min-version = "tls1.2"
# Maximum version of the tls protocol that will be negotiated. If not specified, uses the
# default settings from Go's crypto/tls package.
# max-version = "tls1.2"

View File

@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
influxd.exe -config "%~dp0influxdb.conf"
pause

View File

@@ -10,9 +10,9 @@ export const getSteadyTrendIndicatorTree = () => {
}
export const querySteadyTrend = (params: SteadyDataView.SteadyTrendQueryParams) => {
return http.post<SteadyDataView.SteadyTrendQueryResult>('/steady/data-view/trend/query', params)
return http.post<SteadyDataView.SteadyTrendQueryResult>('/steady/data-view/trend/query', params, { loading: false })
}
export const querySteadyTrendDay = (params: SteadyDataView.SteadyTrendQueryParams) => {
return http.post<SteadyDataView.SteadyTrendQueryResult>('/steady/data-view/trend/day', params)
return http.post<SteadyDataView.SteadyTrendQueryResult>('/steady/data-view/trend/day', params, { loading: false })
}

View File

@@ -6,6 +6,7 @@ type AddDataRequestMethod = 'get' | 'post'
const ADD_DATA_ROUTE_PATHS = ['/addData', '/api/addData'] as const
const ADD_DATA_BASE_URL = String(import.meta.env.VITE_API_URL || '').trim()
const ADD_DATA_INFLUX_STORAGE_TYPE = 'INFLUXDB'
const resolveDevProxyTarget = () => {
const proxyConfig = import.meta.env.VITE_PROXY
@@ -81,16 +82,25 @@ const requestAddData = async <T>(
throw lastError
}
const resolveTaskPathPrefix = (storageType?: AddData.StorageType) => {
// MySQL 与 InfluxDB 任务接口相互独立,创建和状态轮询必须使用同一入库类型前缀。
return storageType === ADD_DATA_INFLUX_STORAGE_TYPE ? '/influx/task' : '/task'
}
export const getAddDataStorageTypeList = () => {
return requestAddData<AddData.StorageTypeItem[]>('get', '/storage-type/list')
}
export const getAddDataPreview = (params: AddData.TaskRequestParams) => {
return requestAddData<AddData.PreviewResponse>('post', '/task/preview', params)
}
export const createAddDataTask = (params: AddData.TaskRequestParams) => {
return requestAddData<AddData.CreateTaskResponse>('post', '/task/create', params)
export const createAddDataTask = (params: AddData.TaskRequestParams, storageType?: AddData.StorageType) => {
return requestAddData<AddData.CreateTaskResponse>('post', `${resolveTaskPathPrefix(storageType)}/create`, params)
}
export const getAddDataTaskStatus = (taskId: string | number) => {
return requestAddData<AddData.TaskStatusResponse>('get', `/task/status/${taskId}`)
export const getAddDataTaskStatus = (taskId: string | number, storageType?: AddData.StorageType) => {
return requestAddData<AddData.TaskStatusResponse>('get', `${resolveTaskPathPrefix(storageType)}/status/${taskId}`)
}
export const getAddDataTemplateList = () => {

View File

@@ -1,6 +1,7 @@
export namespace AddData {
export type LineMode = 'single' | 'multiple'
export type IntervalMinutes = 1 | 3 | 5 | 10
export type StorageType = 'MYSQL' | 'INFLUXDB' | (string & {})
export type TaskStatus = 'WAITING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | (string & {})
export interface TaskRequestParams {
@@ -12,12 +13,18 @@ export namespace AddData {
export interface TaskFormModel {
lineMode: LineMode
storageType: StorageType
lineIds: string[]
startTime: string
endTime: string
intervalMinutes: IntervalMinutes
}
export interface StorageTypeItem {
code?: StorageType
name?: string
}
export interface PreviewTableStat {
tableName?: string
timePointCount?: number | string
@@ -106,4 +113,9 @@ export namespace AddData {
cp95ValueRule: string
decimalScaleText: string
}
export interface StorageTypeOption {
code: StorageType
name: string
}
}

View File

@@ -11,12 +11,23 @@ const apiSource = fs.readFileSync(apiFile, 'utf8')
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
const expectations = [
[
'equipment payload uses network param as single source for mac and ndid',
/const networkParam = resolveOptionalText\(params\.mac\)/.test(apiSource) &&
/ndid:\s*networkParam/.test(apiSource) &&
/mac:\s*networkParam/.test(apiSource)
],
['equipment payload maps devType', /devType:\s*params\.dev_type/],
['equipment payload maps devModel', /devModel:\s*params\.dev_model/],
['equipment payload maps devAccessMethod', /devAccessMethod:\s*params\.dev_access_method/],
['equipment payload maps nodeId', /nodeId:\s*params\.node_id/],
['equipment payload maps nodeProcess', /nodeProcess:\s*resolveOptionalNumber\(params\.node_process\)/],
['line payload maps lineId', /lineId:\s*resolveOptionalText\(params\.line_id\s*\|\|\s*params\.id\)/],
['line payload resolves lineId only from existing line', /const lineId = resolveOptionalText\(params\.id\s*\|\|\s*params\.line_id\)/],
['line payload omits empty lineId field', /if \(lineId\) \{\s*payload\.lineId = lineId\s*\}/],
[
'new line form does not generate lineId before backend save',
/line_id:\s*''/.test(fs.readFileSync(path.join(currentDir, '..', '..', '..', 'views', 'tools', 'addLedger', 'utils', 'ledgerData.ts'), 'utf8'))
],
['line payload maps lineNo', /lineNo:\s*params\.line_no/],
['line payload maps volGrade', /volGrade:\s*params\.vol_grade/],
['line payload maps ctRatio', /ctRatio:\s*params\.ct_ratio/],
@@ -26,7 +37,10 @@ const expectations = [
['delete response type is boolean', /requestAddLedger<boolean>\('delete',\s*'\/node'/]
]
const failures = expectations.filter(([, pattern]) => !pattern.test(`${apiSource}\n${interfaceSource}`))
const source = `${apiSource}\n${interfaceSource}`
const failures = expectations.filter(([, expectation]) =>
typeof expectation === 'boolean' ? !expectation : !expectation.test(source)
)
if (failures.length) {
console.error('addLedger API_DEBUG contract check failed:')

View File

@@ -27,41 +27,54 @@ const toAddLedgerProjectPayload = (params: AddLedger.ProjectForm) => ({
description: params.description
})
const toAddLedgerEquipmentPayload = (params: AddLedger.EquipmentForm) => ({
id: resolveOptionalText(params.id),
projectId: resolveOptionalText(params.projectId || params.parentId),
name: params.name,
ndid: params.ndid,
mac: params.mac,
devType: params.dev_type,
devModel: params.dev_model,
devAccessMethod: params.dev_access_method,
nodeId: params.node_id,
nodeProcess: resolveOptionalNumber(params.node_process),
upgrade: params.upgrade
})
const toAddLedgerEquipmentPayload = (params: AddLedger.EquipmentForm) => {
// 后端仍接收 mac/ndid 两个字段,前端统一用“装置网络参数”作为唯一来源。
const networkParam = resolveOptionalText(params.mac)
const toAddLedgerLinePayload = (params: AddLedger.LineForm) => ({
lineId: resolveOptionalText(params.line_id || params.id),
deviceId: resolveOptionalText(params.deviceId || params.parentId),
name: params.name,
lineNo: params.line_no,
conType: params.conType,
volGrade: params.vol_grade,
position: params.position,
ctRatio: params.ct_ratio,
ct2Ratio: params.ct2_ratio,
ptRatio: params.pt_ratio,
pt2Ratio: params.pt2_ratio,
shortCircuitCapacity: params.short_circuit_capacity,
basicCapacity: params.basic_capacity,
protocolCapacity: params.protocol_capacity,
devCapacity: params.dev_capacity,
monitorObj: params.monitor_obj,
isGovern: params.is_govern,
monitorUser: params.monitor_user,
isImportant: params.is_important
})
return {
id: resolveOptionalText(params.id),
projectId: resolveOptionalText(params.projectId || params.parentId),
name: params.name,
ndid: networkParam,
mac: networkParam,
devType: params.dev_type,
devModel: params.dev_model,
devAccessMethod: params.dev_access_method,
nodeId: params.node_id,
nodeProcess: resolveOptionalNumber(params.node_process),
upgrade: params.upgrade
}
}
const toAddLedgerLinePayload = (params: AddLedger.LineForm) => {
const lineId = resolveOptionalText(params.id || params.line_id)
const payload = {
deviceId: resolveOptionalText(params.deviceId || params.parentId),
name: params.name,
lineNo: params.line_no,
conType: params.conType,
volGrade: params.vol_grade,
position: params.position,
ctRatio: params.ct_ratio,
ct2Ratio: params.ct2_ratio,
ptRatio: params.pt_ratio,
pt2Ratio: params.pt2_ratio,
shortCircuitCapacity: params.short_circuit_capacity,
basicCapacity: params.basic_capacity,
protocolCapacity: params.protocol_capacity,
devCapacity: params.dev_capacity,
monitorObj: params.monitor_obj,
isGovern: params.is_govern,
monitorUser: params.monitor_user,
isImportant: params.is_important
} as Record<string, unknown>
if (lineId) {
payload.lineId = lineId
}
return payload
}
const resolveDevProxyTarget = () => {
const proxyConfig = import.meta.env.VITE_PROXY

View File

@@ -2,7 +2,8 @@ export const DICT_CODES = {
USER_STATE: 'state',
EVENT_TYPE: 'event_type',
LEDGER_DEVICE_TYPE: 'ledger_device_type',
LEDGER_DEVICE_MODEL: 'Ex-factory_Dev_Type'
LEDGER_DEVICE_MODEL: 'Ex-factory_Dev_Type',
LEDGER_TERMINAL_MODEL: 'Dev_Type'
} as const
export type DictCode = (typeof DICT_CODES)[keyof typeof DICT_CODES]

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
}

View File

@@ -19,6 +19,16 @@
/>
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="108px" class="task-form">
<div class="form-row form-row-storage">
<el-form-item class="form-item-storage-type" label="入库类型" prop="storageType">
<el-radio-group v-model="localForm.storageType" :disabled="taskRunning || storageTypeLoading">
<el-radio-button v-for="item in storageTypeOptions" :key="item.code" :label="item.code">
{{ item.name }}
</el-radio-button>
</el-radio-group>
</el-form-item>
</div>
<div class="form-row form-row-first">
<el-form-item class="form-item-line-ids" label="监测点 ID" prop="lineIds">
<div class="line-id-input-group">
@@ -143,6 +153,8 @@ defineOptions({
const props = defineProps<{
form: AddData.TaskFormModel
preview: AddData.NormalizedPreview | null
storageTypeOptions: AddData.StorageTypeOption[]
storageTypeLoading: boolean
previewLoading: boolean
submitLoading: boolean
taskRunning: boolean
@@ -161,6 +173,7 @@ const guidCount = ref(1)
const intervalOptions: AddData.IntervalMinutes[] = [1, 3, 5, 10]
const localForm = reactive<AddData.TaskFormModel>({
lineMode: 'multiple',
storageType: props.form.storageType,
lineIds: [...props.form.lineIds],
startTime: props.form.startTime,
endTime: props.form.endTime,
@@ -169,6 +182,7 @@ const localForm = reactive<AddData.TaskFormModel>({
const syncLocalForm = (form: AddData.TaskFormModel) => {
localForm.lineMode = 'multiple'
localForm.storageType = form.storageType
localForm.lineIds = [...form.lineIds]
localForm.startTime = form.startTime
localForm.endTime = form.endTime
@@ -216,6 +230,7 @@ watch(
emit('update:form', {
lineMode: 'multiple',
storageType: value.storageType,
lineIds: [...value.lineIds],
startTime: value.startTime,
endTime: value.endTime,
@@ -271,6 +286,7 @@ const handleAppendGuids = () => {
}
const formRules: FormRules<AddData.TaskFormModel> = {
storageType: [{ required: true, message: '请选择入库类型', trigger: 'change' }],
lineIds: [
{
validator: (_rule, value: string[], callback) => {
@@ -384,6 +400,10 @@ defineExpose({
grid-template-columns: minmax(0, 1fr);
}
.form-row-storage {
grid-template-columns: minmax(0, 1fr);
}
.form-row-second {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -394,11 +414,13 @@ defineExpose({
}
.form-item-line-ids,
.form-item-storage-type,
.form-item-interval {
margin-bottom: 0;
}
.form-item-line-ids :deep(.el-form-item__content),
.form-item-storage-type :deep(.el-form-item__content),
.form-item-interval :deep(.el-form-item__content) {
width: 100%;
}

View File

@@ -6,6 +6,8 @@
ref="taskPanelRef"
:form="taskForm"
:preview="previewSummary"
:storage-type-options="storageTypeOptions"
:storage-type-loading="loading.storageType"
:preview-loading="loading.preview"
:submit-loading="loading.create"
:task-running="taskRunning"
@@ -36,13 +38,20 @@ import {
createAddDataTask,
getAddDataPreview,
getAddDataTaskStatus,
getAddDataStorageTypeList,
getAddDataTemplateList
} from '@/api/tools/addData'
import type { AddData } from '@/api/tools/addData/interface'
import AddDataTaskPanel from './components/AddDataTaskPanel.vue'
import AddDataTaskStatusCard from './components/AddDataTaskStatusCard.vue'
import AddDataTemplateTable from './components/AddDataTemplateTable.vue'
import { normalizePreview, normalizeTaskStatus, normalizeTemplateItem, resolveText } from './utils/normalize'
import {
normalizePreview,
normalizeStorageTypeOption,
normalizeTaskStatus,
normalizeTemplateItem,
resolveText
} from './utils/normalize'
import { buildPayloadSignature, buildTaskPayload as buildTaskRequestPayload } from './utils/taskPayload'
defineOptions({
@@ -53,16 +62,24 @@ type AddDataTaskPanelExpose = {
validateTaskForm: () => Promise<boolean>
}
const DEFAULT_STORAGE_TYPE_OPTIONS: AddData.StorageTypeOption[] = [
{ code: 'MYSQL', name: 'MySQL' },
{ code: 'INFLUXDB', name: 'InfluxDB' }
]
const taskPanelRef = ref<AddDataTaskPanelExpose | null>(null)
const activeTab = ref('taskStatus')
const storageTypeOptions = ref<AddData.StorageTypeOption[]>([...DEFAULT_STORAGE_TYPE_OPTIONS])
const templateRows = ref<AddData.NormalizedTemplateItem[]>([])
const previewSummary = ref<AddData.NormalizedPreview | null>(null)
const taskStatus = ref<AddData.NormalizedTaskStatus | null>(null)
const currentTaskId = ref('')
const currentTaskStorageType = ref<AddData.StorageType>('MYSQL')
const previewSignature = ref('')
const pollTimer = ref<number | null>(null)
const pollingBusy = ref(false)
const loading = reactive({
storageType: false,
template: false,
preview: false,
create: false,
@@ -70,6 +87,7 @@ const loading = reactive({
})
const taskForm = reactive<AddData.TaskFormModel>({
lineMode: 'multiple',
storageType: 'MYSQL',
lineIds: [],
startTime: '',
endTime: '',
@@ -78,6 +96,7 @@ const taskForm = reactive<AddData.TaskFormModel>({
const handleTaskFormChange = (nextForm: AddData.TaskFormModel) => {
taskForm.lineMode = 'multiple'
taskForm.storageType = nextForm.storageType
taskForm.lineIds = [...nextForm.lineIds]
taskForm.startTime = nextForm.startTime
taskForm.endTime = nextForm.endTime
@@ -94,7 +113,7 @@ const buildTaskPayload = (): AddData.TaskRequestParams => {
}
const buildPreviewDependencySignature = () => {
return buildPayloadSignature(buildTaskPayload())
return buildPayloadSignature(buildTaskPayload(), taskForm.storageType)
}
const isTerminalStatus = (status?: AddData.TaskStatus) => {
@@ -106,6 +125,10 @@ const taskRunning = computed(() => {
return Boolean(currentTaskId.value && (status === 'WAITING' || status === 'RUNNING'))
})
const selectedStorageTypeName = computed(() => {
return storageTypeOptions.value.find(item => item.code === taskForm.storageType)?.name || taskForm.storageType
})
const stopPolling = () => {
if (pollTimer.value !== null) {
window.clearInterval(pollTimer.value)
@@ -113,6 +136,28 @@ const stopPolling = () => {
}
}
const loadStorageTypeOptions = async () => {
loading.storageType = true
try {
const response = await getAddDataStorageTypeList()
const rows = Array.isArray(response.data) ? response.data : []
const options = rows
.map(item => normalizeStorageTypeOption(item))
.filter((item): item is AddData.StorageTypeOption => Boolean(item))
storageTypeOptions.value = options.length ? options : [...DEFAULT_STORAGE_TYPE_OPTIONS]
if (!storageTypeOptions.value.some(item => item.code === taskForm.storageType)) {
taskForm.storageType = storageTypeOptions.value[0]?.code || 'MYSQL'
}
} catch {
// 入库类型接口用于联调校验;接口暂不可用时保留默认选项,避免阻断页面补数流程。
storageTypeOptions.value = [...DEFAULT_STORAGE_TYPE_OPTIONS]
} finally {
loading.storageType = false
}
}
const loadTemplateList = async () => {
loading.template = true
try {
@@ -137,7 +182,11 @@ const getValidatedPayload = async () => {
return payload
}
const loadTaskStatus = async (taskId = currentTaskId.value, silent = false) => {
const loadTaskStatus = async (
taskId = currentTaskId.value,
silent = false,
storageType = currentTaskStorageType.value
) => {
if (!taskId || pollingBusy.value) return
pollingBusy.value = true
@@ -146,7 +195,7 @@ const loadTaskStatus = async (taskId = currentTaskId.value, silent = false) => {
}
try {
const response = await getAddDataTaskStatus(taskId)
const response = await getAddDataTaskStatus(taskId, storageType)
// 状态接口是补数任务唯一的进度来源,统一在这里按正式契约归一化,避免页面层分散兼容字段。
const normalizedStatus = normalizeTaskStatus(response.data)
taskStatus.value = normalizedStatus
@@ -167,15 +216,16 @@ const loadTaskStatus = async (taskId = currentTaskId.value, silent = false) => {
}
}
const startPolling = (taskId: string) => {
const startPolling = (taskId: string, storageType: AddData.StorageType) => {
stopPolling()
currentTaskId.value = taskId
currentTaskStorageType.value = storageType
// 创建任务后先立即拉一次状态,再进入固定轮询,避免页面长时间停留在初始态。
void loadTaskStatus(taskId).catch(() => null)
void loadTaskStatus(taskId, false, storageType).catch(() => null)
pollTimer.value = window.setInterval(() => {
void loadTaskStatus(taskId, true).catch(() => null)
void loadTaskStatus(taskId, true, storageType).catch(() => null)
}, 3000)
}
@@ -188,7 +238,7 @@ const handlePreview = async () => {
const response = await getAddDataPreview(payload)
// preview 是 create 前的唯一准入检查,必须严格按正式契约读取 totalRowCount 和 tableStats。
previewSummary.value = normalizePreview(response.data)
previewSignature.value = buildPayloadSignature(payload)
previewSignature.value = buildPayloadSignature(payload, taskForm.storageType)
ElMessage.success('写入规模预估完成')
} finally {
loading.preview = false
@@ -204,7 +254,7 @@ const handleCreateTask = async () => {
const payload = await getValidatedPayload()
if (!payload) return
const currentSignature = buildPayloadSignature(payload)
const currentSignature = buildPayloadSignature(payload, taskForm.storageType)
if (!previewSummary.value || previewSignature.value !== currentSignature) {
ElMessage.warning('参数已变化,请先重新预估写入量')
return
@@ -212,7 +262,7 @@ const handleCreateTask = async () => {
try {
await ElMessageBox.confirm(
`预计写入 ${previewSummary.value.totalRowCount} 条数据,涉及 ${payload.lineIds.length} 个监测点,确认开始补数?`,
`预计${selectedStorageTypeName.value} 写入 ${previewSummary.value.totalRowCount} 条数据,涉及 ${payload.lineIds.length} 个监测点,确认开始补数?`,
'开始补数',
{
type: 'warning',
@@ -226,7 +276,8 @@ const handleCreateTask = async () => {
loading.create = true
try {
const response = await createAddDataTask(payload)
const storageType = taskForm.storageType
const response = await createAddDataTask(payload, storageType)
const taskId = resolveText(response.data?.taskId)
taskStatus.value = normalizeTaskStatus({
@@ -239,7 +290,7 @@ const handleCreateTask = async () => {
return
}
startPolling(taskId)
startPolling(taskId, storageType)
ElMessage.success('补数任务已创建,正在轮询状态')
} finally {
loading.create = false
@@ -255,7 +306,7 @@ watch(
)
onMounted(async () => {
await loadTemplateList()
await Promise.allSettled([loadStorageTypeOptions(), loadTemplateList()])
})
onBeforeUnmount(() => {

View File

@@ -71,6 +71,20 @@ export const normalizeTaskStatus = (data?: AddData.TaskStatusResponse | null): A
}
}
export const normalizeStorageTypeOption = (item: AddData.StorageTypeItem): AddData.StorageTypeOption | null => {
const code = resolveText(item.code).toUpperCase() as AddData.StorageType
const name = resolveText(item.name, code)
if (!code) {
return null
}
return {
code,
name
}
}
export const normalizeTemplateItem = (item: AddData.TemplateItem): AddData.NormalizedTemplateItem => {
const decimalScale = resolveText(item.decimalScale)

View File

@@ -19,6 +19,9 @@ export const buildTaskPayload = (form: AddData.TaskFormModel): AddData.TaskReque
}
}
export const buildPayloadSignature = (payload: AddData.TaskRequestParams) => {
return JSON.stringify(payload)
export const buildPayloadSignature = (payload: AddData.TaskRequestParams, storageType: AddData.StorageType) => {
return JSON.stringify({
storageType,
...payload
})
}

View File

@@ -2,12 +2,12 @@
<section class="card ledger-form-card">
<div class="form-header">
<div>
<div class="section-title">设备配置</div>
<div class="section-title">装置配置</div>
</div>
<div v-if="!readonly" class="form-actions">
<el-button type="primary" :icon="Check" :loading="saving" @click="emit('save')">保存设备</el-button>
<el-button type="primary" :icon="Check" :loading="saving" @click="emit('save')">保存装置</el-button>
<el-button type="danger" plain :icon="Delete" :disabled="!localForm.id" @click="emit('delete')">
删除设备
删除装置
</el-button>
</div>
</div>
@@ -23,9 +23,6 @@
<el-form-item label="装置名称" prop="name">
<el-input v-model="localForm.name" maxlength="80" clearable placeholder="请输入装置名称" />
</el-form-item>
<el-form-item v-if="!isSimpleMode" label="网络设备 ID" prop="ndid">
<el-input v-model="localForm.ndid" maxlength="64" clearable placeholder="请输入网络设备 ID" />
</el-form-item>
<el-form-item label="装置网络参数" prop="mac">
<el-input v-model="localForm.mac" maxlength="64" clearable placeholder="请输入装置网络参数" />
</el-form-item>
@@ -115,13 +112,15 @@ const localForm = reactive<AddLedger.EquipmentForm>({
})
const syncLocalForm = (form: AddLedger.EquipmentForm) => {
const networkParam = form.mac || form.ndid || ''
localForm.id = form.id || ''
localForm.engineeringId = form.engineeringId || ''
localForm.projectId = form.projectId || ''
localForm.parentId = form.parentId || ''
localForm.name = form.name || ''
localForm.ndid = form.ndid || ''
localForm.mac = form.mac || ''
localForm.ndid = networkParam
localForm.mac = networkParam
localForm.dev_type = form.dev_type || ''
localForm.dev_model = form.dev_model || ''
localForm.dev_access_method = form.dev_access_method || ''
@@ -147,7 +146,7 @@ watch(
return
}
emit('update:form', { ...value })
emit('update:form', { ...value, ndid: value.mac || '' })
},
{ deep: true }
)
@@ -155,7 +154,6 @@ watch(
const isSimpleMode = computed(() => props.mode === 'simple')
const formRules = computed<FormRules<AddLedger.EquipmentForm>>(() => ({
name: [{ required: true, message: '请输入装置名称', trigger: 'blur' }],
...(isSimpleMode.value ? {} : { ndid: [{ required: true, message: '请输入网络设备 ID', trigger: 'blur' }] }),
mac: [{ required: true, message: '请输入装置网络参数', trigger: 'blur' }],
dev_model: [{ required: true, message: '请选择装置型号', trigger: 'change' }]
}))

View File

@@ -15,13 +15,19 @@
v-if="ledgerContext.engineering || ledgerContext.projects.length"
v-model="projectActiveTab"
addable
class="ledger-level-tabs"
:class="[
'ledger-level-tabs',
{
'is-add-disabled': !props.canAddProject,
'project-add-disabled': !props.canAddProject
}
]"
@tab-click="pane => handleContextTabClick(pane, 1)"
@tab-add="emit('add-project')"
@tab-add="handleAddProject"
>
<template #add-icon>
<el-tooltip content="新增项目" placement="top">
<el-icon class="ledger-tab-add-icon">
<el-tooltip :content="props.canAddProject ? '新增项目' : props.addProjectDisabledReason" placement="top">
<el-icon :class="['ledger-tab-add-icon', { 'is-disabled': !props.canAddProject }]">
<CirclePlus />
</el-icon>
</el-tooltip>
@@ -44,13 +50,22 @@
v-if="ledgerContext.projects.length || ledgerContext.equipments.length"
v-model="equipmentActiveTab"
addable
class="ledger-level-tabs"
:class="[
'ledger-level-tabs',
{
'is-add-disabled': !props.canAddEquipment,
'equipment-add-disabled': !props.canAddEquipment
}
]"
@tab-click="pane => handleContextTabClick(pane, 2)"
@tab-add="emit('add-equipment')"
@tab-add="handleAddEquipment"
>
<template #add-icon>
<el-tooltip content="新增设备" placement="top">
<el-icon class="ledger-tab-add-icon">
<el-tooltip
:content="props.canAddEquipment ? '新增设备' : props.addEquipmentDisabledReason"
placement="top"
>
<el-icon :class="['ledger-tab-add-icon', { 'is-disabled': !props.canAddEquipment }]">
<CirclePlus />
</el-icon>
</el-tooltip>
@@ -83,13 +98,19 @@
v-if="ledgerContext.equipments.length || ledgerContext.lines.length"
v-model="lineActiveTab"
addable
class="ledger-level-tabs"
:class="[
'ledger-level-tabs',
{
'is-add-disabled': !props.canAddLine,
'line-add-disabled': !props.canAddLine
}
]"
@tab-click="pane => handleContextTabClick(pane, 3)"
@tab-add="emit('add-line')"
@tab-add="handleAddLine"
>
<template #add-icon>
<el-tooltip content="新增测点" placement="top">
<el-icon class="ledger-tab-add-icon">
<el-tooltip :content="props.canAddLine ? '新增测点' : props.addLineDisabledReason" placement="top">
<el-icon :class="['ledger-tab-add-icon', { 'is-disabled': !props.canAddLine }]">
<CirclePlus />
</el-icon>
</el-tooltip>
@@ -172,6 +193,12 @@ const props = defineProps<{
deviceModelOptions: AddLedger.SelectOption[]
lineNoOptions: AddLedger.SelectOption<number>[]
allLineNoOptions: AddLedger.SelectOption<number>[]
canAddProject: boolean
canAddEquipment: boolean
canAddLine: boolean
addProjectDisabledReason: string
addEquipmentDisabledReason: string
addLineDisabledReason: string
mode: 'simple' | 'full'
}>()
@@ -263,6 +290,24 @@ const handleContextTabClick = (pane: TabsPaneContext, level: AddLedger.NodeLevel
emit('tab-click', id, level)
}
const handleAddProject = () => {
if (!props.canAddProject) return
emit('add-project')
}
const handleAddEquipment = () => {
if (!props.canAddEquipment) return
emit('add-equipment')
}
const handleAddLine = () => {
if (!props.canAddLine) return
emit('add-line')
}
const validateActiveForm = async (level: AddLedger.NodeLevel | null) => {
if (level === 0) return Boolean(await engineeringFormRef.value?.validateForm())
if (level === 1) return Boolean(await projectFormRef.value?.validateForm())
@@ -320,10 +365,22 @@ defineExpose({
border-color: var(--el-color-primary-light-3);
}
.ledger-level-tabs.is-add-disabled :deep(.el-tabs__new-tab),
.ledger-level-tabs.is-add-disabled :deep(.el-tabs__new-tab:hover) {
color: var(--el-text-color-disabled);
cursor: not-allowed;
background-color: var(--el-fill-color-light);
border-color: var(--el-border-color);
}
.ledger-tab-add-icon {
font-size: 14px;
}
.ledger-tab-add-icon.is-disabled {
color: var(--el-text-color-disabled);
}
.ledger-level-tabs :deep(.el-tabs__content) {
overflow: visible;
}

View File

@@ -18,7 +18,7 @@
:rules="formRules"
:disabled="readonly"
label-width="146px"
:class="['ledger-form', isSimpleMode ? 'form-simple' : 'form-three']"
:class="['ledger-form', 'ledger-line-form', isSimpleMode ? 'form-simple' : 'form-three']"
>
<el-form-item label="监测点名" prop="name">
<el-input v-model="localForm.name" maxlength="80" clearable placeholder="请输入监测点名" />

View File

@@ -43,17 +43,24 @@
}
.ledger-form :deep(.el-form-item) {
display: flex;
align-items: flex-start;
min-width: 0;
min-height: 32px;
margin-right: 0 !important;
margin-bottom: 0;
}
.ledger-form :deep(.el-form-item__label) {
height: 32px;
line-height: 32px;
}
.ledger-form :deep(.el-form-item__content) {
display: flex;
align-items: center;
min-width: 0;
min-height: 32px;
line-height: 32px;
}
@@ -64,6 +71,11 @@
gap: 5px 14px;
}
.ledger-form.ledger-line-form {
grid-auto-rows: minmax(32px, auto);
row-gap: 2px;
}
.ledger-form.form-three :deep(.el-form-item),
.ledger-form.form-simple :deep(.el-form-item) {
width: 100%;
@@ -82,6 +94,10 @@
align-items: flex-start;
}
.ledger-form :deep(.form-item-wide > .el-form-item__content) {
align-items: flex-start;
}
.ratio-input-group {
display: flex;
width: 100%;
@@ -95,6 +111,11 @@
margin-bottom: 0;
}
.ledger-line-form .ratio-input-group :deep(.ratio-field) {
min-height: 32px;
margin-bottom: 0;
}
.ratio-input-group :deep(.ratio-field .el-form-item__content) {
display: block;
line-height: 32px;

View File

@@ -0,0 +1,77 @@
/* 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 pageDir = path.resolve(currentDir, '..')
const indexSource = fs.readFileSync(path.join(pageDir, 'index.vue'), 'utf8')
const contextPanelSource = fs.readFileSync(path.join(pageDir, 'components', 'LedgerContextPanel.vue'), 'utf8')
const expectations = [
[
'index passes add availability props',
/:can-add-project="canAddProject"/.test(indexSource) &&
/:can-add-equipment="canAddEquipment"/.test(indexSource) &&
/:can-add-line="canAddLine"/.test(indexSource) &&
/:add-project-disabled-reason="addProjectDisabledReason"/.test(indexSource) &&
/:add-equipment-disabled-reason="addEquipmentDisabledReason"/.test(indexSource) &&
/:add-line-disabled-reason="addLineDisabledReason"/.test(indexSource)
],
[
'index computes add availability from saved parents',
/const resolveEngineeringIdForAddProject = \(\) =>/.test(indexSource) &&
/const resolveProjectContextForAddEquipment = \(\) =>/.test(indexSource) &&
/const resolveDeviceIdForAddLine = \(\) =>/.test(indexSource) &&
/const canAddProject = computed\(\(\) => Boolean\(resolveEngineeringIdForAddProject\(\)\)\)/.test(indexSource) &&
/const canAddEquipment = computed\(\(\) => Boolean\(resolveProjectContextForAddEquipment\(\)\.projectId\)\)/.test(
indexSource
) &&
/const canAddLine = computed\(\(\) => Boolean\(resolveDeviceIdForAddLine\(\)\)\)/.test(indexSource)
],
[
'context panel declares availability props',
/canAddProject: boolean/.test(contextPanelSource) &&
/canAddEquipment: boolean/.test(contextPanelSource) &&
/canAddLine: boolean/.test(contextPanelSource) &&
/addProjectDisabledReason: string/.test(contextPanelSource) &&
/addEquipmentDisabledReason: string/.test(contextPanelSource) &&
/addLineDisabledReason: string/.test(contextPanelSource)
],
[
'context panel blocks disabled add events',
/const handleAddProject = \(\) => \{[\s\S]*if \(!props\.canAddProject\) return[\s\S]*emit\('add-project'\)/.test(
contextPanelSource
) &&
/const handleAddEquipment = \(\) => \{[\s\S]*if \(!props\.canAddEquipment\) return[\s\S]*emit\('add-equipment'\)/.test(
contextPanelSource
) &&
/const handleAddLine = \(\) => \{[\s\S]*if \(!props\.canAddLine\) return[\s\S]*emit\('add-line'\)/.test(
contextPanelSource
)
],
[
'context panel marks disabled add controls',
/project-add-disabled/.test(contextPanelSource) &&
/equipment-add-disabled/.test(contextPanelSource) &&
/line-add-disabled/.test(contextPanelSource) &&
/ledger-tab-add-icon/.test(contextPanelSource) &&
/'is-disabled':\s*!props\.canAddProject/.test(contextPanelSource) &&
/'is-disabled':\s*!props\.canAddEquipment/.test(contextPanelSource) &&
/'is-disabled':\s*!props\.canAddLine/.test(contextPanelSource) &&
/\.ledger-level-tabs\.is-add-disabled/.test(contextPanelSource)
]
]
const failures = expectations.filter(([, matched]) => !matched)
if (failures.length) {
console.error('addLedger add button availability contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('addLedger add button availability contract check passed')

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 pageDir = path.resolve(currentDir, '..')
const indexSource = fs.readFileSync(path.join(pageDir, 'index.vue'), 'utf8')
const expectations = [
[
'add equipment resolves first device type option as default',
/const resolveDefaultDeviceType = \(\) => \{[\s\S]*const defaultDeviceType = deviceTypeOptions\.value\[0\]\?\.value[\s\S]*return defaultDeviceType === undefined \|\| defaultDeviceType === null \? '' : String\(defaultDeviceType\)[\s\S]*\}/.test(
indexSource
)
],
[
'add equipment applies default device type to draft form',
/equipmentForm\.value = \{[\s\S]*\.\.\.createEmptyEquipmentForm\(projectId,\s*engineeringId\),[\s\S]*dev_type:\s*resolveDefaultDeviceType\(\)[\s\S]*\}/.test(
indexSource
)
]
]
const failures = expectations.filter(([, matched]) => !matched)
if (failures.length) {
console.error('addLedger equipment default contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('addLedger equipment default contract check passed')

View File

@@ -0,0 +1,56 @@
/* 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 pageDir = path.resolve(currentDir, '..')
const equipmentFormSource = fs.readFileSync(path.join(pageDir, 'components', 'EquipmentForm.vue'), 'utf8')
const ledgerDataSource = fs.readFileSync(path.join(pageDir, 'utils', 'ledgerData.ts'), 'utf8')
const expectations = [
[
'equipment form labels equipment as device unit',
/<div class="section-title">装置配置<\/div>/.test(equipmentFormSource) &&
/保存装置/.test(equipmentFormSource) &&
/删除装置/.test(equipmentFormSource) &&
/label="装置名称"[\s\S]*placeholder="请输入装置名称"/.test(equipmentFormSource) &&
/message:\s*'请输入装置名称'/.test(equipmentFormSource)
],
[
'equipment form hides independent network device id input',
!/label="网络设备 ID"/.test(equipmentFormSource) &&
!/placeholder="请输入网络设备 ID"/.test(equipmentFormSource) &&
!/ndid:\s*\[\{ required:\s*true/.test(equipmentFormSource)
],
[
'equipment form emits ndid from network param',
/const networkParam = form\.mac \|\| form\.ndid \|\| ''/.test(equipmentFormSource) &&
/localForm\.ndid = networkParam/.test(equipmentFormSource) &&
/localForm\.mac = networkParam/.test(equipmentFormSource) &&
/emit\('update:form', \{ \.\.\.value, ndid: value\.mac \|\| '' \}\)/.test(equipmentFormSource)
],
[
'equipment detail normalizes name and network param from compatible backend fields',
/const equipmentName = resolveString\(data, 'name', 'dev_name', 'devName', 'deviceName'\) \|\| node\?\.name \|\| ''/.test(
ledgerDataSource
) &&
/const networkParam = resolveString\(data, 'mac', 'ndid', 'unnid'\)/.test(ledgerDataSource) &&
/name:\s*equipmentName/.test(ledgerDataSource) &&
/ndid:\s*networkParam/.test(ledgerDataSource) &&
/mac:\s*networkParam/.test(ledgerDataSource)
]
]
const failures = expectations.filter(([, matched]) => !matched)
if (failures.length) {
console.error('addLedger equipment network param contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('addLedger equipment network param contract check passed')

View File

@@ -0,0 +1,71 @@
/* 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 pageDir = path.resolve(currentDir, '..')
const formStyleSource = fs.readFileSync(path.join(pageDir, 'components', 'ledgerForm.scss'), 'utf8')
const lineFormSource = fs.readFileSync(path.join(pageDir, 'components', 'LineForm.vue'), 'utf8')
const expectations = [
[
'form item uses a fixed single-line alignment baseline',
/\.ledger-form :deep\(\.el-form-item\) \{[\s\S]*display:\s*flex;[\s\S]*align-items:\s*flex-start;[\s\S]*min-height:\s*32px;[\s\S]*\}/.test(
formStyleSource
)
],
[
'form label has explicit 32px height',
/\.ledger-form :deep\(\.el-form-item__label\) \{[\s\S]*height:\s*32px;[\s\S]*line-height:\s*32px;[\s\S]*\}/.test(
formStyleSource
)
],
[
'form content centers single-line controls on the 32px baseline',
/\.ledger-form :deep\(\.el-form-item__content\) \{[\s\S]*display:\s*flex;[\s\S]*align-items:\s*center;[\s\S]*min-height:\s*32px;[\s\S]*line-height:\s*32px;[\s\S]*\}/.test(
formStyleSource
)
],
[
'textarea keeps top alignment instead of inheriting centered single-line controls',
/\.ledger-form :deep\(\.form-item-wide > \.el-form-item__content\) \{[\s\S]*align-items:\s*flex-start;[\s\S]*\}/.test(
formStyleSource
)
],
[
'ratio fields keep block layout for nested validation items',
/\.ratio-input-group :deep\(\.ratio-field \.el-form-item__content\) \{[\s\S]*display:\s*block;[\s\S]*\}/.test(
formStyleSource
)
],
[
'line form has a dedicated class for monitor point spacing',
/'ledger-line-form'/.test(lineFormSource)
],
[
'line form uses explicit grid row sizing and row gap',
/\.ledger-form\.ledger-line-form \{[\s\S]*grid-auto-rows:\s*minmax\(32px,\s*auto\);[\s\S]*row-gap:\s*2px;[\s\S]*\}/.test(
formStyleSource
)
],
[
'line form ratio field spacing is normalized',
/\.ledger-line-form \.ratio-input-group :deep\(\.ratio-field\) \{[\s\S]*min-height:\s*32px;[\s\S]*margin-bottom:\s*0;[\s\S]*\}/.test(
formStyleSource
)
]
]
const failures = expectations.filter(([, matched]) => !matched)
if (failures.length) {
console.error('addLedger form alignment contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('addLedger form alignment contract check passed')

View File

@@ -0,0 +1,42 @@
/* 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 pageDir = path.resolve(currentDir, '..')
const srcDir = path.resolve(pageDir, '..', '..', '..')
const constantsSource = fs.readFileSync(path.join(srcDir, 'constants', 'dictCodes.ts'), 'utf8')
const indexSource = fs.readFileSync(path.join(pageDir, 'index.vue'), 'utf8')
const equipmentFormSource = fs.readFileSync(path.join(pageDir, 'components', 'EquipmentForm.vue'), 'utf8')
const expectations = [
['dict code defines terminal model', /LEDGER_TERMINAL_MODEL:\s*'Dev_Type'/.test(constantsSource)],
[
'addLedger uses terminal model dict for equipment model options',
/typeof DICT_CODES\.LEDGER_TERMINAL_MODEL/.test(indexSource) &&
/resolveDictOptions\(DICT_CODES\.LEDGER_TERMINAL_MODEL,\s*fallbackDeviceModelOptions\)/.test(indexSource) &&
/ensureLedgerDictOptionById\(DICT_CODES\.LEDGER_TERMINAL_MODEL,\s*form\.dev_model\s*\|\|\s*''\)/.test(
indexSource
) &&
/loadLedgerDictOptionsByCode\(DICT_CODES\.LEDGER_TERMINAL_MODEL\)/.test(indexSource)
],
[
'equipment form labels terminal dict as device model',
/label="装置型号"[\s\S]*placeholder="请选择装置型号"/.test(equipmentFormSource) &&
/dev_model:\s*\[\{ required:\s*true,\s*message:\s*'请选择装置型号'/.test(equipmentFormSource)
]
]
const failures = expectations.filter(([, matched]) => !matched)
if (failures.length) {
console.error('addLedger terminal model contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('addLedger terminal model contract check passed')

View File

@@ -43,6 +43,12 @@
:device-model-options="deviceModelOptions"
:line-no-options="lineNoOptions"
:all-line-no-options="allLineNoOptions"
:can-add-project="canAddProject"
:can-add-equipment="canAddEquipment"
:can-add-line="canAddLine"
:add-project-disabled-reason="addProjectDisabledReason"
:add-equipment-disabled-reason="addEquipmentDisabledReason"
:add-line-disabled-reason="addLineDisabledReason"
:mode="ledgerFormMode"
@save-engineering="handleSaveEngineering"
@save-project="handleSaveProject"
@@ -100,7 +106,6 @@ import {
createEmptyLineForm,
createEmptyProjectForm,
findNodePath,
generateGuidText,
normalizeEngineeringDetail,
normalizeEquipmentDetail,
normalizeLineDetail,
@@ -127,7 +132,7 @@ type LedgerContextItem<T> = {
draft?: boolean
}
type LedgerDictCode = typeof DICT_CODES.LEDGER_DEVICE_TYPE | typeof DICT_CODES.LEDGER_DEVICE_MODEL
type LedgerDictCode = typeof DICT_CODES.LEDGER_DEVICE_TYPE | typeof DICT_CODES.LEDGER_TERMINAL_MODEL
const dictStore = useDictStore()
const treeData = ref<AddLedger.NormalizedTreeNode[]>([])
@@ -171,7 +176,7 @@ const activeTabIds = reactive({
})
const ledgerDictOptions = reactive<Record<LedgerDictCode, AddLedger.SelectOption[]>>({
[DICT_CODES.LEDGER_DEVICE_TYPE]: [],
[DICT_CODES.LEDGER_DEVICE_MODEL]: []
[DICT_CODES.LEDGER_TERMINAL_MODEL]: []
})
let detailRequestSeq = 0
@@ -188,11 +193,19 @@ const fallbackDeviceModelOptions: AddLedger.SelectOption[] = [
]
const deviceTypeOptions = computed(() => resolveDictOptions(DICT_CODES.LEDGER_DEVICE_TYPE, fallbackDeviceTypeOptions))
const deviceModelOptions = computed(() => resolveDictOptions(DICT_CODES.LEDGER_DEVICE_MODEL, fallbackDeviceModelOptions))
const deviceModelOptions = computed(() =>
resolveDictOptions(DICT_CODES.LEDGER_TERMINAL_MODEL, fallbackDeviceModelOptions)
)
const emptyStateText = computed(() =>
treeData.value.length === 0 ? '台账树为空,请先新增一个工程。' : '从左侧台账树选择工程、项目、设备或监测点。'
)
const resolveDefaultDeviceType = () => {
const defaultDeviceType = deviceTypeOptions.value[0]?.value
return defaultDeviceType === undefined || defaultDeviceType === null ? '' : String(defaultDeviceType)
}
const resolveFallbackDictOptions = (code: LedgerDictCode) =>
code === DICT_CODES.LEDGER_DEVICE_TYPE ? fallbackDeviceTypeOptions : fallbackDeviceModelOptions
@@ -238,7 +251,7 @@ const ensureLedgerDictOptionById = async (code: LedgerDictCode, value: string) =
const ensureEquipmentDictOptions = async (form: AddLedger.EquipmentForm) => {
await Promise.all([
ensureLedgerDictOptionById(DICT_CODES.LEDGER_DEVICE_TYPE, form.dev_type || ''),
ensureLedgerDictOptionById(DICT_CODES.LEDGER_DEVICE_MODEL, form.dev_model || '')
ensureLedgerDictOptionById(DICT_CODES.LEDGER_TERMINAL_MODEL, form.dev_model || '')
])
}
@@ -254,7 +267,7 @@ const loadLedgerDictOptionsByCode = (code: LedgerDictCode) => {
const loadLedgerDictOptions = async () => {
loadLedgerDictOptionsByCode(DICT_CODES.LEDGER_DEVICE_TYPE)
loadLedgerDictOptionsByCode(DICT_CODES.LEDGER_DEVICE_MODEL)
loadLedgerDictOptionsByCode(DICT_CODES.LEDGER_TERMINAL_MODEL)
}
const getCurrentPath = () => {
@@ -268,7 +281,52 @@ const resolveContext = () => {
return resolveContextFromPath(path)
}
const resolveEngineeringIdForAddProject = () => {
const context = resolveContext()
const engineeringIdFromContext = resolveSavedContextItemId(ledgerContext.engineering)
return activeLevel.value === 0
? engineeringForm.value.id || selectedNode.value?.id || ''
: context.engineeringId || engineeringIdFromContext
}
const resolveProjectContextForAddEquipment = () => {
const context = resolveContext()
const activeProjectItem = ledgerContext.projects.find(item => item.id === activeTabIds.project)
const projectIdFromTab = resolveSavedContextItemId(activeProjectItem)
const projectId =
activeLevel.value === 1
? projectForm.value.id || selectedNode.value?.id || ''
: context.projectId || projectIdFromTab
const engineeringId =
projectForm.value.engineeringId ||
context.engineeringId ||
activeProjectItem?.form.engineeringId ||
resolveSavedContextItemId(ledgerContext.engineering)
return {
projectId,
engineeringId
}
}
const resolveDeviceIdForAddLine = () => {
const context = resolveContext()
const activeEquipmentItem = ledgerContext.equipments.find(item => item.id === activeTabIds.equipment)
const deviceIdFromTab = resolveSavedContextItemId(activeEquipmentItem)
return activeLevel.value === 2
? equipmentForm.value.id || selectedNode.value?.id || ''
: context.deviceId || deviceIdFromTab
}
const allLineNoOptions = computed(() => buildLineNoOptions(Array.from({ length: 20 }, (_item, index) => index + 1)))
const canAddProject = computed(() => Boolean(resolveEngineeringIdForAddProject()))
const canAddEquipment = computed(() => Boolean(resolveProjectContextForAddEquipment().projectId))
const canAddLine = computed(() => Boolean(resolveDeviceIdForAddLine()))
const addProjectDisabledReason = computed(() => (canAddProject.value ? '' : '请先选择或保存父级工程'))
const addEquipmentDisabledReason = computed(() => (canAddEquipment.value ? '' : '请先选择或保存父级项目'))
const addLineDisabledReason = computed(() => (canAddLine.value ? '' : '请先选择或保存父级设备'))
const loadAvailableLineNoOptions = async (deviceId: string, lineId = '', currentLineNo?: number) => {
if (!deviceId) {
@@ -637,15 +695,10 @@ const handleAddEngineering = () => {
}
const handleAddProject = () => {
const context = resolveContext()
const engineeringIdFromContext = resolveSavedContextItemId(ledgerContext.engineering)
const engineeringId =
activeLevel.value === 0
? engineeringForm.value.id || selectedNode.value?.id || ''
: context.engineeringId || engineeringIdFromContext
const engineeringId = resolveEngineeringIdForAddProject()
if (!engineeringId) {
ElMessage.warning('请先选择或保存父级工程')
ElMessage.warning(addProjectDisabledReason.value)
return
}
@@ -662,27 +715,19 @@ const handleAddProject = () => {
}
const handleAddEquipment = () => {
const context = resolveContext()
const activeProjectItem = ledgerContext.projects.find(item => item.id === activeTabIds.project)
const projectIdFromTab = resolveSavedContextItemId(activeProjectItem)
const projectId =
activeLevel.value === 1
? projectForm.value.id || selectedNode.value?.id || ''
: context.projectId || projectIdFromTab
const engineeringId =
projectForm.value.engineeringId ||
context.engineeringId ||
activeProjectItem?.form.engineeringId ||
resolveSavedContextItemId(ledgerContext.engineering)
const { projectId, engineeringId } = resolveProjectContextForAddEquipment()
if (!projectId) {
ElMessage.warning('请先选择或保存父级项目')
ElMessage.warning(addEquipmentDisabledReason.value)
return
}
selectedNode.value = null
activeLevel.value = 2
equipmentForm.value = createEmptyEquipmentForm(projectId, engineeringId)
equipmentForm.value = {
...createEmptyEquipmentForm(projectId, engineeringId),
dev_type: resolveDefaultDeviceType()
}
ledgerContext.equipments = [
...ledgerContext.equipments.filter(item => !item.draft),
createDraftContextItem(draftIds.equipment, '新增设备', 2, equipmentForm.value)
@@ -692,16 +737,10 @@ const handleAddEquipment = () => {
}
const handleAddLine = async () => {
const context = resolveContext()
const activeEquipmentItem = ledgerContext.equipments.find(item => item.id === activeTabIds.equipment)
const deviceIdFromTab = resolveSavedContextItemId(activeEquipmentItem)
const deviceId =
activeLevel.value === 2
? equipmentForm.value.id || selectedNode.value?.id || ''
: context.deviceId || deviceIdFromTab
const deviceId = resolveDeviceIdForAddLine()
if (!deviceId) {
ElMessage.warning('请先选择或保存父级设备')
ElMessage.warning(addLineDisabledReason.value)
return
}
@@ -797,11 +836,6 @@ const handleSaveLine = async () => {
return
}
if (!lineForm.value.line_id) {
// 新增测点必须带 32 位 line_id后端仍负责最终唯一性校验。
lineForm.value.line_id = generateGuidText()
}
loading.saving = true
try {
const response = await saveAddLedgerLine(lineForm.value)

View File

@@ -52,7 +52,7 @@ export function createEmptyEquipmentForm(parentProjectId = '', parentEngineering
export function createEmptyLineForm(parentDeviceId = ''): AddLedger.LineForm {
return {
id: '',
line_id: generateGuidText(),
line_id: '',
deviceId: parentDeviceId,
parentId: parentDeviceId,
name: '',
@@ -184,15 +184,17 @@ export const normalizeEquipmentDetail = (
context: LedgerContextIds = { engineeringId: '', projectId: '', deviceId: '' }
): AddLedger.EquipmentForm => {
const data = (detail || {}) as Record<string, unknown>
const equipmentName = resolveString(data, 'name', 'dev_name', 'devName', 'deviceName') || node?.name || ''
const networkParam = resolveString(data, 'mac', 'ndid', 'unnid')
return {
id: resolveString(data, 'id', 'equipmentId') || node?.id || '',
engineeringId: resolveString(data, 'engineeringId', 'associated_engineering') || context.engineeringId,
projectId: resolveString(data, 'projectId', 'associated_project') || context.projectId,
parentId: context.projectId,
name: resolveString(data, 'name') || node?.name || '',
ndid: resolveString(data, 'ndid'),
mac: resolveString(data, 'mac'),
name: equipmentName,
ndid: networkParam,
mac: networkParam,
dev_type: resolveString(data, 'dev_type', 'devType'),
dev_model: resolveString(data, 'dev_model', 'devModel'),
dev_access_method: resolveString(data, 'dev_access_method', 'devAccessMethod'),