Compare commits
9 Commits
6687cf0339
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dff953b8d | |||
| d055a8e1a0 | |||
| 055e69fff7 | |||
| b9ddfb5275 | |||
| f1eaabae0e | |||
| 6755476969 | |||
| f9ed6c6245 | |||
| 609fdd5379 | |||
| 8b19e4a779 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
docs/
|
||||
.worktrees/
|
||||
out/
|
||||
logs/
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -43,7 +43,7 @@
|
||||
- `components/` 放当前页面专属展示块、弹窗、表格、工具栏和信息面板。组件通过 props / emits 与入口页通信,不直接越级调用页面接口状态。
|
||||
- `utils/` 放当前页面专属纯函数或弱状态工具,包括请求参数构造、接口返回归一化、树节点/表单模型转换、枚举选项、时间/数值格式化、图表坐标和导出数据拼装等。
|
||||
- 页面级类型优先复用 `api/**/interface/`;只服务页面内部组件的 UI 类型可放在当前页面 `components/types.ts` 或 `utils/*.ts`,不要扩散到全局类型。
|
||||
- 页面级 contract 脚本可放在对应页面目录下,验证结构拆分后的关键业务约束;当常量或逻辑从 `index.vue` 移到 `utils/` 时,需要同步更新脚本扫描范围。
|
||||
- 页面级 contract 脚本统一放在对应页面目录下的 `contracts/`,用于验证结构拆分后的关键业务约束;不要继续把 `check-*.mjs` 散放在页面根目录。脚本移动到 `contracts/` 后,需要同步修正 `index.vue`、`components/`、`utils/`、`api/**/interface/` 等扫描路径。
|
||||
- 复杂页面拆分优先顺序:先抽纯数据转换和常量,再抽独立展示块,最后再考虑组合式函数;不要在一次需求中顺手改变视觉、接口字段、交互流程或持久化时机。
|
||||
- `waveform` 趋势图规则仍以本文件后续“趋势图”章节为准,图表坐标、线宽、多图对齐等计算逻辑拆分后也必须保留对应 contract 检查。
|
||||
|
||||
@@ -81,7 +81,8 @@ PR 应包含:
|
||||
|
||||
- 必须显示纵坐标最大值和最小值,图表配置中应显式保留 `showMaxLabel` 与 `showMinLabel`。
|
||||
- 纵坐标刻度值采用均分方式生成,不再使用会改变刻度间隔的“友好刻度”取整逻辑。
|
||||
- 纵坐标最大值和最小值基于图形内真实最大值、最小值按 `1.2` 倍扩展;正数下边界使用 `0.8` 倍向下留白,负数上边界使用 `0.8` 倍向上留白,避免正数最小值或负数最大值被扩展到数据内侧。
|
||||
- 纵坐标最大值和最小值基于图形内真实最大值、最小值向外留白;当边界值绝对值 `> 1` 时,外扩边界使用 `1.05` 倍,内收边界使用 `0.95` 倍;当边界值绝对值 `<= 1` 时,继续使用 `1.15` / `0.85`,避免小数趋势图范围过窄或坐标轴退化。正数下边界使用内收倍数向下留白,负数上边界使用内收倍数向上留白,避免正数最小值或负数最大值被扩展到数据内侧。
|
||||
- 当数据整体绝对值 `> 1` 且真实波动范围较窄时,纵坐标可启用紧凑刻度候选:以 `1.015` / `0.985` 作为最小可接受留白边界,允许增加少量均分段数并使用更小的可读步长,优先减少上下空白;紧凑候选的额外分段惩罚应低于普通候选,避免 `200-240` 这类大空白方案因为分段更少而胜出。例如 220V 附近窄幅波动不应被可读步长放大到 `200-240`,更合理时可收敛到类似 `205-235` 的范围。
|
||||
- 当数据同时包含正负值且正负幅值接近时,纵坐标最大值和最小值应尽量对称,按较大绝对值向外取整后取 `±同一边界`,例如最大值 `178`、最小值 `-177` 时显示为 `180` 与 `-180`。
|
||||
- 当最大值、最小值相同或数据接近 `0` 时,需要补充兜底范围,避免坐标轴退化为一条线;小于 `1` 的小数范围按实际小数精度保留,不强制取整。
|
||||
- 当纵坐标区间较小且均分后出现冗长小数时,应优先使用 `1`、`2`、`2.5`、`5` 等可读步长归一化刻度;必要时可少量增加分段,但必须继续保证刻度均分、最大最小值显示、真实数据完整落在坐标范围内。
|
||||
@@ -94,6 +95,7 @@ PR 应包含:
|
||||
- 多图不得让 ECharts 按各自纵坐标标签宽度自动改变绘图区起点;应使用统一的 `grid.left`,并显式配置 `grid.containLabel: false` 或等效方案,避免 `150`、`2`、`-100` 等标签宽度差异导致曲线区域错位。
|
||||
- 纵坐标标签宽度预留应按同组图中最长标签统一评估,必要时增加统一的左侧 `grid.left`,不能为单张图单独调整左边距。
|
||||
- 横坐标首尾标签、单位文字或底部留白只能影响底部显示空间,不应改变绘图区左边界;调整 `grid.bottom`、`axisLabel.margin`、`nameGap` 时,需要同步检查多图 x=0 起点是否仍然对齐。
|
||||
- `waveform` 与 `steady` 多图联动时必须保留鼠标悬停竖线,趋势图 tooltip 应使用 `axisPointer.type: 'line'`,同一联动组图表应通过 ECharts `group` 同步 tooltip / axisPointer;T1/T2 等固定标记线属于 `markLine`,不得与悬停联动竖线混淆。
|
||||
- 验证多图趋势图时,至少检查单通道拆分图和全部通道列表图两种场景;判断标准是多张图左侧坐标轴竖线形成同一条垂直线,底部横坐标标签不遮挡、不贴线。
|
||||
|
||||
|
||||
@@ -103,5 +105,5 @@ PR 应包含:
|
||||
- 当前可见点数按横向缩放范围计算:`ceil(seriesDataLength * ((dataZoom.end - dataZoom.start) / 100))`。
|
||||
- 初始全量展示时,点数越多线越细;横向放大后可见点数减少,线宽可逐步变粗;横向缩小或重置后线宽恢复到对应细线档位。
|
||||
- Y 轴缩放、测量模式、峰值显示不改变主线线宽,避免状态切换造成额外视觉跳动。
|
||||
- 主线最大线宽不得超过 `1.6`。
|
||||
- 线宽分档统一为:`>= 200000` 使用 `0.35`,`100000 - 199999` 使用 `0.45`,`50000 - 99999` 使用 `0.55`,`20000 - 49999` 使用 `0.65`,`10000 - 19999` 使用 `0.75`,`5000 - 9999` 使用 `0.9`,`2000 - 4999` 使用 `1`,`800 - 1999` 使用 `1.2`,`200 - 799` 使用 `1.4`,`< 200` 使用 `1.6`。
|
||||
- 主线最大线宽不得超过 `1.3`。
|
||||
- 线宽分档统一为:`>= 200000` 使用 `0.35`,`100000 - 199999` 使用 `0.45`,`50000 - 99999` 使用 `0.55`,`20000 - 49999` 使用 `0.65`,`10000 - 19999` 使用 `0.75`,`5000 - 9999` 使用 `0.9`,`2000 - 4999` 使用 `1`,`800 - 1999` 使用 `1.1`,`200 - 799` 使用 `1.2`,`< 200` 使用 `1.3`。
|
||||
|
||||
125
BOOT-INF/classes/application.yml
Normal file
125
BOOT-INF/classes/application.yml
Normal file
@@ -0,0 +1,125 @@
|
||||
server:
|
||||
port: 18092
|
||||
spring:
|
||||
application:
|
||||
name: entrance
|
||||
datasource:
|
||||
druid:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
# url: jdbc:mysql://192.168.1.24:13306/pqs91002?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
|
||||
# username: root
|
||||
# password: njcnpqs
|
||||
url: jdbc:mysql://192.168.1.24:13306/pqs91002?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
|
||||
username: root
|
||||
password: njcnpqs
|
||||
#初始化建立物理连接的个数、最小、最大连接数
|
||||
initial-size: 5
|
||||
min-idle: 5
|
||||
max-active: 50
|
||||
#获取连接最大等待时间,单位毫秒
|
||||
max-wait: 60000
|
||||
#链接保持空间而不被驱逐的最长时间,单位毫秒
|
||||
min-evictable-idle-time-millis: 300000
|
||||
validation-query: select 1
|
||||
test-while-idle: true
|
||||
test-on-borrow: false
|
||||
test-on-return: false
|
||||
pool-prepared-statements: true
|
||||
max-pool-prepared-statement-per-connection-size: 20
|
||||
|
||||
#mybatis配置信息
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath*:com/njcn/**/mapping/*.xml
|
||||
#别名扫描
|
||||
type-aliases-package: com.njcn.gather.system.dictionary.pojo.po,com.njcn.gather.machine.pojo.po
|
||||
configuration:
|
||||
#驼峰命名
|
||||
map-underscore-to-camel-case: true
|
||||
#配置sql日志输出
|
||||
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
#关闭日志输出
|
||||
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
||||
global-config:
|
||||
db-config:
|
||||
#指定主键生成策略
|
||||
id-type: assign_uuid
|
||||
|
||||
|
||||
socket:
|
||||
source:
|
||||
ip: 127.0.0.1
|
||||
port: 62000
|
||||
device:
|
||||
ip: 127.0.0.1
|
||||
port: 61000
|
||||
# source:
|
||||
# ip: 192.168.1.121
|
||||
# port: 10086
|
||||
# device:
|
||||
# ip: 192.168.1.121
|
||||
# port: 61000
|
||||
|
||||
webSocket:
|
||||
port: 7777
|
||||
|
||||
#源参数下发,暂态数据默认值
|
||||
Dip:
|
||||
#暂态前时间(s)
|
||||
fPreTime: 2f
|
||||
#写入时间(s)
|
||||
fRampIn: 0.001f
|
||||
#写出时间(s)
|
||||
fRampOut: 0.001f
|
||||
#暂态后时间(s)
|
||||
fAfterTime: 3f
|
||||
|
||||
|
||||
Flicker:
|
||||
waveFluType: CPM
|
||||
waveType: SQU
|
||||
fDutyCycle: 50f
|
||||
|
||||
log:
|
||||
homeDir: D:\logs
|
||||
commonLevel: info
|
||||
report:
|
||||
template: D:\template
|
||||
reportDir: D:\report
|
||||
dateFormat: yyyy年MM月dd日
|
||||
data:
|
||||
homeDir: D:\data
|
||||
qr:
|
||||
cloud: http://pqmcc.com:18082/api/file
|
||||
dev:
|
||||
name: njcn
|
||||
password: Pqs@12345678
|
||||
port: 21
|
||||
path: /etc/qrc.bin
|
||||
gcDev:
|
||||
name: root
|
||||
password: Pqs@12345678
|
||||
port: 21
|
||||
path: /emmc/qrc.bin
|
||||
|
||||
db:
|
||||
type: mysql
|
||||
|
||||
|
||||
# 比对录波需要的配置,晚点再做优化
|
||||
# 系统配置
|
||||
power-quality:
|
||||
# 文件读取配置
|
||||
reading:
|
||||
encoding: GBK # 文件编码(支持中文)
|
||||
|
||||
# 计算参数
|
||||
calculation:
|
||||
sampling:
|
||||
default-rate: 256 # 默认采样率(每周波采样点数)
|
||||
harmonic-times: 50 # 谐波次数
|
||||
ib-add: false # 电流基波叠加标志
|
||||
uharm-add: false # 电压谐波叠加标志
|
||||
# 激活配置
|
||||
activate:
|
||||
private-key: "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcUyYhVqczGxblL+o/xZzF/8nf+LjrfUE/dS1aRHM7uMDD0cgCArhjtfneFePrMxt+Z7W8yNBzSarub8qsfhaVNikV7Es7oaeTygfjQXTi2n4AFkir3fM07J08RpWhl5M8f8uWTCuvFUYAw00gq55typqmnbkmJa2VIUy/iQf+cMCP7abz4/jNhUzUR3qA7TV4oMRgTdIEDUp63YF8dOC+JH8XxYrCVeHXV6fLCwmesdMzl0lB2VTEKMfLbXhOmF5g7P9y/16VCcN8UBuZlbyYfn+GAxJOSbeHi5HshOKfoSuD7Jz+3WQZpNavOWjIFExKIU38/CvnJCOP7XBCqpSTAgMBAAECggEAYeWokWRE3TpvwiOZnUpR/aVMdVi75a3ROL5XIpqPV61B+t/bU3cEpl0GF9C5pUeiRi0IoStZb3mI9D1KPW/REKyUWkhabQO1gFYbTnRlkNOn6MILzKX4cwJjDaZeeo4EBPU7N+qHyOOXrU6hdH5FfxhMdV983ajm5eeuupxER1C2kAcIklTeVpTX6EKOgZb5LBp5ssOVm2P42pOauvcRozRcvZmqnErXmukv0H4l3EVNt4rHpTn9riHUC63e8JfiYzVaF6zuNUxv6nHEft0/SRMw11XSTnNfDzcKqgjz6ksFBS/6eQQYKESk+ONC53HUuYHFAknkwsPupDCT2W8FIQKBgQDLHT/xCU3nxGr4vFKBDNaO2D5oK20ECbBO4oDvLWWmQG7f+6TsMy8PgVdMnoL4RfqGlwFAKEpS6KVFHnBVqnNEhcdy9uCI7x7Xx8UnyUtxj1EDTm76uta9Ki9OrlqB6tImDM9+Ya3vGktW37ht4WOx2OsJRhG1dbf6RLwFlH7DWwKBgQDFBxvi5I1BR6hg6Tj7xd2SqOT2Y+BED3xuSYENhWbmMhLJDResaB7mjztbxlYaY2mOE0holWm2uDmVFFhMh4jYXik4hYH8nmDzq9mDpZCZ9pyjYqnAP8THoAa8EbgrUWB8A6BPH4iL3KbMnBfBKY0pIr2xrvnjQjNBAgta7KDRKQKBgCe6oe4wxrdF2TKsC2tIqpMoQxS3Icy/ZGgZr+SYuaBKTCWtoDW/UT40K3JGMxIDBhzbXphBCUCsVt9tM8Xd4EwP6tJW7dZ7B0pnve2pVwNwaAVAiz6p2yUHIle+jN+Koe5lZRSwYIg7WW81tWpwwsJfzqFyvjYDP6hJV4mz4ROvAoGAaRcdnKvjXApomShMqJ4lTPChD3q+SA8qg3jZSOj6tZXHx00gb2kp8jg7pPvpOTIFPy6x1Ha9aCRjMk0ju84fA6lVuzwa1S907wOehUVuF3Eeo1cgy9Y3k3KbpPyeixxgpkUY4JslLdSHc2NemD0dee951qhJyRmqVOZOQDUuoeECgYEAqBw2cAFk3vM97WY06TSldGA8ajVHx3BYRjj+zl62NTQthy8fw3tqxb3c5e8toOmZWKjZvDhg2TRLhsDDQWEYg3LZG87REqVIjgEPcpjNLidjygGX8n3JF2o0O5I/EMvl0s/+LVQONfduOBvhwDqr8QNisbLsyneiAq7umewMolo="
|
||||
public-key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnFMmIVanMxsW5S/qP8Wcxf/J3/i4631BP3UtWkRzO7jAw9HIAgK4Y7X53hXj6zMbfme1vMjQc0mq7m/KrH4WlTYpFexLO6Gnk8oH40F04tp+ABZIq93zNOydPEaVoZeTPH/LlkwrrxVGAMNNIKuebcqapp25JiWtlSFMv4kH/nDAj+2m8+P4zYVM1Ed6gO01eKDEYE3SBA1Ket2BfHTgviR/F8WKwlXh11enywsJnrHTM5dJQdlUxCjHy214TpheYOz/cv9elQnDfFAbmZW8mH5/hgMSTkm3h4uR7ITin6Erg+yc/t1kGaTWrzloyBRMSiFN/Pwr5yQjj+1wQqqUkwIDAQAB"
|
||||
BIN
build/extraResources/influxdb-1.7.0/influx.exe
Normal file
BIN
build/extraResources/influxdb-1.7.0/influx.exe
Normal file
Binary file not shown.
BIN
build/extraResources/influxdb-1.7.0/influx_inspect.exe
Normal file
BIN
build/extraResources/influxdb-1.7.0/influx_inspect.exe
Normal file
Binary file not shown.
BIN
build/extraResources/influxdb-1.7.0/influx_stress.exe
Normal file
BIN
build/extraResources/influxdb-1.7.0/influx_stress.exe
Normal file
Binary file not shown.
BIN
build/extraResources/influxdb-1.7.0/influx_tsm.exe
Normal file
BIN
build/extraResources/influxdb-1.7.0/influx_tsm.exe
Normal file
Binary file not shown.
BIN
build/extraResources/influxdb-1.7.0/influxd.exe
Normal file
BIN
build/extraResources/influxdb-1.7.0/influxd.exe
Normal file
Binary file not shown.
560
build/extraResources/influxdb-1.7.0/influxdb.conf
Normal file
560
build/extraResources/influxdb-1.7.0/influxdb.conf
Normal 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"
|
||||
4
build/extraResources/influxdb-1.7.0/start-influxdb.bat
Normal file
4
build/extraResources/influxdb-1.7.0/start-influxdb.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
influxd.exe -config "%~dp0influxdb.conf"
|
||||
pause
|
||||
@@ -0,0 +1,473 @@
|
||||
# Event List Export Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rename the existing event list export action to “事件导出”, add independent waveform export selection and download behavior, and show the visible event columns in the requested order with “暂降/暂升幅值(%)” before “持续时间(s)”.
|
||||
|
||||
**Architecture:** Keep all UI orchestration in `frontend/src/views/event/eventList/index.vue`, because the change is narrow and the page already owns table columns, export handlers, and row actions. Add one event-list API method and one request type for waveform export. Extend the existing eventList contract scripts so selection semantics and export payloads are checked without introducing a new test framework.
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`, Element Plus, TypeScript, existing `ProTable`, existing download hook, Node-based contract scripts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `frontend/src/views/event/eventList/check-search-layout-contract.mjs`: assert the event export button text and event export parameter source.
|
||||
- Modify `frontend/src/views/event/eventList/check-visible-contract.mjs`: assert the waveform selection column and selection rules.
|
||||
- Create `frontend/src/views/event/eventList/check-export-contract.mjs`: assert the new waveform export API, payload shape, button state, and cleanup behavior.
|
||||
- Modify `frontend/src/api/event/eventList/interface/index.ts`: add `TransientWaveformExportParams`.
|
||||
- Modify `frontend/src/api/event/eventList/index.ts`: add `exportTransientWaveforms`.
|
||||
- Modify `frontend/src/views/event/eventList/index.vue`: add independent waveform selection UI and handlers, rename event export, and wire waveform export.
|
||||
|
||||
## Task 1: Contract Checks For Export Behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/event/eventList/check-search-layout-contract.mjs`
|
||||
- Modify: `frontend/src/views/event/eventList/check-visible-contract.mjs`
|
||||
- Create: `frontend/src/views/event/eventList/check-export-contract.mjs`
|
||||
|
||||
- [ ] **Step 1: Update search layout contract for event export naming**
|
||||
|
||||
In `frontend/src/views/event/eventList/check-search-layout-contract.mjs`, keep the existing current-search assertion and add a check that the button is now labeled “事件导出”:
|
||||
|
||||
```js
|
||||
[
|
||||
'event export button is named explicitly',
|
||||
/<el-button[\s\S]*@click="handleEventExport"[\s\S]*>事件导出<\/el-button>/
|
||||
],
|
||||
[
|
||||
'event export uses the same current search params as the table request',
|
||||
/exportTransientEvents,[\s\S]*buildEventQueryParams\(resolveCurrentSearchParams\(searchParam\)\)/
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update visible contract for waveform selection column**
|
||||
|
||||
In `frontend/src/views/event/eventList/check-visible-contract.mjs`, add expectations that identify the manual waveform selection column and confirm the old business columns remain:
|
||||
|
||||
```js
|
||||
[
|
||||
'waveform selection column is rendered near the left side',
|
||||
/prop:\s*'waveformSelection'[\s\S]*label:\s*'波形选择'[\s\S]*headerRender:\s*renderWaveformSelectionHeader[\s\S]*render:\s*renderWaveformSelectionCell/
|
||||
],
|
||||
[
|
||||
'waveform selection requires event id, waveform flag and waveform path',
|
||||
/const isWaveformExportable[\s\S]*Boolean\(row\.eventId\)[\s\S]*Number\(row\.fileFlag\)\s*===\s*1[\s\S]*Boolean\(row\.wavePath\)/
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create export contract script**
|
||||
|
||||
Create `frontend/src/views/event/eventList/check-export-contract.mjs` with:
|
||||
|
||||
```js
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, 'index.vue')
|
||||
const apiFile = path.resolve(currentDir, '../../../api/event/eventList/index.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../api/event/eventList/interface/index.ts')
|
||||
const source = fs.readFileSync(pageFile, 'utf8')
|
||||
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
['waveform export api is imported by the page', /import \{[\s\S]*exportTransientWaveforms[\s\S]*\} from '@\/api\/event\/eventList'/],
|
||||
['waveform export button is present and disabled without selection', /<el-button[\s\S]*:disabled="!selectedWaveformRows\.length"[\s\S]*@click="handleWaveformExport"[\s\S]*>波形导出<\/el-button>/],
|
||||
['event export button does not depend on waveform selection', /<el-button type="primary" plain :icon="Download" @click="handleEventExport">事件导出<\/el-button>/],
|
||||
['waveform selected rows are tracked independently', /const selectedWaveformRows\s*=\s*ref<EventList\.TransientEventRecord\[\]>\(\[\]\)/],
|
||||
['waveform export payload uses event ids only', /eventIds:\s*exportableRows\.map\(row => row\.eventId\)/],
|
||||
['waveform export uses server filename download hook', /useDownloadWithServerFileName\(exportTransientWaveforms,\s*'事件波形导出'/],
|
||||
['waveform selection is cleaned after table reload triggers', /const clearWaveformSelection[\s\S]*selectedWaveformRows\.value\s*=\s*\[\]/],
|
||||
['waveform export api method exists', /export const exportTransientWaveforms\s*=\s*\(params:\s*EventList\.TransientWaveformExportParams\)/.test(apiSource)],
|
||||
['waveform export api path is stable', /downloadWithHeaders\('\/event\/list\/transient\/waveform\/export',\s*params\)/.test(apiSource)],
|
||||
['waveform export params type contains eventIds', /export interface TransientWaveformExportParams\s*\{[\s\S]*eventIds:\s*string\[\][\s\S]*\}/.test(interfaceSource)]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => {
|
||||
if (typeof pattern === 'boolean') return !pattern
|
||||
return !pattern.test(source)
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('eventList export contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('eventList export contract check passed')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run contracts and verify red**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node frontend/src/views/event/eventList/check-search-layout-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-visible-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-export-contract.mjs
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Existing checks may pass or fail depending on current user edits.
|
||||
- `check-export-contract.mjs` must fail with missing waveform export API, button, selection state, and payload expectations.
|
||||
|
||||
- [ ] **Step 5: Commit contract red state**
|
||||
|
||||
Do not commit a failing red state unless the user explicitly asks for granular commits. Keep it as local verification before implementation.
|
||||
|
||||
## Task 2: API Type And Export Method
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/event/eventList/interface/index.ts`
|
||||
- Modify: `frontend/src/api/event/eventList/index.ts`
|
||||
|
||||
- [ ] **Step 1: Add waveform export request type**
|
||||
|
||||
In `frontend/src/api/event/eventList/interface/index.ts`, add this interface inside `export namespace EventList` after `TransientPageParams`:
|
||||
|
||||
```ts
|
||||
export interface TransientWaveformExportParams {
|
||||
eventIds: string[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add waveform export API method**
|
||||
|
||||
In `frontend/src/api/event/eventList/index.ts`, add:
|
||||
|
||||
```ts
|
||||
export const exportTransientWaveforms = (params: EventList.TransientWaveformExportParams) => {
|
||||
return http.downloadWithHeaders('/event/list/transient/waveform/export', params)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run export contract and verify API checks improve**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node frontend/src/views/event/eventList/check-export-contract.mjs
|
||||
```
|
||||
|
||||
Expected: still fails because page UI and handlers are not implemented, but API method and type failures disappear.
|
||||
|
||||
## Task 3: Waveform Selection UI And State
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/event/eventList/index.vue`
|
||||
|
||||
- [ ] **Step 1: Add imports**
|
||||
|
||||
Change imports in `index.vue`:
|
||||
|
||||
```ts
|
||||
import { ElButton, ElCheckbox, ElRadioButton, ElRadioGroup } from 'element-plus'
|
||||
import { Download, RefreshRight, View } from '@element-plus/icons-vue'
|
||||
import {
|
||||
exportTransientEvents,
|
||||
exportTransientWaveforms,
|
||||
getTransientEventDetail,
|
||||
getTransientEventPage
|
||||
} from '@/api/event/eventList'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add waveform selection state**
|
||||
|
||||
Add near the existing page refs:
|
||||
|
||||
```ts
|
||||
const selectedWaveformRows = ref<EventList.TransientEventRecord[]>([])
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add exportable row helpers**
|
||||
|
||||
Add before `columns`:
|
||||
|
||||
```ts
|
||||
const isWaveformExportable = (row: EventList.TransientEventRecord) => {
|
||||
return Boolean(row.eventId) && Number(row.fileFlag) === 1 && Boolean(row.wavePath)
|
||||
}
|
||||
|
||||
const currentWaveformRows = computed(() => proTable.value?.tableData || [])
|
||||
const currentExportableWaveformRows = computed(() => currentWaveformRows.value.filter(isWaveformExportable))
|
||||
const selectedWaveformIds = computed(() => selectedWaveformRows.value.map(row => row.eventId))
|
||||
const selectedWaveformIdSet = computed(() => new Set(selectedWaveformIds.value))
|
||||
const isAllCurrentWaveformsSelected = computed(() => {
|
||||
const rows = currentExportableWaveformRows.value
|
||||
return rows.length > 0 && rows.every(row => selectedWaveformIdSet.value.has(row.eventId))
|
||||
})
|
||||
const isCurrentWaveformSelectionIndeterminate = computed(() => {
|
||||
const rows = currentExportableWaveformRows.value
|
||||
if (!rows.length) return false
|
||||
const selectedCount = rows.filter(row => selectedWaveformIdSet.value.has(row.eventId)).length
|
||||
return selectedCount > 0 && selectedCount < rows.length
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add selection mutators**
|
||||
|
||||
Add after the helper computed values:
|
||||
|
||||
```ts
|
||||
const clearWaveformSelection = () => {
|
||||
selectedWaveformRows.value = []
|
||||
}
|
||||
|
||||
const toggleWaveformRowSelection = (row: EventList.TransientEventRecord, checked: boolean) => {
|
||||
if (!isWaveformExportable(row)) return
|
||||
|
||||
if (checked) {
|
||||
if (!selectedWaveformIdSet.value.has(row.eventId)) {
|
||||
selectedWaveformRows.value = [...selectedWaveformRows.value, row]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectedWaveformRows.value = selectedWaveformRows.value.filter(item => item.eventId !== row.eventId)
|
||||
}
|
||||
|
||||
const toggleCurrentWaveformSelection = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
const currentIds = new Set(currentExportableWaveformRows.value.map(row => row.eventId))
|
||||
selectedWaveformRows.value = selectedWaveformRows.value.filter(row => !currentIds.has(row.eventId))
|
||||
return
|
||||
}
|
||||
|
||||
const nextRows = [...selectedWaveformRows.value]
|
||||
const nextIds = new Set(nextRows.map(row => row.eventId))
|
||||
currentExportableWaveformRows.value.forEach(row => {
|
||||
if (!nextIds.has(row.eventId)) {
|
||||
nextRows.push(row)
|
||||
nextIds.add(row.eventId)
|
||||
}
|
||||
})
|
||||
selectedWaveformRows.value = nextRows
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add render functions for the manual selection column**
|
||||
|
||||
Add:
|
||||
|
||||
```ts
|
||||
const renderWaveformSelectionHeader = () =>
|
||||
h(ElCheckbox, {
|
||||
modelValue: isAllCurrentWaveformsSelected.value,
|
||||
indeterminate: isCurrentWaveformSelectionIndeterminate.value,
|
||||
disabled: currentExportableWaveformRows.value.length === 0,
|
||||
'onUpdate:modelValue': (value: string | number | boolean) => toggleCurrentWaveformSelection(Boolean(value))
|
||||
})
|
||||
|
||||
const renderWaveformSelectionCell = ({ row }: { row: EventList.TransientEventRecord }) =>
|
||||
h(ElCheckbox, {
|
||||
modelValue: selectedWaveformIdSet.value.has(row.eventId),
|
||||
disabled: !isWaveformExportable(row),
|
||||
'onUpdate:modelValue': (value: string | number | boolean) => toggleWaveformRowSelection(row, Boolean(value))
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add waveform selection column**
|
||||
|
||||
In the `columns` array, add this before the index column:
|
||||
|
||||
```ts
|
||||
{
|
||||
prop: 'waveformSelection',
|
||||
label: '波形选择',
|
||||
fixed: 'left',
|
||||
width: 90,
|
||||
isSetting: false,
|
||||
headerRender: renderWaveformSelectionHeader,
|
||||
render: renderWaveformSelectionCell
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Place amplitude before duration in visible columns**
|
||||
|
||||
In the `columns` array, keep the visible event column order as `发生时刻`, `监测点名称`, `暂降/暂升幅值(%)`, `持续时间(s)`, `事件类型`, `相别` by placing the `featureAmplitude` column before the duration column.
|
||||
|
||||
- [ ] **Step 8: Clear waveform selection on reset and table request**
|
||||
|
||||
Update `handleSearchReset`:
|
||||
|
||||
```ts
|
||||
const handleSearchReset = () => {
|
||||
eventTimeUnit.value = 'month'
|
||||
eventTimeBaseDate.value = new Date()
|
||||
clearWaveformSelection()
|
||||
syncEventTimeRange()
|
||||
}
|
||||
```
|
||||
|
||||
Update `getTableList`:
|
||||
|
||||
```ts
|
||||
const getTableList = (params: EventSearchParams) => {
|
||||
// 分页查询按 API_DEBUG.md 转换时间范围与枚举筛选参数。
|
||||
clearWaveformSelection()
|
||||
return getTransientEventPage(buildEventQueryParams(resolveCurrentSearchParams(params)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run visible and export contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node frontend/src/views/event/eventList/check-visible-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-export-contract.mjs
|
||||
```
|
||||
|
||||
Expected: visible contract passes for the new column; export contract still fails until buttons and export handlers are renamed/wired.
|
||||
|
||||
## Task 4: Export Buttons And Handlers
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/event/eventList/index.vue`
|
||||
|
||||
- [ ] **Step 1: Replace table header buttons**
|
||||
|
||||
Replace the existing table header slot with:
|
||||
|
||||
```vue
|
||||
<template #tableHeader>
|
||||
<el-button type="primary" plain :icon="Download" @click="handleEventExport">事件导出</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Download"
|
||||
:disabled="!selectedWaveformRows.length"
|
||||
@click="handleWaveformExport"
|
||||
>
|
||||
波形导出
|
||||
</el-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rename event export handler**
|
||||
|
||||
Replace `handleExport` with:
|
||||
|
||||
```ts
|
||||
const handleEventExport = () => {
|
||||
const searchParam = (proTable.value?.searchParam || {}) as EventSearchParams
|
||||
useDownloadWithServerFileName(
|
||||
exportTransientEvents,
|
||||
'暂态事件列表',
|
||||
buildEventQueryParams(resolveCurrentSearchParams(searchParam)),
|
||||
false
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add waveform export handler**
|
||||
|
||||
Add after `handleEventExport`:
|
||||
|
||||
```ts
|
||||
const handleWaveformExport = () => {
|
||||
const exportableRows = selectedWaveformRows.value.filter(isWaveformExportable)
|
||||
|
||||
if (!exportableRows.length) {
|
||||
ElMessage.warning('请先选择存在波形的事件')
|
||||
return
|
||||
}
|
||||
|
||||
useDownloadWithServerFileName(
|
||||
exportTransientWaveforms,
|
||||
'事件波形导出',
|
||||
{
|
||||
eventIds: exportableRows.map(row => row.eventId)
|
||||
},
|
||||
false,
|
||||
'.zip'
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all eventList contract scripts**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node frontend/src/views/event/eventList/check-search-layout-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-visible-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-export-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-query-params-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-route-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-time-range-contract.mjs
|
||||
```
|
||||
|
||||
Expected: all scripts print `passed`.
|
||||
|
||||
## Task 5: Type And Lint Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only; no planned file edits.
|
||||
|
||||
- [ ] **Step 1: Run TypeScript check**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: command completes without TypeScript errors.
|
||||
|
||||
- [ ] **Step 2: Run lint if lightweight enough for the workspace**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: command completes without ESLint errors. If lint changes unrelated files because it uses `--fix`, inspect `git status --short` and keep only changes directly related to this task.
|
||||
|
||||
- [ ] **Step 3: Inspect final diff**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff -- frontend/src/views/event/eventList frontend/src/api/event/eventList
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Event list page contains the new independent waveform selection column.
|
||||
- API files contain only the waveform export type and method.
|
||||
- Existing unrelated worktree changes are not reverted or folded into this task.
|
||||
|
||||
## Task 6: Commit Implementation
|
||||
|
||||
**Files:**
|
||||
- Commit only files modified for this task.
|
||||
|
||||
- [ ] **Step 1: Stage task files only**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add -- frontend/src/views/event/eventList/index.vue frontend/src/views/event/eventList/check-search-layout-contract.mjs frontend/src/views/event/eventList/check-visible-contract.mjs frontend/src/views/event/eventList/check-export-contract.mjs frontend/src/api/event/eventList/index.ts frontend/src/api/event/eventList/interface/index.ts docs/superpowers/plans/2026-05-15-event-list-export-implementation-plan.md
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git commit -m "feat: add event waveform export selection"
|
||||
```
|
||||
|
||||
Expected: commit succeeds and includes only the implementation, contracts, API method, and this plan.
|
||||
@@ -0,0 +1,95 @@
|
||||
# eventList 事件导出与波形导出设计
|
||||
|
||||
## 背景
|
||||
|
||||
`frontend/src/views/event/eventList/index.vue` 当前只有一个“导出”按钮,导出接口为
|
||||
`/event/list/transient/export`,按当前查询条件导出事件列表。页面已有波形状态字段
|
||||
`fileFlag` 和波形路径 `wavePath`,行操作根据波形是否存在切换“查看波形”和“波形补招”。
|
||||
|
||||
本次需要:
|
||||
|
||||
- 将现有“导出”更名为“事件导出”。
|
||||
- 新增“波形导出”。
|
||||
- 波形导出支持单条或多条。
|
||||
- 表格增加用于波形导出的多选能力。
|
||||
- 有波形的行可选,无波形的行不可选。
|
||||
- 表头增加全选复选框。
|
||||
- 事件导出不受波形是否存在影响。
|
||||
|
||||
## 推荐方案
|
||||
|
||||
采用独立的“波形选择”列,不复用 `ProTable` 原生 `selection` 列。
|
||||
|
||||
原因是原生 `selection` 列只能表达一套选择状态。如果用 `selectable(row)` 按波形是否存在控制行是否可选,那么没有波形的事件也无法被选中用于事件导出,这与“事件导出不存在波形控制多选框是否可选的逻辑”冲突。
|
||||
|
||||
独立“波形选择”列只服务波形导出:
|
||||
|
||||
- 表头复选框控制当前页所有可导出波形行。
|
||||
- 行复选框只在 `fileFlag === 1 && wavePath` 时可选。
|
||||
- 无波形行禁用,且不参与全选、半选、导出参数。
|
||||
- 事件导出继续按当前查询条件导出,不读取波形选择状态。
|
||||
|
||||
## UI 行为
|
||||
|
||||
表格头部按钮按现有业务页面按钮风格:
|
||||
|
||||
- “事件导出”:`type="primary" plain`,图标继续使用 `Download`。
|
||||
- “波形导出”:`type="primary" plain`,图标继续使用 `Download`,没有选中波形时禁用。
|
||||
|
||||
表格左侧新增“波形选择”列,放在序号列之前或之后均可,优先靠近序号列:
|
||||
|
||||
- 表头为一个复选框。
|
||||
- 表头复选框仅统计当前页可导出波形行。
|
||||
- 当前页可导出波形行全部选中时为选中态。
|
||||
- 当前页可导出波形行部分选中时为半选态。
|
||||
- 当前页没有可导出波形行时禁用。
|
||||
- 切换页码、查询、重置后,应清理不在当前页的数据选择,避免旧页选中项误参与导出。
|
||||
|
||||
## 数据与接口
|
||||
|
||||
事件导出沿用现有接口:
|
||||
|
||||
```ts
|
||||
exportTransientEvents(params: EventList.TransientPageParams)
|
||||
```
|
||||
|
||||
波形导出新增接口方法,前端按 `eventIds` 传参:
|
||||
|
||||
```ts
|
||||
exportTransientWaveforms(params: EventList.TransientWaveformExportParams)
|
||||
```
|
||||
|
||||
参数类型:
|
||||
|
||||
```ts
|
||||
interface TransientWaveformExportParams {
|
||||
eventIds: string[]
|
||||
}
|
||||
```
|
||||
|
||||
接口路径按以下命名实现;如果后端最终提供不同路径,实施时只替换 API 方法中的路径:
|
||||
|
||||
```text
|
||||
POST /event/list/transient/waveform/export
|
||||
```
|
||||
|
||||
后端负责根据事件 ID 校验波形文件并打包导出。前端不直接拼装本地波形文件路径。
|
||||
|
||||
## 边界处理
|
||||
|
||||
- 行没有 `eventId` 时,不允许加入波形导出选择。
|
||||
- 行 `fileFlag` 不为 `1` 时,不允许加入波形导出选择。
|
||||
- 行 `wavePath` 为空时,不允许加入波形导出选择。
|
||||
- 点击“波形导出”时再次过滤可导出行,避免页面状态残留导致传入非法数据。
|
||||
- 如果过滤后没有可导出事件,提示用户先选择存在波形的事件。
|
||||
- 事件导出仍使用当前搜索条件,并保持原有时间范围构造逻辑。
|
||||
|
||||
## 验证方式
|
||||
|
||||
- 静态检查“事件导出”按钮文案已更新,且仍调用 `exportTransientEvents`。
|
||||
- 静态检查事件导出参数仍来自 `buildEventQueryParams(resolveCurrentSearchParams(searchParam))`。
|
||||
- 静态检查“波形导出”调用新增接口,并传入 `eventIds`。
|
||||
- 静态检查波形选择判断同时包含 `fileFlag === 1`、`wavePath` 和 `eventId`。
|
||||
- 手动验证当前页中无波形行复选框禁用,有波形行可选。
|
||||
- 手动验证表头全选只选择当前页有波形的行。
|
||||
- 手动验证查询、重置、翻页后旧选择不会误参与波形导出。
|
||||
@@ -37,6 +37,7 @@
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"print-js": "^1.6.0",
|
||||
"qs": "^6.11.2",
|
||||
"scichart": "^5.2.28",
|
||||
"screenfull": "^6.0.2",
|
||||
"semver": "^7.3.5",
|
||||
"sortablejs": "^1.15.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from '@/api'
|
||||
import type { EventList } from './interface'
|
||||
import type { Waveform } from '@/api/tools/waveform/interface'
|
||||
|
||||
export const getTransientEventPage = (params: EventList.TransientPageParams) => {
|
||||
return http.post<EventList.PageResult<EventList.TransientEventRecord>>('/event/list/transient/page', params)
|
||||
@@ -9,6 +10,14 @@ export const getTransientEventDetail = (eventId: string) => {
|
||||
return http.get<EventList.TransientEventRecord>(`/event/list/transient/${eventId}`)
|
||||
}
|
||||
|
||||
export const getTransientEventWave = (eventId: string) => {
|
||||
return http.get<Waveform.WaveComtradeResultVO>(`/event/list/transient/${eventId}/wave`)
|
||||
}
|
||||
|
||||
export const exportTransientEvents = (params: EventList.TransientPageParams) => {
|
||||
return http.downloadWithHeaders('/event/list/transient/export', params)
|
||||
}
|
||||
|
||||
export const exportTransientWaveforms = (params: EventList.TransientWaveformExportParams) => {
|
||||
return http.downloadWithHeaders('/event/list/transient/wave/export', params)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ export namespace EventList {
|
||||
startTimeEnd?: string
|
||||
eventType?: string
|
||||
phase?: string
|
||||
eventDescribe?: string
|
||||
event_describe?: string
|
||||
durationMin?: number
|
||||
durationMax?: number
|
||||
featureAmplitudeMin?: number
|
||||
featureAmplitudeMax?: number
|
||||
severityMin?: number
|
||||
severityMax?: number
|
||||
fileFlag?: number
|
||||
dealFlag?: number
|
||||
lineIds?: string[]
|
||||
@@ -28,25 +30,25 @@ export namespace EventList {
|
||||
lineName?: string
|
||||
}
|
||||
|
||||
export interface TransientWaveformExportParams {
|
||||
eventIds: string[]
|
||||
}
|
||||
|
||||
export interface TransientEventRecord {
|
||||
eventId: string
|
||||
measurementPointId?: string
|
||||
eventType?: string
|
||||
eventTypeName?: string
|
||||
equipmentName?: string
|
||||
mac?: string
|
||||
engineeringName?: string
|
||||
projectName?: string
|
||||
startTime?: string
|
||||
lineName?: string
|
||||
event_describe?: string
|
||||
eventDescribe?: string
|
||||
eventDescription?: string
|
||||
eventDesc?: string
|
||||
description?: string
|
||||
describe?: string
|
||||
remark?: string
|
||||
sagsource?: string
|
||||
phase?: string
|
||||
severity?: number
|
||||
duration?: number
|
||||
featureAmplitude?: number
|
||||
wavePath?: string
|
||||
|
||||
@@ -18,6 +18,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||
|
||||
export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||
loading?: boolean
|
||||
silentStatusError?: boolean
|
||||
}
|
||||
|
||||
const config = {
|
||||
@@ -109,6 +110,10 @@ class RequestHttp {
|
||||
}
|
||||
// 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
|
||||
if (data.code && data.code !== ResultEnum.SUCCESS) {
|
||||
if ((response.config as CustomAxiosRequestConfig).silentStatusError) {
|
||||
return Promise.reject(data)
|
||||
}
|
||||
|
||||
if (data.message.includes('&')) {
|
||||
let formattedMessage = data.message.split('&').join('<br>')
|
||||
if (data.message.includes(':')) {
|
||||
@@ -147,7 +152,9 @@ class RequestHttp {
|
||||
if (error.message.indexOf('timeout') !== -1) ElMessage.error('请求超时!请您稍后重试')
|
||||
if (error.message.indexOf('Network Error') !== -1) ElMessage.error('网络错误!请您稍后重试')
|
||||
// 根据服务器响应的错误状态码,做不同的处理
|
||||
if (response) checkStatus(response.status)
|
||||
if (response && !(error.config as CustomAxiosRequestConfig | undefined)?.silentStatusError) {
|
||||
checkStatus(response.status)
|
||||
}
|
||||
// 服务器结果都没有返回(可能服务器错误可能客户端断网),断网处理:可以跳转到断网页面
|
||||
if (!window.navigator.onLine) router.replace('/500')
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -10,9 +10,15 @@ 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 })
|
||||
}
|
||||
|
||||
export const querySteadyChecksquare = (params: SteadyDataView.SteadyChecksquareQueryParams) => {
|
||||
return http.post<SteadyDataView.SteadyChecksquareQueryResult>('/steady/data-view/checksquare/query', params, {
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,10 +40,8 @@ export namespace SteadyDataView {
|
||||
lineIds: string[]
|
||||
indicatorCodes: string[]
|
||||
statTypes: SteadyTrendStatType[]
|
||||
phases: string[]
|
||||
timeStart: string
|
||||
timeEnd: string
|
||||
bucket?: string
|
||||
qualityFlag?: number
|
||||
harmonicOrders?: number[]
|
||||
}
|
||||
@@ -61,6 +59,7 @@ export namespace SteadyDataView {
|
||||
indicatorName?: string
|
||||
seriesName?: string
|
||||
phase?: string
|
||||
harmonicOrder?: number
|
||||
statType?: SteadyTrendStatType
|
||||
unit?: string
|
||||
points: SteadyTrendPoint[]
|
||||
@@ -72,7 +71,69 @@ export namespace SteadyDataView {
|
||||
sourcePointCount?: number
|
||||
displayPointCount?: number
|
||||
loadableDays?: string[]
|
||||
queryTimeStart?: string
|
||||
queryTimeEnd?: string
|
||||
queryCompleted?: boolean
|
||||
series: SteadyTrendSeries[]
|
||||
}
|
||||
|
||||
export interface SteadyChecksquareQueryParams {
|
||||
lineId: string
|
||||
indicatorCodes: string[]
|
||||
timeStart: string
|
||||
timeEnd: string
|
||||
harmonicOrders?: number[]
|
||||
}
|
||||
|
||||
export interface SteadyChecksquareSegment {
|
||||
startTime: string
|
||||
endTime: string
|
||||
status: 'NORMAL' | 'MISSING' | string
|
||||
missingPointCount?: number
|
||||
durationMinutes?: number
|
||||
}
|
||||
|
||||
export interface SteadyChecksquareStatSummary {
|
||||
statType: SteadyTrendStatType
|
||||
supported: boolean
|
||||
hasData?: boolean
|
||||
expectedPointCount?: number
|
||||
actualPointCount?: number
|
||||
missingPointCount?: number
|
||||
missingRate?: number | null
|
||||
missingRateText?: string | null
|
||||
maxContinuousMissingMinutes?: number
|
||||
}
|
||||
|
||||
export interface SteadyChecksquareStatDetail {
|
||||
statType: SteadyTrendStatType
|
||||
supported: boolean
|
||||
segments: SteadyChecksquareSegment[]
|
||||
}
|
||||
|
||||
export interface SteadyChecksquareItem {
|
||||
itemKey: string
|
||||
indicatorCode: string
|
||||
indicatorName?: string
|
||||
harmonicOrder?: number | null
|
||||
hasData?: boolean
|
||||
expectedPointCount?: number
|
||||
actualPointCount?: number
|
||||
missingPointCount?: number
|
||||
missingRate?: number | null
|
||||
missingRateText?: string | null
|
||||
maxContinuousMissingMinutes?: number
|
||||
statSummaries: SteadyChecksquareStatSummary[]
|
||||
statDetails: SteadyChecksquareStatDetail[]
|
||||
children?: SteadyChecksquareItem[]
|
||||
}
|
||||
|
||||
export interface SteadyChecksquareQueryResult {
|
||||
lineId: string
|
||||
lineName?: string
|
||||
timeStart: string
|
||||
timeEnd: string
|
||||
intervalMinutes?: number
|
||||
items: SteadyChecksquareItem[]
|
||||
}
|
||||
}
|
||||
|
||||
18
frontend/src/api/steady/steadyTrend/index.ts
Normal file
18
frontend/src/api/steady/steadyTrend/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import http from '@/api'
|
||||
import type { SteadyTrend } from './interface'
|
||||
|
||||
export const getSteadyTrendLedgerTree = (params?: { keyword?: string }) => {
|
||||
return http.get<SteadyTrend.SteadyLedgerNode[]>('/steady/data-view/ledger-tree', params, { loading: false })
|
||||
}
|
||||
|
||||
export const getSteadyTrendIndicatorTree = () => {
|
||||
return http.get<SteadyTrend.SteadyIndicatorNode[]>('/steady/data-view/indicator-tree', {}, { loading: false })
|
||||
}
|
||||
|
||||
export const querySteadyTrend = (params: SteadyTrend.SteadyTrendQueryParams) => {
|
||||
return http.post<SteadyTrend.SteadyTrendQueryResult>('/steady/data-view/trend/query', params, { loading: false })
|
||||
}
|
||||
|
||||
export const querySteadyTrendDay = (params: SteadyTrend.SteadyTrendQueryParams) => {
|
||||
return http.post<SteadyTrend.SteadyTrendQueryResult>('/steady/data-view/trend/day', params, { loading: false })
|
||||
}
|
||||
77
frontend/src/api/steady/steadyTrend/interface/index.ts
Normal file
77
frontend/src/api/steady/steadyTrend/interface/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export namespace SteadyTrend {
|
||||
export interface SteadyLedgerNode {
|
||||
id: string
|
||||
parentId?: string
|
||||
name: string
|
||||
level: 0 | 1 | 2 | 3
|
||||
sort?: number
|
||||
deviceCount?: number
|
||||
lineCount?: number
|
||||
selectable?: boolean
|
||||
children?: SteadyLedgerNode[]
|
||||
}
|
||||
|
||||
export interface SteadyIndicatorSeriesField {
|
||||
field: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SteadyIndicatorNode {
|
||||
id?: string
|
||||
treeKey?: string
|
||||
indicatorCode?: string
|
||||
name: string
|
||||
groupCode?: string
|
||||
tableName?: string
|
||||
baseFields?: string[]
|
||||
phaseCodes?: string[]
|
||||
seriesFields?: SteadyIndicatorSeriesField[]
|
||||
supportStats?: SteadyTrendStatType[]
|
||||
harmonic?: boolean
|
||||
harmonicOrderStart?: number | null
|
||||
harmonicOrderEnd?: number | null
|
||||
unit?: string
|
||||
children?: SteadyIndicatorNode[]
|
||||
}
|
||||
|
||||
export type SteadyTrendStatType = 'AVG' | 'MAX' | 'MIN' | 'CP95'
|
||||
|
||||
export interface SteadyTrendQueryParams {
|
||||
lineIds: string[]
|
||||
indicatorCodes: string[]
|
||||
statTypes: SteadyTrendStatType[]
|
||||
timeStart: string
|
||||
timeEnd: string
|
||||
qualityFlag?: number
|
||||
harmonicOrders?: number[]
|
||||
}
|
||||
|
||||
export interface SteadyTrendPoint {
|
||||
time: string
|
||||
value: number | null
|
||||
}
|
||||
|
||||
export interface SteadyTrendSeries {
|
||||
seriesKey: string
|
||||
lineId: string
|
||||
lineName?: string
|
||||
indicatorCode: string
|
||||
indicatorName?: string
|
||||
seriesName?: string
|
||||
phase?: string
|
||||
harmonicOrder?: number
|
||||
statType?: SteadyTrendStatType
|
||||
unit?: string
|
||||
points: SteadyTrendPoint[]
|
||||
}
|
||||
|
||||
export interface SteadyTrendQueryResult {
|
||||
sampled?: boolean
|
||||
bucket?: string
|
||||
sourcePointCount?: number
|
||||
displayPointCount?: number
|
||||
loadableDays?: string[]
|
||||
series: SteadyTrendSeries[]
|
||||
}
|
||||
|
||||
}
|
||||
75
frontend/src/api/system/dbms/index.ts
Normal file
75
frontend/src/api/system/dbms/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import http from '@/api'
|
||||
import type { CustomAxiosRequestConfig } from '@/api'
|
||||
import type { Dbms } from '@/api/system/dbms/interface'
|
||||
|
||||
export const getDbmsOverview = () => {
|
||||
return http.get<Dbms.Overview>('/database/overview', {}, { loading: false })
|
||||
}
|
||||
|
||||
export const getDbmsConnectionList = (params: Dbms.ConnectionListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||
return http.post<Dbms.ConnectionPageData>('/database/connections/list', params, { loading: false, ...config })
|
||||
}
|
||||
|
||||
export const addDbmsConnection = (params: Dbms.ConnectionPayload) => {
|
||||
return http.post<boolean>('/database/connections/add', params)
|
||||
}
|
||||
|
||||
export const updateDbmsConnection = (params: Dbms.ConnectionPayload) => {
|
||||
return http.post<boolean>('/database/connections/update', params)
|
||||
}
|
||||
|
||||
export const deleteDbmsConnection = (params: Dbms.DeleteConnectionParams) => {
|
||||
return http.post<boolean>('/database/connections/delete', params)
|
||||
}
|
||||
|
||||
export const testDbmsConnection = (params: Dbms.TestConnectionParams) => {
|
||||
return http.post<Dbms.TestConnectionResult>('/database/connections/test', params)
|
||||
}
|
||||
|
||||
export const getDbmsTableList = (params: Dbms.TableListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||
return http.post<Dbms.TableRecord[]>('/database/connections/tables', params, config)
|
||||
}
|
||||
|
||||
export const createDbmsBackupTask = (params: Dbms.CreateBackupParams) => {
|
||||
return http.post<Dbms.TaskCreateResult>('/database/backups/create', params)
|
||||
}
|
||||
|
||||
export const getDbmsBackupTaskList = (params: Dbms.TaskListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||
return http.post<Dbms.TaskPageData>('/database/backups/tasks/list', params, { loading: false, ...config })
|
||||
}
|
||||
|
||||
export const getDbmsBackupTaskStatus = (taskId: string) => {
|
||||
return http.get<Dbms.TaskRecord>('/database/backups/tasks/status', { taskId }, { loading: false })
|
||||
}
|
||||
|
||||
export const stopDbmsBackupTask = (params: Dbms.StopBackupTaskParams) => {
|
||||
return http.post<boolean>('/database/backups/tasks/stop', params)
|
||||
}
|
||||
|
||||
export const restartDbmsBackupTask = (params: Dbms.RestartBackupTaskParams) => {
|
||||
return http.post<Dbms.TaskCreateResult>('/database/backups/tasks/restart', params)
|
||||
}
|
||||
|
||||
export const getDbmsBackupFileList = (params: Dbms.FileListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||
return http.post<Dbms.BackupFilePageData>('/database/backups/files/list', params, { loading: false, ...config })
|
||||
}
|
||||
|
||||
export const createDbmsRestoreTask = (params: Dbms.CreateRestoreParams) => {
|
||||
return http.post<Dbms.TaskCreateResult>('/database/restores/create', params)
|
||||
}
|
||||
|
||||
export const getDbmsRestoreTaskStatus = (taskId: string) => {
|
||||
return http.get<Dbms.TaskRecord>('/database/restores/tasks/status', { taskId }, { loading: false })
|
||||
}
|
||||
|
||||
export const getDbmsRestoreTaskList = (params: Dbms.TaskListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||
return http.post<Dbms.TaskPageData>('/database/restores/tasks/list', params, { loading: false, ...config })
|
||||
}
|
||||
|
||||
export const deleteDbmsBackupFile = (params: Dbms.DeleteBackupFileParams) => {
|
||||
return http.post<boolean>('/database/delete/backup-file', params)
|
||||
}
|
||||
|
||||
export const deleteDbmsTask = (params: Dbms.DeleteTaskParams) => {
|
||||
return http.post<boolean>('/database/delete/task', params)
|
||||
}
|
||||
205
frontend/src/api/system/dbms/interface/index.ts
Normal file
205
frontend/src/api/system/dbms/interface/index.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { ReqPage, ResPage } from '@/api/interface'
|
||||
|
||||
export namespace Dbms {
|
||||
export type DbType = 'ORACLE' | 'MYSQL'
|
||||
export type ConnectType = 'SERVICE_NAME' | 'SID'
|
||||
export type BackupStrategy = 'DATA_PUMP' | 'JDBC_EXPORT'
|
||||
export type BackupMode = 'FULL_TABLE' | 'TIME_RANGE' | 'SIZE_SPLIT'
|
||||
export type OperationType = 'BACKUP' | 'RESTORE'
|
||||
export type TaskStatus = 'WAITING' | 'RUNNING' | 'SUCCESS' | 'FAIL' | 'FAILED' | 'CANCELLED'
|
||||
export type RestoreMode = 'SKIP' | 'APPEND' | 'TRUNCATE' | 'REPLACE'
|
||||
|
||||
export interface Overview {
|
||||
menuName: string
|
||||
menuCode: string
|
||||
path: string
|
||||
status: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ConnectionListParams extends ReqPage {
|
||||
connectionName?: string
|
||||
dbType?: DbType
|
||||
schemaName?: string
|
||||
}
|
||||
|
||||
export interface ConnectionRecord {
|
||||
id: string
|
||||
connectionName: string
|
||||
dbType: DbType
|
||||
host: string
|
||||
port: number
|
||||
connectType?: ConnectType | null
|
||||
serviceName?: string | null
|
||||
sid?: string | null
|
||||
databaseName?: string | null
|
||||
schemaName?: string | null
|
||||
username: string
|
||||
savePassword: 0 | 1
|
||||
directoryName?: string | null
|
||||
directoryPath?: string | null
|
||||
extraConfigJson?: string | null
|
||||
remark?: string | null
|
||||
lastTestStatus?: string | null
|
||||
lastTestMessage?: string | null
|
||||
lastTestTime?: string | null
|
||||
state?: number
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export interface ConnectionPayload {
|
||||
id?: string
|
||||
connectionName: string
|
||||
dbType: DbType
|
||||
host: string
|
||||
port: number
|
||||
connectType?: ConnectType | null
|
||||
serviceName?: string | null
|
||||
sid?: string | null
|
||||
databaseName?: string | null
|
||||
schemaName?: string | null
|
||||
username: string
|
||||
password?: string | null
|
||||
savePassword: 0 | 1
|
||||
directoryName?: string | null
|
||||
directoryPath?: string | null
|
||||
extraConfigJson?: string | null
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface DeleteConnectionParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TestConnectionParams {
|
||||
connectionId?: string
|
||||
connection?: ConnectionPayload
|
||||
temporaryPassword?: string
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface TableListParams {
|
||||
connectionId: string
|
||||
temporaryPassword?: string
|
||||
schemaName?: string
|
||||
}
|
||||
|
||||
export interface TableRecord {
|
||||
owner: string
|
||||
tableName: string
|
||||
autoIncrement?: number | string | null
|
||||
updateTime?: string | null
|
||||
dataLength?: number | string | null
|
||||
engine?: string | null
|
||||
tableRows?: number | string | null
|
||||
comments?: string | null
|
||||
}
|
||||
|
||||
export interface CreateBackupParams {
|
||||
connectionId: string
|
||||
backupStrategy?: BackupStrategy
|
||||
schemaName?: string
|
||||
targetNames?: string[]
|
||||
backupMode?: BackupMode
|
||||
timeColumn?: string | null
|
||||
startTime?: string | null
|
||||
endTime?: string | null
|
||||
maxFileSizeMb?: number | null
|
||||
directoryName?: string | null
|
||||
temporaryPassword?: string
|
||||
}
|
||||
|
||||
export interface CreateRestoreParams {
|
||||
connectionId: string
|
||||
backupFileId: string
|
||||
restoreMode?: RestoreMode
|
||||
targetSchemaName?: string
|
||||
temporaryPassword?: string
|
||||
overwriteConfirmText?: string | null
|
||||
}
|
||||
|
||||
export interface TaskCreateResult {
|
||||
taskId: string
|
||||
taskNo: string
|
||||
taskStatus: TaskStatus
|
||||
}
|
||||
|
||||
export interface StopBackupTaskParams {
|
||||
taskId: string
|
||||
}
|
||||
|
||||
export interface RestartBackupTaskParams {
|
||||
taskId: string
|
||||
temporaryPassword?: string
|
||||
}
|
||||
|
||||
export interface TaskListParams extends ReqPage {
|
||||
connectionId?: string
|
||||
taskStatus?: TaskStatus | ''
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
id: string
|
||||
taskNo: string
|
||||
connectionId: string
|
||||
dbType: DbType
|
||||
operationType: OperationType
|
||||
backupStrategy?: BackupStrategy | null
|
||||
restoreMode?: RestoreMode | null
|
||||
taskStatus: TaskStatus
|
||||
schemaName?: string | null
|
||||
targetNamesJson?: string | null
|
||||
resultMessage?: string | null
|
||||
progressPercent?: number | null
|
||||
startedAt?: string | null
|
||||
finishedAt?: string | null
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export interface FileListParams extends ReqPage {
|
||||
connectionId?: string
|
||||
taskId?: string
|
||||
backupStrategy?: BackupStrategy | ''
|
||||
}
|
||||
|
||||
export interface BackupFileRecord {
|
||||
id: string
|
||||
taskId: string
|
||||
connectionId: string
|
||||
dbType: DbType
|
||||
backupStrategy: BackupStrategy
|
||||
fileFormat?: string | null
|
||||
schemaName?: string | null
|
||||
targetNamesJson?: string | null
|
||||
backupMode?: BackupMode | null
|
||||
fileName: string
|
||||
filePath?: string | null
|
||||
metadataFilePath?: string | null
|
||||
logFileName?: string | null
|
||||
logFilePath?: string | null
|
||||
fileSize?: number | null
|
||||
checksum?: string | null
|
||||
state?: number
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
export interface DeleteBackupFileParams {
|
||||
backupFileId: string
|
||||
confirmText: string
|
||||
}
|
||||
|
||||
export interface DeleteTaskParams {
|
||||
taskId: string
|
||||
confirmText: string
|
||||
}
|
||||
|
||||
export interface ConnectionPageData extends ResPage<ConnectionRecord> {}
|
||||
export interface TaskPageData extends ResPage<TaskRecord> {}
|
||||
export interface BackupFilePageData extends ResPage<BackupFileRecord> {}
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:')
|
||||
|
||||
@@ -27,41 +27,55 @@ 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,
|
||||
lineType: params.lineType,
|
||||
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
|
||||
@@ -159,6 +173,14 @@ export const saveAddLedgerEquipment = (params: AddLedger.EquipmentForm) => {
|
||||
return requestAddLedger<AddLedger.EquipmentForm>('post', '/equipment/save', toAddLedgerEquipmentPayload(params))
|
||||
}
|
||||
|
||||
export const getAddLedgerEquipmentUnit = (params: { devId: string }) => {
|
||||
return requestAddLedger<AddLedger.EquipmentUnitForm>('get', '/equipment/unit', params)
|
||||
}
|
||||
|
||||
export const saveAddLedgerEquipmentUnit = (params: AddLedger.EquipmentUnitForm) => {
|
||||
return requestAddLedger<AddLedger.EquipmentUnitForm>('post', '/equipment/unit/save', params)
|
||||
}
|
||||
|
||||
export const saveAddLedgerLine = (params: AddLedger.LineForm) => {
|
||||
return requestAddLedger<AddLedger.LineForm>('post', '/line/save', toAddLedgerLinePayload(params))
|
||||
}
|
||||
|
||||
@@ -67,6 +67,48 @@ export namespace AddLedger {
|
||||
upgrade?: number
|
||||
}
|
||||
|
||||
export interface EquipmentUnitForm {
|
||||
devId: string
|
||||
unitFrequency?: string
|
||||
unitFrequencyDev?: string
|
||||
phaseVoltage?: string
|
||||
lineVoltage?: string
|
||||
voltageDev?: string
|
||||
uvoltageDev?: string
|
||||
ieffective?: string
|
||||
singleP?: string
|
||||
singleViewP?: string
|
||||
singleNoP?: string
|
||||
totalActiveP?: string
|
||||
totalViewP?: string
|
||||
totalNoP?: string
|
||||
vfundEffective?: string
|
||||
ifund?: string
|
||||
fundActiveP?: string
|
||||
fundNoP?: string
|
||||
vdistortion?: string
|
||||
vharmonicRate?: string
|
||||
iharmonic?: string
|
||||
pharmonic?: string
|
||||
iiharmonic?: string
|
||||
positiveV?: string
|
||||
noPositiveV?: string
|
||||
}
|
||||
|
||||
export interface OverlimitDetail {
|
||||
id?: string
|
||||
freqDev?: number
|
||||
voltageFluctuation?: number
|
||||
voltageDev?: number
|
||||
uvoltageDev?: number
|
||||
ubalance?: number
|
||||
shortUbalance?: number
|
||||
flicker?: number
|
||||
uaberrance?: number
|
||||
iNeg?: number
|
||||
[key: string]: string | number | undefined
|
||||
}
|
||||
|
||||
export interface LineForm {
|
||||
id?: string
|
||||
line_id?: string
|
||||
@@ -85,10 +127,12 @@ export namespace AddLedger {
|
||||
basic_capacity?: number
|
||||
protocol_capacity?: number
|
||||
dev_capacity?: number
|
||||
lineType?: number
|
||||
monitor_obj?: string
|
||||
is_govern?: number
|
||||
monitor_user?: string
|
||||
is_important?: number
|
||||
overlimit?: OverlimitDetail
|
||||
}
|
||||
|
||||
export type NodeDetail = EngineeringForm | ProjectForm | EquipmentForm | LineForm
|
||||
|
||||
@@ -117,6 +117,57 @@ const resolveZoomRangeFromAxisValues = (startValue: unknown, endValue: unknown)
|
||||
)
|
||||
}
|
||||
|
||||
const resolveTimeValue = (value: unknown): number | undefined => {
|
||||
if (Array.isArray(value)) return resolveTimeValue(value[0])
|
||||
|
||||
const numberValue = getFiniteNumber(value)
|
||||
if (numberValue !== undefined) return numberValue
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||||
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getSeriesTimeRange = () => {
|
||||
const seriesList = Array.isArray(props.options?.series) ? props.options.series : []
|
||||
let minTime = Number.POSITIVE_INFINITY
|
||||
let maxTime = Number.NEGATIVE_INFINITY
|
||||
|
||||
seriesList.forEach((series: { data?: unknown[] }) => {
|
||||
(series.data || []).forEach(point => {
|
||||
const timestamp = resolveTimeValue(point)
|
||||
|
||||
if (timestamp === undefined) return
|
||||
|
||||
minTime = Math.min(minTime, timestamp)
|
||||
maxTime = Math.max(maxTime, timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
return Number.isFinite(minTime) && Number.isFinite(maxTime) && maxTime > minTime
|
||||
? { minTime, maxTime }
|
||||
: null
|
||||
}
|
||||
|
||||
const resolveZoomRangeFromTimeAxisValues = (startValue: unknown, endValue: unknown) => {
|
||||
const startTime = resolveTimeValue(startValue)
|
||||
const endTime = resolveTimeValue(endValue)
|
||||
const timeRange = getSeriesTimeRange()
|
||||
|
||||
if (startTime === undefined || endTime === undefined || !timeRange) return null
|
||||
|
||||
const rangeSize = timeRange.maxTime - timeRange.minTime
|
||||
|
||||
return normalizeZoomPercentRange(
|
||||
((Math.min(startTime, endTime) - timeRange.minTime) / rangeSize) * 100,
|
||||
((Math.max(startTime, endTime) - timeRange.minTime) / rangeSize) * 100
|
||||
)
|
||||
}
|
||||
|
||||
const resolveCurrentDataZoomRange = (zoomPayload: ChartDataZoomPayload) => {
|
||||
const dataZoomOptions = chart?.getOption?.()?.dataZoom
|
||||
const dataZoomList = Array.isArray(dataZoomOptions) ? dataZoomOptions : dataZoomOptions ? [dataZoomOptions] : []
|
||||
@@ -140,6 +191,9 @@ const resolveChartDataZoomRange = (zoomPayload: ChartDataZoomPayload) => {
|
||||
const valueRange = resolveZoomRangeFromAxisValues(zoomPayload?.startValue, zoomPayload?.endValue)
|
||||
if (valueRange) return valueRange
|
||||
|
||||
const timeValueRange = resolveZoomRangeFromTimeAxisValues(zoomPayload?.startValue, zoomPayload?.endValue)
|
||||
if (timeValueRange) return timeValueRange
|
||||
|
||||
return resolveCurrentDataZoomRange(zoomPayload)
|
||||
}
|
||||
|
||||
@@ -199,9 +253,18 @@ const resetChartCursor = () => {
|
||||
isPanPointerDown = false
|
||||
}
|
||||
|
||||
const isSliderDataZoomResizeHandle = (target: any) => {
|
||||
return target?.type === 'path' && target?.draggable === true && target?.parent?.parent?.type === 'group'
|
||||
}
|
||||
|
||||
const updatePanCursor = (event: { offsetX: number; offsetY: number }) => {
|
||||
const viewportRoot = getChartViewportRoot()
|
||||
|
||||
if (viewportRoot && isSliderDataZoomResizeHandle((event as any)?.target)) {
|
||||
viewportRoot.style.cursor = 'pointer'
|
||||
return
|
||||
}
|
||||
|
||||
if (!viewportRoot || (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark')) {
|
||||
resetChartCursor()
|
||||
return
|
||||
|
||||
@@ -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]
|
||||
|
||||
56
frontend/src/routers/modules/check-dbms-route-contract.mjs
Normal file
56
frontend/src/routers/modules/check-dbms-route-contract.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const read = path => readFileSync(resolve(root, path), 'utf8')
|
||||
|
||||
const staticRouterSource = read('routers/modules/staticRouter.ts')
|
||||
const authStoreSource = read('stores/modules/auth.ts')
|
||||
|
||||
const expectedPaths = [
|
||||
'/systemMonitor/dbms',
|
||||
'/systemMonitor/dbms/index',
|
||||
'/systemMonitor/databaseMonitor',
|
||||
'/systemMonitor/databaseMonitor/index',
|
||||
'/systemMonitor/database-monitor',
|
||||
'/systemMonitor/database-monitor/index',
|
||||
'/system-ops/dbms/index',
|
||||
'/system-ops/database-monitor',
|
||||
'/system-ops/database-monitor/index'
|
||||
]
|
||||
|
||||
const errors = []
|
||||
|
||||
const dbmsRouteIndex = staticRouterSource.indexOf("name: 'systemOpsDbms'")
|
||||
if (dbmsRouteIndex === -1) {
|
||||
errors.push('staticRouter.ts must define the systemOpsDbms route')
|
||||
} else {
|
||||
const nextRouteIndex = staticRouterSource.indexOf('\n {', dbmsRouteIndex + 1)
|
||||
const routeBlock = staticRouterSource.slice(dbmsRouteIndex, nextRouteIndex === -1 ? undefined : nextRouteIndex)
|
||||
for (const path of expectedPaths) {
|
||||
if (!routeBlock.includes(`'${path}'`)) {
|
||||
errors.push(`systemOpsDbms route alias must include ${path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'function isDbmsMenu',
|
||||
"if (isDbmsMenu(menu))",
|
||||
"return '/system-ops/dbms'",
|
||||
"menu.name = 'systemOpsDbms'",
|
||||
"menu.component = '@/views/system-ops/dbms/index.vue'"
|
||||
]) {
|
||||
if (!authStoreSource.includes(snippet)) {
|
||||
errors.push(`auth.ts must include DBMS menu normalization snippet: ${snippet}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error('dbms route contract failed:')
|
||||
for (const error of errors) console.error(`- ${error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms route contract passed')
|
||||
@@ -21,7 +21,20 @@ const COMPONENT_PATH_ALIASES: Record<string, string> = {
|
||||
'/event/event-list/index': '/event/eventList/index',
|
||||
// 后端菜单可能使用短横线模块名,前端页面目录统一为 steadyDataView。
|
||||
'/steady/steady-data-view': '/steady/steadyDataView',
|
||||
'/steady/steady-data-view/index': '/steady/steadyDataView/index'
|
||||
'/steady/steady-data-view/index': '/steady/steadyDataView/index',
|
||||
'/steady/steady-trend': '/steady/steadyTrend',
|
||||
'/steady/steady-trend/index': '/steady/steadyTrend/index',
|
||||
'/steady/check-square': '/steady/checksquare',
|
||||
'/steady/check-square/index': '/steady/checksquare/index',
|
||||
// 数据库监控菜单统一落到 system-ops/dbms 页面,兼容后端菜单常见 component 写法。
|
||||
'/systemMonitor/dbms': '/system-ops/dbms',
|
||||
'/systemMonitor/dbms/index': '/system-ops/dbms/index',
|
||||
'/systemMonitor/databaseMonitor': '/system-ops/dbms',
|
||||
'/systemMonitor/databaseMonitor/index': '/system-ops/dbms/index',
|
||||
'/systemMonitor/database-monitor': '/system-ops/dbms',
|
||||
'/systemMonitor/database-monitor/index': '/system-ops/dbms/index',
|
||||
'/system-ops/database-monitor': '/system-ops/dbms',
|
||||
'/system-ops/database-monitor/index': '/system-ops/dbms/index'
|
||||
}
|
||||
const STATIC_ROUTE_NAMES = new Set([
|
||||
'layout',
|
||||
@@ -34,8 +47,11 @@ const STATIC_ROUTE_NAMES = new Set([
|
||||
'toolAddLedger',
|
||||
'eventList',
|
||||
'steadyDataView',
|
||||
'steadyTrend',
|
||||
'checksquare',
|
||||
'systemMonitor',
|
||||
'diskMonitor',
|
||||
'systemOpsDbms',
|
||||
'403',
|
||||
'404',
|
||||
'500'
|
||||
|
||||
@@ -117,6 +117,41 @@ export const staticRouter: RouteRecordRaw[] = [
|
||||
title: '稳态数据'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/steadyTrend/index',
|
||||
name: 'steadyTrend',
|
||||
alias: [
|
||||
'/steadyTrend',
|
||||
'/steadytrend',
|
||||
'/steadytrend/index',
|
||||
'/steady/steadyTrend',
|
||||
'/steady/steadyTrend/index',
|
||||
'/steady/steady-trend',
|
||||
'/steady/steady-trend/index'
|
||||
],
|
||||
component: () => import('@/views/steady/steadyTrend/index.vue'),
|
||||
meta: {
|
||||
cacheName: 'SteadyTrend',
|
||||
title: '\u7a33\u6001\u8d8b\u52bf'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/checksquare/index',
|
||||
name: 'checksquare',
|
||||
alias: [
|
||||
'/checksquare',
|
||||
'/checksquare/index',
|
||||
'/steady/checksquare',
|
||||
'/steady/checksquare/index',
|
||||
'/steady/check-square',
|
||||
'/steady/check-square/index'
|
||||
],
|
||||
component: () => import('@/views/steady/checksquare/index.vue'),
|
||||
meta: {
|
||||
cacheName: 'ChecksquareView',
|
||||
title: '数据验证入库'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: '403',
|
||||
@@ -163,6 +198,26 @@ export const staticRouter: RouteRecordRaw[] = [
|
||||
title: '磁盘监控'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/system-ops/dbms',
|
||||
name: 'systemOpsDbms',
|
||||
alias: [
|
||||
'/systemMonitor/dbms',
|
||||
'/systemMonitor/dbms/index',
|
||||
'/systemMonitor/databaseMonitor',
|
||||
'/systemMonitor/databaseMonitor/index',
|
||||
'/systemMonitor/database-monitor',
|
||||
'/systemMonitor/database-monitor/index',
|
||||
'/system-ops/dbms/index',
|
||||
'/system-ops/database-monitor',
|
||||
'/system-ops/database-monitor/index'
|
||||
],
|
||||
component: () => import('@/views/system-ops/dbms/index.vue'),
|
||||
meta: {
|
||||
cacheName: 'DbmsView',
|
||||
title: '数据库运维'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('@/components/ErrorMessage/404.vue')
|
||||
|
||||
@@ -144,6 +144,25 @@ function normalizeBusinessMenu(menu: any): any {
|
||||
menu.component = '@/views/steady/steadyDataView/index.vue'
|
||||
}
|
||||
|
||||
if (isSteadyTrendMenu(menu)) {
|
||||
menu.path = '/steadyTrend/index'
|
||||
menu.name = 'steadyTrend'
|
||||
menu.component = '@/views/steady/steadyTrend/index.vue'
|
||||
}
|
||||
|
||||
if (isChecksquareMenu(menu)) {
|
||||
menu.path = '/checksquare/index'
|
||||
menu.name = 'checksquare'
|
||||
menu.component = '@/views/steady/checksquare/index.vue'
|
||||
}
|
||||
|
||||
if (isDbmsMenu(menu)) {
|
||||
// 数据库运维菜单后端存在 systemMonitor/dbms 等历史路径,统一收敛到当前静态页面入口。
|
||||
menu.path = '/system-ops/dbms'
|
||||
menu.name = 'systemOpsDbms'
|
||||
menu.component = '@/views/system-ops/dbms/index.vue'
|
||||
}
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
@@ -173,8 +192,51 @@ function isSteadyDataViewMenu(menu: any): boolean {
|
||||
return title.includes('稳态数据')
|
||||
}
|
||||
|
||||
function isSteadyTrendMenu(menu: any): boolean {
|
||||
const normalizedName = String(menu?.name ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||
const normalizedPath = String(menu?.path ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||
const normalizedComponent = String(menu?.component ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||
const title = String(menu?.meta?.title ?? menu?.title ?? '')
|
||||
|
||||
if (normalizedName === 'steadytrend') return true
|
||||
if (normalizedPath.includes('steadytrend')) return true
|
||||
if (normalizedComponent.includes('steadytrend')) return true
|
||||
|
||||
return title.includes('\u7a33\u6001\u8d8b\u52bf')
|
||||
}
|
||||
|
||||
function isChecksquareMenu(menu: any): boolean {
|
||||
const normalizedName = String(menu?.name ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||
const normalizedPath = String(menu?.path ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||
const normalizedComponent = String(menu?.component ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||
const title = String(menu?.meta?.title ?? menu?.title ?? '')
|
||||
|
||||
if (normalizedName === 'checksquare') return true
|
||||
if (normalizedPath.includes('checksquare')) return true
|
||||
if (normalizedComponent.includes('checksquare')) return true
|
||||
|
||||
return title.includes('数据验证')
|
||||
}
|
||||
|
||||
function isDbmsMenu(menu: any): boolean {
|
||||
const normalizedName = String(menu?.name ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||
const normalizedPath = String(menu?.path ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||
const normalizedComponent = String(menu?.component ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||
const title = String(menu?.meta?.title ?? menu?.title ?? '')
|
||||
|
||||
if (normalizedName === 'systemopsdbms' || normalizedName === 'dbms') return true
|
||||
if (normalizedPath.includes('systemmonitor/dbms') || normalizedPath.includes('systemops/dbms')) return true
|
||||
if (normalizedPath.includes('databasemonitor') || normalizedComponent.includes('databasemonitor')) return true
|
||||
if (normalizedComponent.includes('systemmonitor/dbms') || normalizedComponent.includes('systemops/dbms')) return true
|
||||
|
||||
return title.includes('数据库') && (title.includes('运维') || title.includes('监控'))
|
||||
}
|
||||
|
||||
export function resolveBusinessMenuPath(menu: Menu.MenuOptions): string {
|
||||
if (isEventListMenu(menu)) return '/eventList/index'
|
||||
if (isChecksquareMenu(menu)) return '/checksquare/index'
|
||||
if (isSteadyTrendMenu(menu)) return '/steadyTrend/index'
|
||||
if (isDbmsMenu(menu)) return '/system-ops/dbms'
|
||||
return isSteadyDataViewMenu(menu) ? '/steadyDataView/index' : menu.path
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ $primary-color: var(--el-color-primary);
|
||||
--cn-color-phase-a: #daa520;
|
||||
--cn-color-phase-b: #2e8b57;
|
||||
--cn-color-phase-c: #a52a2a;
|
||||
--cn-color-phase-t: #000000;
|
||||
--cn-color-phase-ab: #daa520;
|
||||
--cn-color-phase-bc: #2e8b57;
|
||||
--cn-color-phase-ca: #a52a2a;
|
||||
|
||||
/* 波形状态与常用业务颜色 */
|
||||
--cn-color-run: #20b2aa;
|
||||
|
||||
39
frontend/src/utils/phaseColors.ts
Normal file
39
frontend/src/utils/phaseColors.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const readThemeColor = (name: string, fallback: string) => {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
|
||||
return value || fallback
|
||||
}
|
||||
|
||||
export const phaseColorFallbackMap: Record<string, string> = {
|
||||
A: '#daa520',
|
||||
B: '#2e8b57',
|
||||
C: '#a52a2a',
|
||||
T: '#000000',
|
||||
AB: '#daa520',
|
||||
BC: '#2e8b57',
|
||||
CA: '#a52a2a'
|
||||
}
|
||||
|
||||
const phaseColorVariableMap: Record<string, string> = {
|
||||
A: '--cn-color-phase-a',
|
||||
B: '--cn-color-phase-b',
|
||||
C: '--cn-color-phase-c',
|
||||
T: '--cn-color-phase-t',
|
||||
AB: '--cn-color-phase-ab',
|
||||
BC: '--cn-color-phase-bc',
|
||||
CA: '--cn-color-phase-ca'
|
||||
}
|
||||
|
||||
export const resolvePhaseThemeColor = (phase: string) => {
|
||||
const normalizedPhase = String(phase || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const variableName = phaseColorVariableMap[normalizedPhase]
|
||||
const fallback = phaseColorFallbackMap[normalizedPhase]
|
||||
|
||||
return variableName && fallback ? readThemeColor(variableName, fallback) : ''
|
||||
}
|
||||
|
||||
export const getDefaultPhaseThemeColors = () => ['A', 'B', 'C'].map(resolvePhaseThemeColor)
|
||||
@@ -45,6 +45,11 @@ assert.deepEqual(buildTimePeriodRange('day', new Date(2026, 4, 13)), [
|
||||
'2026-05-13 23:59:59.999'
|
||||
])
|
||||
|
||||
assert.deepEqual(buildTimePeriodRange('week', new Date(2026, 4, 13)), [
|
||||
'2026-05-11 00:00:00.000',
|
||||
'2026-05-17 23:59:59.999'
|
||||
])
|
||||
|
||||
assert.deepEqual(buildTimePeriodRange('month', new Date(2026, 4, 13)), [
|
||||
'2026-05-01 00:00:00.000',
|
||||
'2026-05-31 23:59:59.999'
|
||||
@@ -60,17 +65,25 @@ assert.deepEqual(buildTimePeriodRange('month', shiftTimePeriod('month', new Date
|
||||
'2026-04-30 23:59:59.999'
|
||||
])
|
||||
|
||||
assert.deepEqual(buildTimePeriodRange('week', shiftTimePeriod('week', new Date(2026, 4, 13), -1)), [
|
||||
'2026-05-04 00:00:00.000',
|
||||
'2026-05-10 23:59:59.999'
|
||||
])
|
||||
|
||||
assert.equal(formatTimePeriodDateTime(new Date(2026, 4, 13, 8, 9, 10, 11)), '2026-05-13 08:09:10.011')
|
||||
assert.equal(getTimePeriodPickerType('day'), 'date')
|
||||
assert.equal(getTimePeriodPickerFormat('month'), 'YYYY-MM')
|
||||
assert.equal(resolveTimePeriodUnitLabel('year'), '年')
|
||||
assert.equal(resolveTimePeriodUnitLabel('custom'), '自定义')
|
||||
|
||||
const componentExpectations = [
|
||||
['component renders unit selector', /time-period-search__unit[\s\S]*timePeriodUnitOptions/],
|
||||
['component renders unit selector', /time-period-search__unit[\s\S]*visibleTimePeriodUnitOptions/],
|
||||
['component renders previous period button', /ArrowLeft[\s\S]*上一个/],
|
||||
['component renders current period button', /Clock[\s\S]*当前/],
|
||||
['component renders next period button', /ArrowRight[\s\S]*下一个/],
|
||||
['component renders date picker by selected unit', /getTimePeriodPickerType\(props\.unit\)/],
|
||||
['component supports custom datetime range picker', /type="datetimerange"[\s\S]*handleRangeChange/],
|
||||
['component can limit visible units by props', /visibleUnits\?:\s*TimePeriodUnit\[\][\s\S]*visibleTimePeriodUnitOptions/],
|
||||
['component uses fixed eventList-compatible picker width', /time-period-search__picker[\s\S]*width:\s*112px;[\s\S]*flex:\s*0 0 112px;/]
|
||||
]
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<template>
|
||||
<div class="time-period-search">
|
||||
<el-select class="time-period-search__unit" :model-value="unit" @update:model-value="handleUnitChange">
|
||||
<el-option v-for="item in timePeriodUnitOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<el-option
|
||||
v-for="item in visibleTimePeriodUnitOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-button
|
||||
v-if="!isCustomUnit"
|
||||
class="time-period-search__button"
|
||||
:icon="ArrowLeft"
|
||||
:title="`上一个${unitLabel}`"
|
||||
@@ -12,6 +18,7 @@
|
||||
/>
|
||||
|
||||
<el-date-picker
|
||||
v-if="!isCustomUnit"
|
||||
class="time-period-search__picker"
|
||||
:model-value="baseDate"
|
||||
:type="getTimePeriodPickerType(props.unit)"
|
||||
@@ -22,7 +29,23 @@
|
||||
@update:model-value="handleDateChange"
|
||||
/>
|
||||
|
||||
<el-date-picker
|
||||
v-else
|
||||
class="time-period-search__range-picker"
|
||||
:model-value="rangeValue"
|
||||
type="datetimerange"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss.SSS"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
:clearable="false"
|
||||
:editable="false"
|
||||
@update:model-value="handleRangeChange"
|
||||
/>
|
||||
|
||||
<el-button
|
||||
v-if="!isCustomUnit"
|
||||
class="time-period-search__button"
|
||||
:icon="ArrowRight"
|
||||
:title="`下一个${unitLabel}`"
|
||||
@@ -30,6 +53,7 @@
|
||||
/>
|
||||
|
||||
<el-button
|
||||
v-if="!isCustomUnit"
|
||||
class="time-period-search__button"
|
||||
:icon="Clock"
|
||||
:title="`当前${unitLabel}`"
|
||||
@@ -57,15 +81,25 @@ defineOptions({
|
||||
const props = defineProps<{
|
||||
unit: TimePeriodUnit
|
||||
modelValue: Date | string | number
|
||||
rangeValue?: string[]
|
||||
visibleUnits?: TimePeriodUnit[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:unit': [value: TimePeriodUnit]
|
||||
'update:modelValue': [value: Date]
|
||||
'update:rangeValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
const baseDate = computed(() => new Date(props.modelValue))
|
||||
const unitLabel = computed(() => resolveTimePeriodUnitLabel(props.unit))
|
||||
const isCustomUnit = computed(() => props.unit === 'custom')
|
||||
const visibleTimePeriodUnitOptions = computed(() => {
|
||||
if (!props.visibleUnits?.length) return timePeriodUnitOptions
|
||||
|
||||
return timePeriodUnitOptions.filter(item => props.visibleUnits?.includes(item.value))
|
||||
})
|
||||
const rangeValue = computed(() => props.rangeValue || [])
|
||||
|
||||
const handleUnitChange = (value: TimePeriodUnit) => {
|
||||
emit('update:unit', value)
|
||||
@@ -76,6 +110,11 @@ const handleDateChange = (value: Date | string | number | null) => {
|
||||
emit('update:modelValue', new Date(value))
|
||||
}
|
||||
|
||||
const handleRangeChange = (value: string[] | null) => {
|
||||
if (!value?.length) return
|
||||
emit('update:rangeValue', value)
|
||||
}
|
||||
|
||||
const shiftPeriod = (offset: number) => {
|
||||
emit('update:modelValue', shiftTimePeriod(props.unit, baseDate.value, offset))
|
||||
}
|
||||
@@ -103,6 +142,12 @@ const setCurrentPeriod = () => {
|
||||
flex: 0 0 112px;
|
||||
}
|
||||
|
||||
.time-period-search__range-picker {
|
||||
width: 360px;
|
||||
flex: 1 1 360px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.time-period-search__button {
|
||||
width: 28px;
|
||||
flex: 0 0 28px;
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
export type TimePeriodUnit = 'day' | 'month' | 'year'
|
||||
export type TimePeriodUnit = 'day' | 'week' | 'month' | 'year' | 'custom'
|
||||
|
||||
export const timePeriodUnitOptions: { label: string; value: TimePeriodUnit }[] = [
|
||||
{ label: '日', value: 'day' },
|
||||
{ label: '周', value: 'week' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '年', value: 'year' }
|
||||
{ label: '年', value: 'year' },
|
||||
{ label: '自定义', value: 'custom' }
|
||||
]
|
||||
|
||||
const datePickerTypeMap: Record<TimePeriodUnit, 'date' | 'month' | 'year'> = {
|
||||
day: 'date',
|
||||
week: 'date',
|
||||
month: 'month',
|
||||
year: 'year'
|
||||
year: 'year',
|
||||
custom: 'date'
|
||||
}
|
||||
|
||||
const datePickerFormatMap: Record<TimePeriodUnit, string> = {
|
||||
day: 'YYYY-MM-DD',
|
||||
week: 'YYYY-MM-DD',
|
||||
month: 'YYYY-MM',
|
||||
year: 'YYYY'
|
||||
year: 'YYYY',
|
||||
custom: 'YYYY-MM-DD'
|
||||
}
|
||||
|
||||
const padTimeValue = (value: number, length = 2) => String(value).padStart(length, '0')
|
||||
@@ -52,6 +58,23 @@ export const buildTimePeriodRange = (unit: TimePeriodUnit, date: Date): string[]
|
||||
]
|
||||
}
|
||||
|
||||
if (unit === 'week') {
|
||||
const dayOfWeek = date.getDay()
|
||||
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek
|
||||
const weekStart = new Date(year, month, day + mondayOffset, 0, 0, 0, 0)
|
||||
const weekEnd = new Date(
|
||||
weekStart.getFullYear(),
|
||||
weekStart.getMonth(),
|
||||
weekStart.getDate() + 6,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999
|
||||
)
|
||||
|
||||
return [formatTimePeriodDateTime(weekStart), formatTimePeriodDateTime(weekEnd)]
|
||||
}
|
||||
|
||||
if (unit === 'year') {
|
||||
return [
|
||||
formatTimePeriodDateTime(new Date(year, 0, 1, 0, 0, 0, 0)),
|
||||
@@ -78,6 +101,11 @@ export const shiftTimePeriod = (unit: TimePeriodUnit, date: Date, offset: number
|
||||
return nextDate
|
||||
}
|
||||
|
||||
if (unit === 'week') {
|
||||
nextDate.setDate(nextDate.getDate() + offset * 7)
|
||||
return nextDate
|
||||
}
|
||||
|
||||
// 月份切换以 1 日为锚点,避免 31 日切到短月份时发生日期溢出。
|
||||
nextDate.setDate(1)
|
||||
nextDate.setMonth(nextDate.getMonth() + offset)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, 'index.vue')
|
||||
const source = fs.readFileSync(pageFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
['search grid keeps five fields on wide event list screens', /:search-col="\{\s*xs:\s*1,\s*sm:\s*2,\s*md:\s*2,\s*lg:\s*5,\s*xl:\s*5\s*\}"/],
|
||||
['event time table column keeps occurrence time label', /prop:\s*'startTime',\s*label:\s*'发生时刻'/],
|
||||
['event time search label is shortened to time', /search:\s*\{\s*label:\s*'时间',\s*key:\s*'startTimeRange'/],
|
||||
['event time search field only takes one grid column', /key:\s*'startTimeRange',\s*span:\s*1,/],
|
||||
['event time search imports shared TimePeriodSearch component', /import TimePeriodSearch from '@\/views\/components\/TimePeriodSearch\/index\.vue'/],
|
||||
['event time search imports shared period helpers', /from '@\/views\/components\/TimePeriodSearch\/timePeriod'/],
|
||||
['event time search renders shared TimePeriodSearch', /h\(TimePeriodSearch,[\s\S]*unit:\s*eventTimeUnit\.value,[\s\S]*modelValue:\s*eventTimeBaseDate\.value/]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => !pattern.test(source))
|
||||
|
||||
if (failures.length) {
|
||||
console.error('eventList search layout contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('eventList search layout contract check passed')
|
||||
@@ -1,52 +0,0 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, 'index.vue')
|
||||
const queryFile = path.join(currentDir, 'utils', 'queryParams.ts')
|
||||
const source = fs.readFileSync(pageFile, 'utf8')
|
||||
const querySource = fs.readFileSync(queryFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
'table keeps requested visible event column order',
|
||||
/label:\s*'发生时刻'[\s\S]*label:\s*'监测点名称'[\s\S]*label:\s*'持续时间\(s\)'[\s\S]*label:\s*'暂降\/暂升幅值\(%\)'[\s\S]*label:\s*'事件类型'[\s\S]*label:\s*'相别'/
|
||||
],
|
||||
[
|
||||
'event type column displays name but searches by eventType code',
|
||||
/prop:\s*'eventTypeName'[\s\S]*label:\s*'事件类型'[\s\S]*enum:\s*eventTypeOptions[\s\S]*fieldNames:\s*\{\s*label:\s*'name',\s*value:\s*'id'\s*\}[\s\S]*isFilterEnum:\s*false[\s\S]*search:\s*\{[\s\S]*key:\s*'eventType'[\s\S]*el:\s*'select'/
|
||||
],
|
||||
[
|
||||
'event description defaults hidden in table columns',
|
||||
/prop:\s*'event_describe'[\s\S]*label:\s*'事件描述'[\s\S]*isShow:\s*false/
|
||||
],
|
||||
['event location defaults hidden in table columns', /prop:\s*'sagsource'[\s\S]*label:\s*'事件发生位置'[\s\S]*isShow:\s*false/],
|
||||
['waveform status defaults hidden in table columns', /prop:\s*'fileFlag'[\s\S]*label:\s*'波形文件状态'[\s\S]*isShow:\s*false/],
|
||||
['monitor point is rendered as a clickable link', /prop:\s*'lineName'[\s\S]*type:\s*'primary'[\s\S]*link:\s*true[\s\S]*handleViewMeasurementPoint\(row\)/],
|
||||
['measurement point dialog is present', /measurementPointDialogVisible[\s\S]*title="监测点信息"/],
|
||||
['operation switches between view waveform and supplement waveform', /Number\(row\.fileFlag\)\s*===\s*1[\s\S]*查看波形[\s\S]*波形补招/],
|
||||
['waveform status search uses custom render instead of select', /renderFileFlagSearch[\s\S]*prop:\s*'fileFlag'[\s\S]*search:\s*\{[\s\S]*render:\s*renderFileFlagSearch/],
|
||||
[
|
||||
'event description is not a search field',
|
||||
/prop:\s*'event_describe'[\s\S]*label:\s*'事件描述'[\s\S]*search:[\s\S]*prop:\s*'sagsource'/.test(source) === false
|
||||
],
|
||||
['ledger names are searched through one keyword field', /ledgerKeyword[\s\S]*label:\s*'台账关键字'/],
|
||||
['query params fan out ledger keyword to ledger name fields', /ledgerKeyword[\s\S]*engineeringName[\s\S]*projectName[\s\S]*equipmentName[\s\S]*lineName/.test(querySource)]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => {
|
||||
if (typeof pattern === 'boolean') return !pattern
|
||||
return !pattern.test(source)
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('eventList visible contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('eventList visible contract check passed')
|
||||
469
frontend/src/views/event/eventList/components/EventListTable.vue
Normal file
469
frontend/src/views/event/eventList/components/EventListTable.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
row-key="eventId"
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:search-col="{ xs: 1, sm: 2, md: 2, lg: 5, xl: 5 }"
|
||||
@reset="handleSearchReset"
|
||||
>
|
||||
<template #tableHeader>
|
||||
<el-button type="primary" plain :icon="Download" @click="handleEventExport">{{ eventExportText }}</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Download"
|
||||
:disabled="!selectedWaveformRows.length"
|
||||
@click="handleWaveformExport"
|
||||
>{{ waveformExportText }}</el-button>
|
||||
</template>
|
||||
|
||||
<template #fileFlag="{ row }">
|
||||
<el-tag :type="Number(row.fileFlag) === 1 ? 'success' : 'info'" effect="light">
|
||||
{{ resolveFileFlagText(row.fileFlag) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template #operation="{ row }">
|
||||
<el-button v-if="Number(row.fileFlag) === 1" type="primary" link :icon="View" @click="emit('viewWaveform', row)">
|
||||
{{ viewWaveformText }}
|
||||
</el-button>
|
||||
<el-button v-else type="primary" link :icon="RefreshRight" @click="emit('supplementWaveform')">
|
||||
{{ supplementWaveformText }}
|
||||
</el-button>
|
||||
<el-button type="primary" link :icon="DataAnalysis" @click="emit('viewVoltageTolerance', row)">
|
||||
{{ voltageToleranceText }}
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, computed, reactive, ref } from 'vue'
|
||||
import { ElButton, ElCheckbox, ElInputNumber, ElRadioButton, ElRadioGroup } from 'element-plus'
|
||||
import { DataAnalysis, Download, RefreshRight, View } from '@element-plus/icons-vue'
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
||||
import type { EventList } from '@/api/event/eventList/interface'
|
||||
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
||||
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||
import { formatEventOccurrenceTime } from '../utils/eventTimeRange'
|
||||
import type { EventSearchParams } from '../utils/queryParams'
|
||||
import { resolveEventDescription, resolveEventSeverity, resolveEventTypeName } from '../utils/display'
|
||||
import {
|
||||
fileFlagOptions,
|
||||
phaseOptions,
|
||||
resolveFileFlagText,
|
||||
resolvePhaseText
|
||||
} from '../utils/status'
|
||||
|
||||
defineOptions({
|
||||
name: 'EventListTable'
|
||||
})
|
||||
|
||||
type EventTypeOption = {
|
||||
id?: string
|
||||
name?: string
|
||||
code?: string
|
||||
value?: string
|
||||
}
|
||||
type NumberRangeSearchKey =
|
||||
| 'featureAmplitudeMin'
|
||||
| 'featureAmplitudeMax'
|
||||
| 'durationMin'
|
||||
| 'durationMax'
|
||||
| 'severityMin'
|
||||
| 'severityMax'
|
||||
|
||||
const props = defineProps<{
|
||||
eventTypeOptions: EventTypeOption[]
|
||||
requestApi: (params: EventSearchParams) => Promise<any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
eventExport: [params: EventSearchParams]
|
||||
waveformExport: [rows: EventList.TransientEventRecord[]]
|
||||
viewMeasurementPoint: [row: EventList.TransientEventRecord]
|
||||
viewWaveform: [row: EventList.TransientEventRecord]
|
||||
viewVoltageTolerance: [row: EventList.TransientEventRecord]
|
||||
supplementWaveform: []
|
||||
}>()
|
||||
|
||||
const eventExportText = '\u4e8b\u4ef6\u5bfc\u51fa'
|
||||
const waveformExportText = '\u6ce2\u5f62\u5bfc\u51fa'
|
||||
const viewWaveformText = '\u6ce2\u5f62\u67e5\u770b'
|
||||
const voltageToleranceText = 'ITIC/SEMI F47'
|
||||
const supplementWaveformText = '\u6ce2\u5f62\u8865\u62db'
|
||||
const allText = '\u5168\u90e8'
|
||||
const minValueText = '\u6700\u5c0f\u503c'
|
||||
const maxValueText = '\u6700\u5927\u503c'
|
||||
const toText = '\u81f3'
|
||||
const numberRangeInputStyle = { flex: '0 0 72px', width: '72px', minWidth: '0' }
|
||||
const numberRangeSeparatorStyle = { flex: '0 0 auto', margin: '0 3px' }
|
||||
|
||||
const proTable = ref<ProTableInstance>()
|
||||
const selectedWaveformRows = ref<EventList.TransientEventRecord[]>([])
|
||||
const eventTimeUnit = ref<TimePeriodUnit>('month')
|
||||
const eventTimeBaseDate = ref(new Date())
|
||||
const defaultStartTimeRange = buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
|
||||
|
||||
const commitEventTimeRange = ({ shouldSearch = false } = {}) => {
|
||||
const timeRange = buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
|
||||
const searchParam = proTable.value?.searchParam as EventSearchParams | undefined
|
||||
|
||||
if (searchParam) {
|
||||
searchParam.startTimeRange = timeRange
|
||||
}
|
||||
|
||||
// 自定义时间控件必须显式提交 ProTable 查询,避免 useTable 继续沿用上一次 totalParam。
|
||||
if (shouldSearch) {
|
||||
proTable.value?.search()
|
||||
}
|
||||
|
||||
return timeRange
|
||||
}
|
||||
|
||||
const handleEventTimeUnitChange = (value: TimePeriodUnit) => {
|
||||
eventTimeUnit.value = value
|
||||
commitEventTimeRange({ shouldSearch: true })
|
||||
}
|
||||
|
||||
const handleEventTimeDateChange = (value: Date | string | number | null) => {
|
||||
if (!value) return
|
||||
eventTimeBaseDate.value = new Date(value)
|
||||
commitEventTimeRange({ shouldSearch: true })
|
||||
}
|
||||
|
||||
const handleSearchReset = () => {
|
||||
eventTimeUnit.value = 'month'
|
||||
eventTimeBaseDate.value = new Date()
|
||||
clearWaveformSelection()
|
||||
commitEventTimeRange({ shouldSearch: true })
|
||||
}
|
||||
|
||||
const resolveCurrentSearchParams = (params: EventSearchParams = {}) => ({
|
||||
...params,
|
||||
startTimeRange: buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
|
||||
})
|
||||
|
||||
const renderEventTimeSearch = () =>
|
||||
h(TimePeriodSearch, {
|
||||
class: 'event-time-search',
|
||||
unit: eventTimeUnit.value,
|
||||
modelValue: eventTimeBaseDate.value,
|
||||
'onUpdate:unit': handleEventTimeUnitChange,
|
||||
'onUpdate:modelValue': handleEventTimeDateChange
|
||||
})
|
||||
|
||||
const renderFileFlagSearch = ({ searchParam }: { searchParam: EventSearchParams }) => {
|
||||
return h(
|
||||
ElRadioGroup,
|
||||
{
|
||||
class: 'event-file-flag-search',
|
||||
modelValue: searchParam.fileFlag ?? '',
|
||||
'onUpdate:modelValue': (value: string | number | boolean | undefined) => {
|
||||
searchParam.fileFlag = value === '' || value === undefined ? undefined : Number(value)
|
||||
}
|
||||
},
|
||||
() => [
|
||||
h(ElRadioButton, { label: '' }, () => allText),
|
||||
...fileFlagOptions.map(option =>
|
||||
h(ElRadioButton, { key: option.value, label: option.value }, () => option.label)
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const resolveNumberRangeValue = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
const updateNumberRangeValue = (searchParam: EventSearchParams, key: NumberRangeSearchKey, value: unknown) => {
|
||||
const nextValue = resolveNumberRangeValue(value)
|
||||
|
||||
if (nextValue === undefined) {
|
||||
delete searchParam[key]
|
||||
return
|
||||
}
|
||||
|
||||
searchParam[key] = nextValue
|
||||
}
|
||||
|
||||
const renderNumberRangeSearch =
|
||||
(minKey: NumberRangeSearchKey, maxKey: NumberRangeSearchKey) =>
|
||||
({ searchParam }: { searchParam: EventSearchParams }) =>
|
||||
h('div', { class: 'event-number-range-search' }, [
|
||||
h(ElInputNumber, {
|
||||
class: 'event-number-range-input',
|
||||
style: numberRangeInputStyle,
|
||||
modelValue: resolveNumberRangeValue(searchParam[minKey]),
|
||||
controls: false,
|
||||
placeholder: minValueText,
|
||||
'onUpdate:modelValue': (value: number | undefined) => updateNumberRangeValue(searchParam, minKey, value)
|
||||
}),
|
||||
h('span', { class: 'event-number-range-separator', style: numberRangeSeparatorStyle }, toText),
|
||||
h(ElInputNumber, {
|
||||
class: 'event-number-range-input',
|
||||
style: numberRangeInputStyle,
|
||||
modelValue: resolveNumberRangeValue(searchParam[maxKey]),
|
||||
controls: false,
|
||||
placeholder: maxValueText,
|
||||
'onUpdate:modelValue': (value: number | undefined) => updateNumberRangeValue(searchParam, maxKey, value)
|
||||
})
|
||||
])
|
||||
|
||||
const isWaveformExportable = (row: EventList.TransientEventRecord) =>
|
||||
Boolean(row.eventId) && Number(row.fileFlag) === 1 && Boolean(row.wavePath)
|
||||
|
||||
const currentWaveformRows = computed(() => proTable.value?.tableData || [])
|
||||
const currentExportableWaveformRows = computed(() => currentWaveformRows.value.filter(isWaveformExportable))
|
||||
const selectedWaveformIds = computed(() => selectedWaveformRows.value.map(row => row.eventId).filter(Boolean))
|
||||
const selectedWaveformIdSet = computed(() => new Set(selectedWaveformIds.value))
|
||||
const isAllCurrentWaveformsSelected = computed(
|
||||
() =>
|
||||
currentExportableWaveformRows.value.length > 0 &&
|
||||
currentExportableWaveformRows.value.every(row => selectedWaveformIdSet.value.has(row.eventId))
|
||||
)
|
||||
const isCurrentWaveformSelectionIndeterminate = computed(() => {
|
||||
const selectedCount = currentExportableWaveformRows.value.filter(row =>
|
||||
selectedWaveformIdSet.value.has(row.eventId)
|
||||
).length
|
||||
return selectedCount > 0 && selectedCount < currentExportableWaveformRows.value.length
|
||||
})
|
||||
|
||||
const clearWaveformSelection = () => {
|
||||
selectedWaveformRows.value = []
|
||||
}
|
||||
|
||||
const toggleWaveformRowSelection = (row: EventList.TransientEventRecord, checked: boolean) => {
|
||||
if (!isWaveformExportable(row)) return
|
||||
|
||||
if (checked) {
|
||||
if (!selectedWaveformIdSet.value.has(row.eventId)) {
|
||||
selectedWaveformRows.value = [...selectedWaveformRows.value, row]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectedWaveformRows.value = selectedWaveformRows.value.filter(item => item.eventId !== row.eventId)
|
||||
}
|
||||
|
||||
const toggleCurrentWaveformSelection = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const currentSelectedIds = new Set(selectedWaveformIds.value)
|
||||
const nextRows = [...selectedWaveformRows.value]
|
||||
|
||||
currentExportableWaveformRows.value.forEach(row => {
|
||||
if (!currentSelectedIds.has(row.eventId)) {
|
||||
nextRows.push(row)
|
||||
currentSelectedIds.add(row.eventId)
|
||||
}
|
||||
})
|
||||
|
||||
selectedWaveformRows.value = nextRows
|
||||
return
|
||||
}
|
||||
|
||||
const currentIds = new Set(currentExportableWaveformRows.value.map(row => row.eventId))
|
||||
selectedWaveformRows.value = selectedWaveformRows.value.filter(row => !currentIds.has(row.eventId))
|
||||
}
|
||||
|
||||
const renderWaveformSelectionHeader = () =>
|
||||
h(ElCheckbox, {
|
||||
modelValue: isAllCurrentWaveformsSelected.value,
|
||||
indeterminate: isCurrentWaveformSelectionIndeterminate.value,
|
||||
disabled: !currentExportableWaveformRows.value.length,
|
||||
'onUpdate:modelValue': (value: string | number | boolean) => toggleCurrentWaveformSelection(Boolean(value))
|
||||
})
|
||||
|
||||
const renderWaveformSelectionCell = ({ row }: { row: EventList.TransientEventRecord }) =>
|
||||
h(ElCheckbox, {
|
||||
modelValue: selectedWaveformIdSet.value.has(row.eventId),
|
||||
disabled: !isWaveformExportable(row),
|
||||
'onUpdate:modelValue': (value: string | number | boolean) => toggleWaveformRowSelection(row, Boolean(value))
|
||||
})
|
||||
|
||||
const columns = reactive<ColumnProps<EventList.TransientEventRecord>[]>([
|
||||
{
|
||||
prop: 'waveformSelection',
|
||||
label: '\u6ce2\u5f62\u9009\u62e9',
|
||||
fixed: 'left',
|
||||
width: 90,
|
||||
isSetting: false,
|
||||
headerRender: renderWaveformSelectionHeader,
|
||||
render: renderWaveformSelectionCell
|
||||
},
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '\u5e8f\u53f7' },
|
||||
{
|
||||
prop: 'startTime',
|
||||
label: '\u53d1\u751f\u65f6\u523b',
|
||||
minWidth: 200,
|
||||
render: ({ row }) => formatEventOccurrenceTime(row.startTime),
|
||||
search: {
|
||||
label: '\u65f6\u95f4',
|
||||
key: 'startTimeRange',
|
||||
order: 2,
|
||||
span: 1,
|
||||
defaultValue: defaultStartTimeRange,
|
||||
render: renderEventTimeSearch
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'ledgerKeyword',
|
||||
label: '\u53f0\u8d26\u5173\u952e\u5b57',
|
||||
isShow: false,
|
||||
isSetting: false,
|
||||
search: {
|
||||
el: 'input',
|
||||
label: '\u53f0\u8d26\u5173\u952e\u5b57',
|
||||
order: 3,
|
||||
props: {
|
||||
placeholder: '\u5de5\u7a0b/\u9879\u76ee/\u8bbe\u5907/\u76d1\u6d4b\u70b9'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'lineName',
|
||||
label: '\u76d1\u6d4b\u70b9\u540d\u79f0',
|
||||
minWidth: 180,
|
||||
render: ({ row }) =>
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
type: 'primary',
|
||||
link: true,
|
||||
onClick: () => emit('viewMeasurementPoint', row)
|
||||
},
|
||||
() => resolveText(row.lineName)
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'featureAmplitude',
|
||||
label: '\u6682\u964d/\u6682\u5347\u5e45\u503c(%)',
|
||||
minWidth: 160,
|
||||
search: {
|
||||
label: '\u6682\u6001\u5e45\u503c(%)',
|
||||
order: 5,
|
||||
render: renderNumberRangeSearch('featureAmplitudeMin', 'featureAmplitudeMax')
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'duration',
|
||||
label: '\u6301\u7eed\u65f6\u95f4(s)',
|
||||
minWidth: 130,
|
||||
search: {
|
||||
label: '\u6682\u6001\u6301\u7eed\u65f6\u95f4',
|
||||
order: 6,
|
||||
render: renderNumberRangeSearch('durationMin', 'durationMax')
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'eventTypeName',
|
||||
label: '\u4e8b\u4ef6\u7c7b\u578b',
|
||||
minWidth: 160,
|
||||
enum: props.eventTypeOptions,
|
||||
fieldNames: { label: 'name', value: 'code' },
|
||||
isFilterEnum: false,
|
||||
render: ({ row }) => resolveEventTypeName(row, props.eventTypeOptions),
|
||||
search: {
|
||||
order: 4,
|
||||
key: 'eventType',
|
||||
el: 'select'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'severity',
|
||||
label: '\u4e25\u91cd\u5ea6',
|
||||
minWidth: 100,
|
||||
render: ({ row }) => resolveEventSeverity(row),
|
||||
search: {
|
||||
label: '\u4e8b\u4ef6\u4e25\u91cd\u5ea6',
|
||||
order: 7,
|
||||
render: renderNumberRangeSearch('severityMin', 'severityMax')
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'phase',
|
||||
label: '\u76f8\u522b',
|
||||
minWidth: 90,
|
||||
enum: phaseOptions,
|
||||
isFilterEnum: false,
|
||||
render: ({ row }) => resolvePhaseText(row.phase)
|
||||
},
|
||||
{
|
||||
prop: 'event_describe',
|
||||
label: '\u4e8b\u4ef6\u63cf\u8ff0',
|
||||
minWidth: 180,
|
||||
isShow: false,
|
||||
render: ({ row }) => resolveEventDescription(row)
|
||||
},
|
||||
{ prop: 'sagsource', label: '\u4e8b\u4ef6\u53d1\u751f\u4f4d\u7f6e', minWidth: 140, isShow: false },
|
||||
{
|
||||
prop: 'fileFlag',
|
||||
label: '\u6ce2\u5f62\u6587\u4ef6\u72b6\u6001',
|
||||
minWidth: 130,
|
||||
isShow: false,
|
||||
enum: fileFlagOptions,
|
||||
search: {
|
||||
order: 8,
|
||||
render: renderFileFlagSearch
|
||||
}
|
||||
},
|
||||
{ prop: 'operation', label: '\u64cd\u4f5c', fixed: 'right', width: 240 }
|
||||
])
|
||||
|
||||
const resolveText = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const getTableList = (params: EventSearchParams) => {
|
||||
clearWaveformSelection()
|
||||
return props.requestApi(resolveCurrentSearchParams(params))
|
||||
}
|
||||
|
||||
const handleEventExport = () => {
|
||||
const searchParam = (proTable.value?.searchParam || {}) as EventSearchParams
|
||||
emit('eventExport', resolveCurrentSearchParams(searchParam))
|
||||
}
|
||||
|
||||
const handleWaveformExport = () => {
|
||||
emit('waveformExport', selectedWaveformRows.value.filter(isWaveformExportable))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.event-file-flag-search) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.event-number-range-search) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
:deep(.event-number-range-input) {
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.event-number-range-input .el-input__inner) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(.event-number-range-separator) {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
margin: 0 3px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<el-dialog :model-value="visible" :title="dialogTitle" width="640px" @update:model-value="emit('update:visible', $event)">
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
<el-descriptions v-else :column="2" border>
|
||||
<el-descriptions-item
|
||||
v-for="item in measurementPointItems"
|
||||
:key="item.prop"
|
||||
:label="item.label"
|
||||
>
|
||||
{{ resolveText(data?.[item.prop]) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EventList } from '@/api/event/eventList/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'MeasurementPointDialog'
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
data: EventList.TransientEventRecord | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
const dialogTitle = '\u76d1\u6d4b\u70b9\u4fe1\u606f'
|
||||
const measurementPointItems: { label: string; prop: keyof EventList.TransientEventRecord }[] = [
|
||||
{ label: '\u5de5\u7a0b\u540d\u79f0', prop: 'engineeringName' },
|
||||
{ label: '\u9879\u76ee\u540d\u79f0', prop: 'projectName' },
|
||||
{ label: '\u8bbe\u5907\u540d\u79f0', prop: 'equipmentName' },
|
||||
{ label: '\u7f51\u7edc\u53c2\u6570', prop: 'mac' },
|
||||
{ label: '\u76d1\u6d4b\u70b9\u540d\u79f0', prop: 'lineName' }
|
||||
]
|
||||
|
||||
const resolveText = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
return String(value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="ITIC/SEMI F47 曲线判定"
|
||||
width="860px"
|
||||
class="voltage-tolerance-dialog"
|
||||
destroy-on-close
|
||||
@opened="renderChart"
|
||||
@closed="disposeChart"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<el-empty v-if="!data" description="暂无事件数据" />
|
||||
<template v-else>
|
||||
<el-descriptions class="event-summary" :column="3" border>
|
||||
<el-descriptions-item label="发生时刻">
|
||||
{{ formatEventOccurrenceTime(data.startTime) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="持续时间(s)">{{ resolveNumberText(data.duration) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="电压百分比(%)">
|
||||
{{ resolveNumberText(data.featureAmplitude) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-tabs v-model="activeStandardKey" class="standard-tabs">
|
||||
<el-tab-pane label="ITIC" name="itic" />
|
||||
<el-tab-pane label="SEMI F47" name="semiF47" />
|
||||
</el-tabs>
|
||||
|
||||
<div class="result-bar">
|
||||
<el-tag :type="activeEvaluation?.tolerable ? 'success' : 'danger'" effect="light">
|
||||
{{ activeEvaluation?.statusText || '无法判定' }}
|
||||
</el-tag>
|
||||
<span v-if="activeEvaluation">
|
||||
边界电压 {{ formatPercent(activeEvaluation.thresholdVoltagePercent) }},裕量
|
||||
{{ formatSignedPercent(activeEvaluation.marginPercent) }}
|
||||
</span>
|
||||
<span v-else>缺少合法的持续时间或电压百分比,无法定位事件点。</span>
|
||||
</div>
|
||||
|
||||
<div ref="chartRef" class="voltage-tolerance-chart" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as echarts from 'echarts'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { EventList } from '@/api/event/eventList/interface'
|
||||
import {
|
||||
buildVoltageToleranceChartOption,
|
||||
evaluateVoltageTolerance,
|
||||
type VoltageToleranceStandardKey
|
||||
} from '../utils/voltageTolerance'
|
||||
import { formatEventOccurrenceTime } from '../utils/eventTimeRange'
|
||||
|
||||
defineOptions({
|
||||
name: 'VoltageToleranceDialog'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
data: EventList.TransientEventRecord | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
const activeStandardKey = ref<VoltageToleranceStandardKey>('itic')
|
||||
const chartRef = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const activeEvaluation = computed(() => {
|
||||
if (!props.data) return null
|
||||
return evaluateVoltageTolerance(activeStandardKey.value, props.data)
|
||||
})
|
||||
|
||||
const disposeChart = () => {
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
}
|
||||
|
||||
const renderChart = async () => {
|
||||
await nextTick()
|
||||
|
||||
if (!props.visible || !props.data || !chartRef.value) return
|
||||
|
||||
if (!chart) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
}
|
||||
|
||||
chart.setOption(buildVoltageToleranceChartOption(activeStandardKey.value, props.data), true)
|
||||
chart.resize()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
visible => {
|
||||
if (!visible) return
|
||||
activeStandardKey.value = 'itic'
|
||||
renderChart()
|
||||
}
|
||||
)
|
||||
|
||||
watch([activeStandardKey, () => props.data], () => {
|
||||
if (props.visible) {
|
||||
renderChart()
|
||||
}
|
||||
})
|
||||
|
||||
const resolveNumberText = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? Number(parsed.toFixed(4)).toString() : '--'
|
||||
}
|
||||
|
||||
const formatPercent = (value: number) => `${Number(value.toFixed(2))}%`
|
||||
|
||||
const formatSignedPercent = (value: number) => {
|
||||
const formattedValue = formatPercent(Math.abs(value))
|
||||
return value >= 0 ? `+${formattedValue}` : `-${formattedValue}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.event-summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.standard-tabs {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
margin-bottom: 12px;
|
||||
gap: 10px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.voltage-tolerance-chart {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -23,16 +23,22 @@ fs.mkdirSync(tempDir, { recursive: true })
|
||||
const tempModulePath = path.join(tempDir, 'display.mjs')
|
||||
fs.writeFileSync(tempModulePath, transpiled, 'utf8')
|
||||
|
||||
const { resolveEventDescription, resolveEventTypeName } = await import(pathToFileURL(tempModulePath).href)
|
||||
const { resolveEventDescription, resolveEventSeverity, resolveEventTypeName } = await import(pathToFileURL(tempModulePath).href)
|
||||
|
||||
assert.equal(resolveEventDescription({ event_describe: '电压暂降' }), '电压暂降')
|
||||
assert.equal(resolveEventDescription({ event_describe: '' }), '--')
|
||||
assert.equal(resolveEventDescription({ eventDescribe: '驼峰描述' }), '--')
|
||||
assert.equal(resolveEventDescription({ eventDescription: '描述字段' }), '--')
|
||||
assert.equal(resolveEventDescription({ eventType: 'VOLTAGE_SAG' }), '--')
|
||||
assert.equal(resolveEventDescription({}), '--')
|
||||
assert.equal(resolveEventDescription(null), '--')
|
||||
|
||||
assert.equal(resolveEventSeverity({ severity: 72.5 }), '72.5')
|
||||
assert.equal(resolveEventSeverity({ severity: 0 }), '0')
|
||||
assert.equal(resolveEventSeverity({ severity: -1 }), '-')
|
||||
assert.equal(resolveEventSeverity({ severity: '-2' }), '-')
|
||||
assert.equal(resolveEventSeverity({ severity: '' }), '-')
|
||||
assert.equal(resolveEventSeverity({}), '-')
|
||||
assert.equal(resolveEventSeverity(null), '-')
|
||||
|
||||
const eventTypeOptions = [
|
||||
{ id: 'c5ce588cb76fba90c4510000000000000', name: '电压暂降', code: 'VOLTAGE_SAG' },
|
||||
{ id: 'a26e588cb76fba90c4510000000000000', name: '电压暂升', code: 'VOLTAGE_SWELL', value: 'SWELL' }
|
||||
@@ -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 pageFile = path.join(currentDir, '..', 'index.vue')
|
||||
const componentDir = path.join(currentDir, '..', 'components')
|
||||
const apiFile = path.resolve(currentDir, '../../../../api/event/eventList/index.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../../api/event/eventList/interface/index.ts')
|
||||
|
||||
const pageSource = fs.readFileSync(pageFile, 'utf8')
|
||||
const componentSource = fs.existsSync(componentDir)
|
||||
? fs
|
||||
.readdirSync(componentDir)
|
||||
.filter(file => file.endsWith('.vue'))
|
||||
.map(file => fs.readFileSync(path.join(componentDir, file), 'utf8'))
|
||||
.join('\n')
|
||||
: ''
|
||||
const source = `${pageSource}\n${componentSource}`
|
||||
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||
const eventExportButtonBlocks = (source.match(/<el-button[\s\S]*?<\/el-button>/g) || []).filter(
|
||||
block => /@click="handleEventExport"/.test(block)
|
||||
)
|
||||
|
||||
const expectations = [
|
||||
['waveform export api is imported by the page', /import \{[\s\S]*exportTransientWaveforms[\s\S]*\} from '@\/api\/event\/eventList'/],
|
||||
['waveform export button is present and disabled without selection', /<el-button[\s\S]*:disabled="!selectedWaveformRows\.length"[\s\S]*@click="handleWaveformExport"[\s\S]*<\/el-button>/],
|
||||
['event export button is wired explicitly', /<el-button[\s\S]*@click="handleEventExport"[\s\S]*<\/el-button>/],
|
||||
['event export button does not depend on waveform selection', eventExportButtonBlocks.some(block => !/selectedWaveformRows/.test(block))],
|
||||
['waveform selected rows are tracked independently', /const selectedWaveformRows\s*=\s*ref<EventList\.TransientEventRecord\[\]>\(\[\]\)/],
|
||||
['waveform export payload uses event ids only', /eventIds:\s*rows\.map\(row => row\.eventId\)/],
|
||||
['waveform export uses server filename download hook', /useDownloadWithServerFileName\(exportTransientWaveforms/],
|
||||
['waveform selection clear helper exists', /const clearWaveformSelection\s*=\s*\(\)\s*=>\s*\{[\s\S]*selectedWaveformRows\.value\s*=\s*\[\][\s\S]*\}/],
|
||||
['table request clears waveform selection before reload', /const getTableList\s*=\s*\([^)]*\)\s*=>\s*\{[\s\S]*clearWaveformSelection\(\)[\s\S]*props\.requestApi/],
|
||||
['search reset clears waveform selection', /const handleSearchReset\s*=\s*\(\)\s*=>\s*\{[\s\S]*clearWaveformSelection\(\)[\s\S]*commitEventTimeRange\(\{ shouldSearch: true \}\)/],
|
||||
['waveform export api method exists', /export const exportTransientWaveforms\s*=\s*\(params:\s*EventList\.TransientWaveformExportParams\)/.test(apiSource)],
|
||||
['waveform export api path follows API_DEBUG.md', /downloadWithHeaders\('\/event\/list\/transient\/wave\/export',\s*params\)/.test(apiSource)],
|
||||
['waveform export params type contains eventIds', /export interface TransientWaveformExportParams\s*\{[\s\S]*eventIds:\s*string\[\][\s\S]*\}/.test(interfaceSource)]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => {
|
||||
if (typeof pattern === 'boolean') return !pattern
|
||||
return !pattern.test(source)
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('eventList export contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('eventList export contract check passed')
|
||||
@@ -28,10 +28,26 @@ const { buildEventQueryParams } = await import(pathToFileURL(tempModulePath).hre
|
||||
const params = buildEventQueryParams({
|
||||
pageNum: 2,
|
||||
pageSize: 20,
|
||||
eventType: 'VOLTAGE_SAG'
|
||||
eventType: 'VOLTAGE_SAG',
|
||||
startTimeRange: ['2025-12-01 00:00:00.000', '2025-12-31 23:59:59.999'],
|
||||
featureAmplitudeMin: '10',
|
||||
featureAmplitudeMax: '90',
|
||||
durationMin: '0.02',
|
||||
durationMax: '10',
|
||||
severityMin: '1',
|
||||
severityMax: '5'
|
||||
})
|
||||
|
||||
assert.equal(params.eventType, 'VOLTAGE_SAG')
|
||||
assert.equal(params.startTimeStart, '2025-12-01 00:00:00')
|
||||
assert.equal(params.startTimeEnd, '2025-12-31 23:59:59')
|
||||
assert.equal(params.featureAmplitudeMin, 10)
|
||||
assert.equal(params.featureAmplitudeMax, 90)
|
||||
assert.equal(params.durationMin, 0.02)
|
||||
assert.equal(params.durationMax, 10)
|
||||
assert.equal(params.severityMin, 1)
|
||||
assert.equal(params.severityMax, 5)
|
||||
assert.equal(Object.hasOwn(params, 'eventTypeCode'), false)
|
||||
assert.equal(source.includes(['event', 'Describe'].join('')), false)
|
||||
|
||||
console.log('eventList query params contract passed')
|
||||
@@ -4,11 +4,11 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const routersDir = path.join(currentDir, '..', '..', '..', 'routers', 'modules')
|
||||
const routersDir = path.join(currentDir, '..', '..', '..', '..', 'routers', 'modules')
|
||||
const staticRouterFile = path.join(routersDir, 'staticRouter.ts')
|
||||
const dynamicRouterFile = path.join(routersDir, 'dynamicRouter.ts')
|
||||
const authStoreFile = path.join(currentDir, '..', '..', '..', 'stores', 'modules', 'auth.ts')
|
||||
const subMenuFile = path.join(currentDir, '..', '..', '..', 'layouts', 'components', 'Menu', 'SubMenu.vue')
|
||||
const authStoreFile = path.join(currentDir, '..', '..', '..', '..', 'stores', 'modules', 'auth.ts')
|
||||
const subMenuFile = path.join(currentDir, '..', '..', '..', '..', 'layouts', 'components', 'Menu', 'SubMenu.vue')
|
||||
|
||||
const staticRouterSource = fs.readFileSync(staticRouterFile, 'utf8')
|
||||
const dynamicRouterSource = fs.readFileSync(dynamicRouterFile, 'utf8')
|
||||
@@ -0,0 +1,78 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||
const componentFile = path.join(currentDir, '..', 'components', 'EventListTable.vue')
|
||||
const pageSource = fs.readFileSync(pageFile, 'utf8')
|
||||
const componentSource = fs.existsSync(componentFile) ? fs.readFileSync(componentFile, 'utf8') : ''
|
||||
const source = `${pageSource}\n${componentSource}`
|
||||
|
||||
const expectations = [
|
||||
['page renders extracted event list table component', /<EventListTable/],
|
||||
['event list table component exists', fs.existsSync(componentFile)],
|
||||
['search grid keeps five fields on wide event list screens', /:search-col="\{\s*xs:\s*1,\s*sm:\s*2,\s*md:\s*2,\s*lg:\s*5,\s*xl:\s*5\s*\}"/],
|
||||
['event time table column keeps occurrence time prop', /prop:\s*'startTime'/],
|
||||
['event time search uses startTimeRange key', /search:\s*\{[\s\S]*key:\s*'startTimeRange'/],
|
||||
['event time search field only takes one grid column', /key:\s*'startTimeRange'[\s\S]*span:\s*1,/],
|
||||
[
|
||||
'event type search is ordered immediately after ledger keyword search',
|
||||
/prop:\s*'ledgerKeyword'[\s\S]*search:\s*\{[\s\S]*order:\s*3[\s\S]*prop:\s*'eventTypeName'[\s\S]*search:\s*\{[\s\S]*order:\s*4/
|
||||
],
|
||||
['event time search imports shared TimePeriodSearch component', /import TimePeriodSearch from '@\/views\/components\/TimePeriodSearch\/index\.vue'/],
|
||||
['event time search imports shared period helpers', /from '@\/views\/components\/TimePeriodSearch\/timePeriod'/],
|
||||
['event time search renders shared TimePeriodSearch', /h\(TimePeriodSearch,[\s\S]*unit:\s*eventTimeUnit\.value,[\s\S]*modelValue:\s*eventTimeBaseDate\.value/],
|
||||
[
|
||||
'event list requests use current visible time range instead of stale ProTable params',
|
||||
/const resolveCurrentSearchParams[\s\S]*startTimeRange:\s*buildTimePeriodRange\(eventTimeUnit\.value,\s*eventTimeBaseDate\.value\)[\s\S]*props\.requestApi\(resolveCurrentSearchParams\(params\)\)/
|
||||
],
|
||||
[
|
||||
'event time changes are committed through one search entry',
|
||||
/const commitEventTimeRange[\s\S]*proTable\.value\?\.search\(\)/
|
||||
],
|
||||
[
|
||||
'event time unit changes trigger table search',
|
||||
/const handleEventTimeUnitChange[\s\S]*commitEventTimeRange\(\{ shouldSearch: true \}\)/
|
||||
],
|
||||
[
|
||||
'event time date changes trigger table search',
|
||||
/const handleEventTimeDateChange[\s\S]*commitEventTimeRange\(\{ shouldSearch: true \}\)/
|
||||
],
|
||||
[
|
||||
'event export button is wired explicitly',
|
||||
/<el-button[\s\S]*@click="handleEventExport"[\s\S]*<\/el-button>/
|
||||
],
|
||||
[
|
||||
'event export uses the same current search params as the table request',
|
||||
/const handleEventExport[\s\S]*resolveCurrentSearchParams\(searchParam\)/
|
||||
],
|
||||
[
|
||||
'number range search keeps min and max inputs on one line',
|
||||
/event-number-range-search[\s\S]*flex-wrap:\s*nowrap;/
|
||||
],
|
||||
[
|
||||
'number range inputs pass compact width through render props',
|
||||
/const numberRangeInputStyle[\s\S]*flex:\s*'0 0 72px'[\s\S]*width:\s*'72px'[\s\S]*h\(ElInputNumber,\s*\{[\s\S]*style:\s*numberRangeInputStyle[\s\S]*h\(ElInputNumber,\s*\{[\s\S]*style:\s*numberRangeInputStyle/
|
||||
],
|
||||
[
|
||||
'number range separator keeps three pixel side margin',
|
||||
/const numberRangeSeparatorStyle[\s\S]*flex:\s*'0 0 auto'[\s\S]*margin:\s*'0 3px'[\s\S]*class:\s*'event-number-range-separator',\s*style:\s*numberRangeSeparatorStyle/
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => {
|
||||
if (typeof pattern === 'boolean') return !pattern
|
||||
return !pattern.test(source)
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('eventList search layout contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('eventList search layout contract check passed')
|
||||
@@ -5,7 +5,7 @@ import { pathToFileURL } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const sharedModulePath = path.resolve('src/views/components/TimePeriodSearch/timePeriod.ts')
|
||||
const eventModulePath = path.resolve('src/views/event/eventList/eventTimeRange.ts')
|
||||
const eventModulePath = path.resolve('src/views/event/eventList/utils/eventTimeRange.ts')
|
||||
|
||||
if (!fs.existsSync(sharedModulePath)) {
|
||||
throw new Error('TimePeriodSearch/timePeriod.ts must provide shared time range helpers')
|
||||
@@ -0,0 +1,85 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||
const componentDir = path.join(currentDir, '..', 'components')
|
||||
const queryFile = path.join(currentDir, '..', 'utils', 'queryParams.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../../api/event/eventList/interface/index.ts')
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8')
|
||||
const pageSource = read(pageFile)
|
||||
const componentSource = fs.existsSync(componentDir)
|
||||
? fs
|
||||
.readdirSync(componentDir)
|
||||
.filter(file => file.endsWith('.vue'))
|
||||
.map(file => read(path.join(componentDir, file)))
|
||||
.join('\n')
|
||||
: ''
|
||||
const source = `${pageSource}\n${componentSource}`
|
||||
const querySource = read(queryFile)
|
||||
const interfaceSource = read(interfaceFile)
|
||||
const measurementPointItemsBlock = source.match(/const measurementPointItems[\s\S]*?\n\]/)?.[0] || ''
|
||||
|
||||
const expectations = [
|
||||
['page imports extracted event list table component', /EventListTable/],
|
||||
['page imports extracted measurement point dialog component', /MeasurementPointDialog/],
|
||||
['event list table component exists', fs.existsSync(path.join(componentDir, 'EventListTable.vue'))],
|
||||
['measurement point dialog component exists', fs.existsSync(path.join(componentDir, 'MeasurementPointDialog.vue'))],
|
||||
[
|
||||
'waveform selection column is rendered near the left side',
|
||||
/prop:\s*'waveformSelection'[\s\S]*fixed:\s*'left'[\s\S]*headerRender:\s*renderWaveformSelectionHeader[\s\S]*render:\s*renderWaveformSelectionCell/
|
||||
],
|
||||
[
|
||||
'waveform selection requires event id, waveform flag and waveform path',
|
||||
/const isWaveformExportable[\s\S]*Boolean\(row\.eventId\)[\s\S]*Number\(row\.fileFlag\)\s*===\s*1[\s\S]*Boolean\(row\.wavePath\)/
|
||||
],
|
||||
[
|
||||
'table keeps requested visible event column order',
|
||||
/prop:\s*'startTime'[\s\S]*prop:\s*'lineName'[\s\S]*prop:\s*'featureAmplitude'[\s\S]*prop:\s*'duration'[\s\S]*prop:\s*'eventTypeName'[\s\S]*prop:\s*'severity'[\s\S]*prop:\s*'phase'/
|
||||
],
|
||||
['severity column renders through severity resolver', /prop:\s*'severity'[\s\S]*render:\s*\(\{ row \}\)\s*=>\s*resolveEventSeverity\(row\)/],
|
||||
[
|
||||
'event type column displays name but searches by eventType code',
|
||||
/prop:\s*'eventTypeName'[\s\S]*enum:\s*props\.eventTypeOptions[\s\S]*fieldNames:\s*\{\s*label:\s*'name',\s*value:\s*'code'\s*\}[\s\S]*isFilterEnum:\s*false[\s\S]*search:\s*\{[\s\S]*key:\s*'eventType'[\s\S]*el:\s*'select'/
|
||||
],
|
||||
['event description defaults hidden in table columns', /prop:\s*'event_describe'[\s\S]*isShow:\s*false/],
|
||||
['event location defaults hidden in table columns', /prop:\s*'sagsource'[\s\S]*isShow:\s*false/],
|
||||
['waveform status defaults hidden in table columns', /prop:\s*'fileFlag'[\s\S]*isShow:\s*false/],
|
||||
['monitor point is rendered as a clickable link', /prop:\s*'lineName'[\s\S]*type:\s*'primary'[\s\S]*link:\s*true[\s\S]*viewMeasurementPoint/],
|
||||
['measurement point dialog is present', /<MeasurementPointDialog[\s\S]*v-model:visible="measurementPointDialogVisible"/],
|
||||
['measurement point dialog hides measurement point id', measurementPointItemsBlock && !/measurementPointId/.test(measurementPointItemsBlock)],
|
||||
[
|
||||
'measurement point dialog shows network params after equipment name',
|
||||
/prop:\s*'equipmentName'[\s\S]*prop:\s*'mac'[\s\S]*prop:\s*'lineName'/.test(measurementPointItemsBlock)
|
||||
],
|
||||
['event record type supports network param mac', /mac\?:\s*string/.test(interfaceSource)],
|
||||
['event record type supports sag severity', /severity\?:\s*number/.test(interfaceSource)],
|
||||
['operation switches between view waveform and supplement waveform', /Number\(row\.fileFlag\)\s*===\s*1[\s\S]*viewWaveform[\s\S]*supplementWaveform/],
|
||||
['phase column renders eventList phase text explicitly', /prop:\s*'phase'[\s\S]*isFilterEnum:\s*false[\s\S]*render:\s*\(\{ row \}\)\s*=>\s*resolvePhaseText\(row\.phase\)/],
|
||||
['waveform status search uses custom render instead of select', /renderFileFlagSearch[\s\S]*prop:\s*'fileFlag'[\s\S]*search:\s*\{[\s\S]*render:\s*renderFileFlagSearch/],
|
||||
['phase column is not a search field', /prop:\s*'phase'[\s\S]*search:[\s\S]*prop:\s*'event_describe'/.test(source) === false],
|
||||
[
|
||||
'event description is not a search field',
|
||||
/prop:\s*'event_describe'[\s\S]*search:[\s\S]*prop:\s*'sagsource'/.test(source) === false
|
||||
],
|
||||
['ledger names are searched through one keyword field', /prop:\s*'ledgerKeyword'[\s\S]*search:\s*\{[\s\S]*order:\s*3/],
|
||||
['query params fan out ledger keyword to ledger name fields', /ledgerKeyword[\s\S]*engineeringName[\s\S]*projectName[\s\S]*equipmentName[\s\S]*lineName/.test(querySource)]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => {
|
||||
if (typeof pattern === 'boolean') return !pattern
|
||||
return !pattern.test(source)
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('eventList visible contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('eventList visible contract check passed')
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const utilsFile = path.resolve(currentDir, '../utils/voltageTolerance.ts')
|
||||
const tableFile = path.resolve(currentDir, '../components/EventListTable.vue')
|
||||
const dialogFile = path.resolve(currentDir, '../components/VoltageToleranceDialog.vue')
|
||||
const pageFile = path.resolve(currentDir, '../index.vue')
|
||||
|
||||
if (!fs.existsSync(utilsFile)) {
|
||||
throw new Error('eventList voltage tolerance helpers must be extracted to utils/voltageTolerance.ts')
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dialogFile)) {
|
||||
throw new Error('eventList voltage tolerance dialog must be extracted to components/VoltageToleranceDialog.vue')
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(utilsFile, 'utf8')
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ES2020,
|
||||
target: ts.ScriptTarget.ES2020
|
||||
}
|
||||
}).outputText
|
||||
|
||||
const tempDir = path.resolve('node_modules/.cache/event-list-contract')
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
const tempModulePath = path.join(tempDir, 'voltageTolerance.mjs')
|
||||
fs.writeFileSync(tempModulePath, transpiled, 'utf8')
|
||||
|
||||
const {
|
||||
VOLTAGE_TOLERANCE_STANDARDS,
|
||||
evaluateVoltageTolerance,
|
||||
resolveVoltageTolerancePoint,
|
||||
buildVoltageToleranceChartOption
|
||||
} = await import(pathToFileURL(tempModulePath).href)
|
||||
|
||||
assert.deepEqual(
|
||||
VOLTAGE_TOLERANCE_STANDARDS.map(item => item.key),
|
||||
['itic', 'semiF47']
|
||||
)
|
||||
|
||||
assert.deepEqual(resolveVoltageTolerancePoint({ duration: 0.5, featureAmplitude: 70 }), {
|
||||
durationSeconds: 0.5,
|
||||
voltagePercent: 70
|
||||
})
|
||||
assert.equal(resolveVoltageTolerancePoint({ duration: null, featureAmplitude: 70 }), null)
|
||||
assert.equal(resolveVoltageTolerancePoint({ duration: 0.5, featureAmplitude: undefined }), null)
|
||||
|
||||
assert.equal(evaluateVoltageTolerance('semiF47', { duration: 0.2, featureAmplitude: 50 })?.tolerable, true)
|
||||
assert.equal(evaluateVoltageTolerance('semiF47', { duration: 0.5, featureAmplitude: 65 })?.tolerable, false)
|
||||
assert.equal(evaluateVoltageTolerance('itic', { duration: 10, featureAmplitude: 80 })?.tolerable, true)
|
||||
assert.equal(evaluateVoltageTolerance('itic', { duration: 10, featureAmplitude: 75 })?.tolerable, false)
|
||||
assert.equal(evaluateVoltageTolerance('itic', { duration: 0, featureAmplitude: 75 }), null)
|
||||
|
||||
const option = buildVoltageToleranceChartOption('itic', { duration: 10, featureAmplitude: 75 })
|
||||
assert.equal(option.xAxis.type, 'log')
|
||||
assert.ok(option.series.some(item => item.name === '事件点'))
|
||||
assert.ok(option.series.some(item => item.name.includes('ITIC')))
|
||||
|
||||
const tableSource = fs.readFileSync(tableFile, 'utf8')
|
||||
const pageSource = fs.readFileSync(pageFile, 'utf8')
|
||||
const dialogSource = fs.readFileSync(dialogFile, 'utf8')
|
||||
|
||||
assert.match(tableSource, /ITIC\/SEMI F47/)
|
||||
assert.match(tableSource, /viewVoltageTolerance/)
|
||||
assert.match(pageSource, /VoltageToleranceDialog/)
|
||||
assert.match(pageSource, /voltageToleranceDialogVisible/)
|
||||
assert.match(dialogSource, /<el-tabs[\s\S]*ITIC[\s\S]*SEMI F47/)
|
||||
assert.match(dialogSource, /buildVoltageToleranceChartOption/)
|
||||
assert.match(dialogSource, /formatEventOccurrenceTime\(data\.startTime\)/)
|
||||
assert.doesNotMatch(dialogSource, /label="监测点"/)
|
||||
assert.doesNotMatch(dialogSource, /label="相别"/)
|
||||
assert.doesNotMatch(dialogSource, /label="事件描述"/)
|
||||
|
||||
console.log('eventList voltage tolerance contract passed')
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||
const apiFile = path.resolve(currentDir, '../../../../api/event/eventList/index.ts')
|
||||
const waveformPageFile = path.resolve(currentDir, '../../../tools/waveform/index.vue')
|
||||
const source = fs.readFileSync(pageFile, 'utf8')
|
||||
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||
const waveformSource = fs.readFileSync(waveformPageFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
'event list api exposes transient waveform debug endpoint',
|
||||
/export const getTransientEventWave\s*=\s*\(eventId:\s*string\)[\s\S]*http\.get[\s\S]*`\/event\/list\/transient\/\$\{eventId\}\/wave`/.test(apiSource)
|
||||
],
|
||||
['event list page imports waveform debug endpoint', /import \{[\s\S]*getTransientEventWave[\s\S]*\} from '@\/api\/event\/eventList'/],
|
||||
['view waveform validates event id before requesting waveform data', /if \(!row\.eventId\)[\s\S]*return/],
|
||||
['view waveform requests backend-parsed waveform data', /const response\s*=\s*await getTransientEventWave\(row\.eventId\)/],
|
||||
[
|
||||
'view waveform stores parsed waveform result for waveform page',
|
||||
/sessionStorage\.setItem\(\s*EVENT_LIST_WAVEFORM_SESSION_KEY,\s*JSON\.stringify\(response\.data\)\s*\)/
|
||||
],
|
||||
[
|
||||
'view waveform navigates to waveform page with event source query',
|
||||
/router\.push\(\{[\s\S]*path:\s*'\/tools\/waveform'[\s\S]*source:\s*'eventList'[\s\S]*eventId:\s*row\.eventId/
|
||||
],
|
||||
[
|
||||
'waveform page reads the shared event list session key',
|
||||
/const EVENT_LIST_WAVEFORM_SESSION_KEY\s*=\s*'eventList:waveformParseResult'/.test(waveformSource)
|
||||
],
|
||||
[
|
||||
'waveform page loads event list waveform data on mount',
|
||||
/onMounted\(\(\)\s*=>\s*\{[\s\S]*sessionStorage\.getItem\(EVENT_LIST_WAVEFORM_SESSION_KEY\)[\s\S]*applyWaveformParseResult/.test(waveformSource)
|
||||
],
|
||||
[
|
||||
'waveform page removes consumed event list waveform data',
|
||||
/sessionStorage\.removeItem\(EVENT_LIST_WAVEFORM_SESSION_KEY\)/.test(waveformSource)
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => {
|
||||
if (typeof pattern === 'boolean') return !pattern
|
||||
return !pattern.test(source)
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('eventList waveform view contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('eventList waveform view contract check passed')
|
||||
@@ -1,241 +1,61 @@
|
||||
<template>
|
||||
<div class="table-box event-list-page">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
row-key="eventId"
|
||||
:columns="columns"
|
||||
<EventListTable
|
||||
:event-type-options="eventTypeOptions"
|
||||
:request-api="getTableList"
|
||||
:search-col="{ xs: 1, sm: 2, md: 2, lg: 5, xl: 5 }"
|
||||
@reset="handleSearchReset"
|
||||
>
|
||||
<template #tableHeader>
|
||||
<el-button type="primary" plain :icon="Download" @click="handleExport">导出</el-button>
|
||||
</template>
|
||||
@event-export="handleEventExport"
|
||||
@waveform-export="handleWaveformExport"
|
||||
@view-measurement-point="handleViewMeasurementPoint"
|
||||
@view-voltage-tolerance="handleViewVoltageTolerance"
|
||||
@view-waveform="handleViewWaveform"
|
||||
@supplement-waveform="handleSupplementWaveform"
|
||||
/>
|
||||
|
||||
<template #fileFlag="{ row }">
|
||||
<el-tag :type="Number(row.fileFlag) === 1 ? 'success' : 'info'" effect="light">
|
||||
{{ resolveFileFlagText(row.fileFlag) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<MeasurementPointDialog
|
||||
v-model:visible="measurementPointDialogVisible"
|
||||
:loading="measurementPointLoading"
|
||||
:data="measurementPointData"
|
||||
/>
|
||||
|
||||
<template #operation="{ row }">
|
||||
<el-button v-if="Number(row.fileFlag) === 1" type="primary" link :icon="View" @click="handleViewWaveform(row)">
|
||||
查看波形
|
||||
</el-button>
|
||||
<el-button v-else type="primary" link :icon="RefreshRight" @click="handleSupplementWaveform(row)">
|
||||
波形补招
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
|
||||
<el-dialog v-model="measurementPointDialogVisible" title="监测点信息" width="640px">
|
||||
<el-skeleton v-if="measurementPointLoading" :rows="4" animated />
|
||||
<el-descriptions v-else :column="2" border>
|
||||
<el-descriptions-item
|
||||
v-for="item in measurementPointItems"
|
||||
:key="item.prop"
|
||||
:label="item.label"
|
||||
>
|
||||
{{ resolveText(measurementPointData?.[item.prop]) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
<VoltageToleranceDialog
|
||||
v-model:visible="voltageToleranceDialogVisible"
|
||||
:data="voltageToleranceEventData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElButton, ElRadioButton, ElRadioGroup } from 'element-plus'
|
||||
import { Download, RefreshRight, View } from '@element-plus/icons-vue'
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
||||
import { exportTransientEvents, getTransientEventDetail, getTransientEventPage } from '@/api/event/eventList'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
exportTransientEvents,
|
||||
exportTransientWaveforms,
|
||||
getTransientEventDetail,
|
||||
getTransientEventPage,
|
||||
getTransientEventWave
|
||||
} from '@/api/event/eventList'
|
||||
import type { EventList } from '@/api/event/eventList/interface'
|
||||
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
import { DICT_CODES } from '@/constants/dictCodes'
|
||||
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
||||
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||
import { formatEventOccurrenceTime } from './eventTimeRange'
|
||||
import EventListTable from './components/EventListTable.vue'
|
||||
import MeasurementPointDialog from './components/MeasurementPointDialog.vue'
|
||||
import VoltageToleranceDialog from './components/VoltageToleranceDialog.vue'
|
||||
import { buildEventQueryParams, type EventSearchParams } from './utils/queryParams'
|
||||
import { resolveEventDescription, resolveEventTypeName } from './utils/display'
|
||||
import {
|
||||
fileFlagOptions,
|
||||
phaseOptions,
|
||||
resolveFileFlagText
|
||||
} from './utils/status'
|
||||
|
||||
defineOptions({
|
||||
name: 'EventListView'
|
||||
})
|
||||
|
||||
const proTable = ref<ProTableInstance>()
|
||||
const router = useRouter()
|
||||
const dictStore = useDictStore()
|
||||
const EVENT_LIST_WAVEFORM_SESSION_KEY = 'eventList:waveformParseResult'
|
||||
const measurementPointDialogVisible = ref(false)
|
||||
const measurementPointLoading = ref(false)
|
||||
const measurementPointData = ref<EventList.TransientEventRecord | null>(null)
|
||||
const eventTimeUnit = ref<TimePeriodUnit>('month')
|
||||
const eventTimeBaseDate = ref(new Date())
|
||||
const defaultStartTimeRange = buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
|
||||
const voltageToleranceDialogVisible = ref(false)
|
||||
const voltageToleranceEventData = ref<EventList.TransientEventRecord | null>(null)
|
||||
const eventTypeOptions = computed(() => dictStore.getDictData(DICT_CODES.EVENT_TYPE))
|
||||
const measurementPointItems: { label: string; prop: keyof EventList.TransientEventRecord }[] = [
|
||||
{ label: '工程名称', prop: 'engineeringName' },
|
||||
{ label: '项目名称', prop: 'projectName' },
|
||||
{ label: '设备名称', prop: 'equipmentName' },
|
||||
{ label: '监测点名称', prop: 'lineName' },
|
||||
{ label: '监测点 ID', prop: 'measurementPointId' }
|
||||
]
|
||||
|
||||
const syncEventTimeRange = () => {
|
||||
const timeRange = buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
|
||||
const searchParam = proTable.value?.searchParam as EventSearchParams | undefined
|
||||
|
||||
if (searchParam) {
|
||||
searchParam.startTimeRange = timeRange
|
||||
}
|
||||
|
||||
return timeRange
|
||||
}
|
||||
|
||||
const handleEventTimeUnitChange = (value: TimePeriodUnit) => {
|
||||
eventTimeUnit.value = value
|
||||
syncEventTimeRange()
|
||||
}
|
||||
|
||||
const handleEventTimeDateChange = (value: Date | string | number | null) => {
|
||||
if (!value) return
|
||||
eventTimeBaseDate.value = new Date(value)
|
||||
syncEventTimeRange()
|
||||
}
|
||||
|
||||
const handleSearchReset = () => {
|
||||
eventTimeUnit.value = 'month'
|
||||
eventTimeBaseDate.value = new Date()
|
||||
syncEventTimeRange()
|
||||
}
|
||||
|
||||
const renderEventTimeSearch = () =>
|
||||
h(TimePeriodSearch, {
|
||||
class: 'event-time-search',
|
||||
unit: eventTimeUnit.value,
|
||||
modelValue: eventTimeBaseDate.value,
|
||||
'onUpdate:unit': handleEventTimeUnitChange,
|
||||
'onUpdate:modelValue': handleEventTimeDateChange
|
||||
})
|
||||
|
||||
const renderFileFlagSearch = ({ searchParam }: { searchParam: EventSearchParams }) => {
|
||||
return h(
|
||||
ElRadioGroup,
|
||||
{
|
||||
class: 'event-file-flag-search',
|
||||
modelValue: searchParam.fileFlag ?? '',
|
||||
'onUpdate:modelValue': (value: string | number | boolean | undefined) => {
|
||||
searchParam.fileFlag = value === '' || value === undefined ? undefined : Number(value)
|
||||
}
|
||||
},
|
||||
() => [
|
||||
h(ElRadioButton, { label: '' }, () => '全部'),
|
||||
...fileFlagOptions.map(option =>
|
||||
h(ElRadioButton, { key: option.value, label: option.value }, () => option.label)
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const columns = reactive<ColumnProps<EventList.TransientEventRecord>[]>([
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||
{
|
||||
prop: 'startTime',
|
||||
label: '发生时刻',
|
||||
minWidth: 200,
|
||||
render: ({ row }) => formatEventOccurrenceTime(row.startTime),
|
||||
search: {
|
||||
label: '时间',
|
||||
key: 'startTimeRange',
|
||||
span: 1,
|
||||
defaultValue: defaultStartTimeRange,
|
||||
render: renderEventTimeSearch
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'ledgerKeyword',
|
||||
label: '台账关键字',
|
||||
isShow: false,
|
||||
isSetting: false,
|
||||
search: {
|
||||
el: 'input',
|
||||
label: '台账关键字',
|
||||
props: {
|
||||
placeholder: '工程/项目/设备/监测点'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'lineName',
|
||||
label: '监测点名称',
|
||||
minWidth: 180,
|
||||
render: ({ row }) =>
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
type: 'primary',
|
||||
link: true,
|
||||
onClick: () => handleViewMeasurementPoint(row)
|
||||
},
|
||||
() => resolveText(row.lineName)
|
||||
)
|
||||
},
|
||||
{ prop: 'duration', label: '持续时间(s)', minWidth: 130 },
|
||||
{ prop: 'featureAmplitude', label: '暂降/暂升幅值(%)', minWidth: 160 },
|
||||
{
|
||||
prop: 'eventTypeName',
|
||||
label: '事件类型',
|
||||
minWidth: 160,
|
||||
enum: eventTypeOptions,
|
||||
fieldNames: { label: 'name', value: 'id' },
|
||||
isFilterEnum: false,
|
||||
render: ({ row }) => resolveEventTypeName(row, eventTypeOptions.value),
|
||||
search: {
|
||||
key: 'eventType',
|
||||
el: 'select'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'phase',
|
||||
label: '相别',
|
||||
minWidth: 90,
|
||||
enum: phaseOptions,
|
||||
search: {
|
||||
el: 'select'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'event_describe',
|
||||
label: '事件描述',
|
||||
minWidth: 180,
|
||||
isShow: false,
|
||||
render: ({ row }) => resolveEventDescription(row)
|
||||
},
|
||||
{ prop: 'sagsource', label: '事件发生位置', minWidth: 140, isShow: false },
|
||||
{
|
||||
prop: 'fileFlag',
|
||||
label: '波形文件状态',
|
||||
minWidth: 130,
|
||||
isShow: false,
|
||||
enum: fileFlagOptions,
|
||||
search: {
|
||||
render: renderFileFlagSearch
|
||||
}
|
||||
},
|
||||
{ prop: 'operation', label: '操作', fixed: 'right', width: 130 }
|
||||
])
|
||||
|
||||
const resolveText = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const getTableList = (params: EventSearchParams) => {
|
||||
// 分页查询按 API_DEBUG.md 转换时间范围与枚举筛选参数。
|
||||
@@ -244,7 +64,7 @@ const getTableList = (params: EventSearchParams) => {
|
||||
|
||||
const handleViewMeasurementPoint = async (row: EventList.TransientEventRecord) => {
|
||||
if (!row.eventId) {
|
||||
ElMessage.warning('缺少事件 ID,无法查询监测点信息')
|
||||
ElMessage.warning('\u7f3a\u5c11\u4e8b\u4ef6 ID\uff0c\u65e0\u6cd5\u67e5\u8be2\u76d1\u6d4b\u70b9\u4fe1\u606f')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -260,29 +80,48 @@ const handleViewMeasurementPoint = async (row: EventList.TransientEventRecord) =
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewWaveform = (row: EventList.TransientEventRecord) => {
|
||||
if (!row.wavePath) {
|
||||
ElMessage.warning('缺少波形文件路径,无法查看波形')
|
||||
const handleViewWaveform = async (row: EventList.TransientEventRecord) => {
|
||||
if (!row.eventId) {
|
||||
ElMessage.warning('\u7f3a\u5c11\u4e8b\u4ef6 ID\uff0c\u65e0\u6cd5\u67e5\u770b\u6ce2\u5f62')
|
||||
return
|
||||
}
|
||||
|
||||
// 查看波形由后端按事件定位并解析 COMTRADE 文件,前端只传递解析结果。
|
||||
const response = await getTransientEventWave(row.eventId)
|
||||
sessionStorage.setItem(EVENT_LIST_WAVEFORM_SESSION_KEY, JSON.stringify(response.data))
|
||||
|
||||
router.push({
|
||||
path: '/tools/waveform',
|
||||
query: {
|
||||
eventId: row.eventId,
|
||||
wavePath: row.wavePath
|
||||
source: 'eventList',
|
||||
eventId: row.eventId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSupplementWaveform = (_row: EventList.TransientEventRecord) => {
|
||||
// 波形补招需要后端补招接口,当前先保留操作入口避免误触发未知流程。
|
||||
ElMessage.warning('暂无波形补招接口,无法发起补招')
|
||||
const handleViewVoltageTolerance = (row: EventList.TransientEventRecord) => {
|
||||
voltageToleranceEventData.value = row
|
||||
voltageToleranceDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const searchParam = (proTable.value?.searchParam || {}) as EventSearchParams
|
||||
useDownloadWithServerFileName(exportTransientEvents, '暂态事件列表', buildEventQueryParams(searchParam), false)
|
||||
const handleSupplementWaveform = () => {
|
||||
// 波形补招需要后端补招接口,当前保留操作入口避免误触发未知流程。
|
||||
ElMessage.warning('\u6682\u65e0\u6ce2\u5f62\u8865\u62db\u63a5\u53e3\uff0c\u65e0\u6cd5\u53d1\u8d77\u8865\u62db')
|
||||
}
|
||||
|
||||
const handleEventExport = (params: EventSearchParams) => {
|
||||
useDownloadWithServerFileName(exportTransientEvents, '\u6682\u6001\u4e8b\u4ef6\u5217\u8868', buildEventQueryParams(params), false)
|
||||
}
|
||||
|
||||
const handleWaveformExport = (rows: EventList.TransientEventRecord[]) => {
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('\u8bf7\u5148\u9009\u62e9\u5b58\u5728\u6ce2\u5f62\u7684\u4e8b\u4ef6')
|
||||
return
|
||||
}
|
||||
|
||||
useDownloadWithServerFileName(exportTransientWaveforms, '\u4e8b\u4ef6\u6ce2\u5f62\u5bfc\u51fa', {
|
||||
eventIds: rows.map(row => row.eventId)
|
||||
}, false, '.zip')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -296,8 +135,4 @@ const handleExport = () => {
|
||||
.event-list-page :deep(.el-descriptions__cell) {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.event-list-page :deep(.event-file-flag-search) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventList } from '@/api/event/eventList/interface'
|
||||
import { formatEventOccurrenceTime } from '../eventTimeRange'
|
||||
import { formatEventOccurrenceTime } from './eventTimeRange'
|
||||
import { resolveEventDescription } from './display'
|
||||
import { resolveDealFlagText, resolveFileFlagText } from './status'
|
||||
|
||||
|
||||
@@ -22,6 +22,18 @@ export const resolveEventDescription = (row: EventRecordLike) => {
|
||||
return description || '--'
|
||||
}
|
||||
|
||||
export const resolveEventSeverity = (row: EventRecordLike) => {
|
||||
if (!row) return '-'
|
||||
|
||||
const severityText = resolveOptionalText((row as Record<string, unknown>).severity)
|
||||
if (!severityText) return '-'
|
||||
|
||||
const severity = Number(severityText)
|
||||
if (!Number.isFinite(severity) || severity < 0) return '-'
|
||||
|
||||
return String(severity)
|
||||
}
|
||||
|
||||
export const resolveEventTypeName = (row: EventRecordLike, eventTypeOptions: EventTypeOptionLike[] = []) => {
|
||||
if (!row) return '/'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export const formatEventOccurrenceTime = (value: unknown) => {
|
||||
const text = String(value).trim()
|
||||
if (!text) return '--'
|
||||
|
||||
// 发生时刻直接承载事件定位精度:小数秒按接口原始值展示,不补零、不裁剪。
|
||||
// 发生时刻直接承载接口返回精度:有毫秒就展示,没有毫秒不在前端合成。
|
||||
const matched = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/)
|
||||
if (!matched) return text
|
||||
|
||||
@@ -11,6 +11,13 @@ const resolveOptionalText = (value: unknown) => {
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
const resolveEventTimeText = (value: unknown) => {
|
||||
const text = resolveOptionalText(value)
|
||||
if (!text) return undefined
|
||||
|
||||
return text.replace(/\.\d+$/, '')
|
||||
}
|
||||
|
||||
const resolveOptionalNumber = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return undefined
|
||||
const parsed = Number(value)
|
||||
@@ -30,10 +37,16 @@ export const buildEventQueryParams = (params: EventSearchParams = {}) => {
|
||||
return pruneEmptyParams({
|
||||
pageNum: params.pageNum,
|
||||
pageSize: params.pageSize,
|
||||
startTimeStart: resolveOptionalText(timeRange[0]),
|
||||
startTimeEnd: resolveOptionalText(timeRange[1]),
|
||||
startTimeStart: resolveEventTimeText(timeRange[0]),
|
||||
startTimeEnd: resolveEventTimeText(timeRange[1]),
|
||||
eventType: resolveOptionalText(params.eventType),
|
||||
phase: resolveOptionalText(params.phase),
|
||||
durationMin: resolveOptionalNumber(params.durationMin),
|
||||
durationMax: resolveOptionalNumber(params.durationMax),
|
||||
featureAmplitudeMin: resolveOptionalNumber(params.featureAmplitudeMin),
|
||||
featureAmplitudeMax: resolveOptionalNumber(params.featureAmplitudeMax),
|
||||
severityMin: resolveOptionalNumber(params.severityMin),
|
||||
severityMax: resolveOptionalNumber(params.severityMax),
|
||||
fileFlag: resolveOptionalNumber(params.fileFlag),
|
||||
dealFlag: resolveOptionalNumber(params.dealFlag),
|
||||
engineeringName: ledgerKeyword,
|
||||
|
||||
@@ -5,6 +5,12 @@ export const phaseOptions = [
|
||||
{ label: '三相', value: 'ABC' }
|
||||
]
|
||||
|
||||
export function resolvePhaseText(value: unknown) {
|
||||
const text = value === null || value === undefined ? '' : String(value).trim()
|
||||
const option = phaseOptions.find(item => item.value === text)
|
||||
return option?.label.replace(' ', '') || text || '--'
|
||||
}
|
||||
|
||||
export const fileFlagOptions = [
|
||||
{ label: '未招', value: 0 },
|
||||
{ label: '已招', value: 1 }
|
||||
|
||||
221
frontend/src/views/event/eventList/utils/voltageTolerance.ts
Normal file
221
frontend/src/views/event/eventList/utils/voltageTolerance.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
export type VoltageToleranceStandardKey = 'itic' | 'semiF47'
|
||||
|
||||
export interface VoltageTolerancePoint {
|
||||
durationSeconds: number
|
||||
voltagePercent: number
|
||||
}
|
||||
|
||||
export interface VoltageToleranceStandard {
|
||||
key: VoltageToleranceStandardKey
|
||||
label: string
|
||||
boundaryName: string
|
||||
boundaryPoints: VoltageTolerancePoint[]
|
||||
}
|
||||
|
||||
export interface VoltageToleranceEvaluation {
|
||||
standard: VoltageToleranceStandard
|
||||
point: VoltageTolerancePoint
|
||||
thresholdVoltagePercent: number
|
||||
tolerable: boolean
|
||||
marginPercent: number
|
||||
statusText: string
|
||||
}
|
||||
|
||||
type EventVoltageSource = {
|
||||
duration?: unknown
|
||||
featureAmplitude?: unknown
|
||||
}
|
||||
|
||||
const iticBoundaryPoints: VoltageTolerancePoint[] = [
|
||||
{ durationSeconds: 0.001, voltagePercent: 0 },
|
||||
{ durationSeconds: 0.02, voltagePercent: 0 },
|
||||
{ durationSeconds: 0.5, voltagePercent: 70 },
|
||||
{ durationSeconds: 10, voltagePercent: 80 },
|
||||
{ durationSeconds: 100, voltagePercent: 90 }
|
||||
]
|
||||
|
||||
const semiF47BoundaryPoints: VoltageTolerancePoint[] = [
|
||||
{ durationSeconds: 0.001, voltagePercent: 50 },
|
||||
{ durationSeconds: 0.2, voltagePercent: 50 },
|
||||
{ durationSeconds: 0.5, voltagePercent: 70 },
|
||||
{ durationSeconds: 1, voltagePercent: 80 },
|
||||
{ durationSeconds: 100, voltagePercent: 80 }
|
||||
]
|
||||
|
||||
export const VOLTAGE_TOLERANCE_STANDARDS: VoltageToleranceStandard[] = [
|
||||
{
|
||||
key: 'itic',
|
||||
label: 'ITIC',
|
||||
boundaryName: 'ITIC 可容忍边界',
|
||||
boundaryPoints: iticBoundaryPoints
|
||||
},
|
||||
{
|
||||
key: 'semiF47',
|
||||
label: 'SEMI F47',
|
||||
boundaryName: 'SEMI F47 可容忍边界',
|
||||
boundaryPoints: semiF47BoundaryPoints
|
||||
}
|
||||
]
|
||||
|
||||
export const resolveVoltageToleranceStandard = (standardKey: VoltageToleranceStandardKey) => {
|
||||
return VOLTAGE_TOLERANCE_STANDARDS.find(item => item.key === standardKey) || VOLTAGE_TOLERANCE_STANDARDS[0]
|
||||
}
|
||||
|
||||
const resolveFiniteNumber = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return undefined
|
||||
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
export const resolveVoltageTolerancePoint = (event: EventVoltageSource): VoltageTolerancePoint | null => {
|
||||
const durationSeconds = resolveFiniteNumber(event.duration)
|
||||
const voltagePercent = resolveFiniteNumber(event.featureAmplitude)
|
||||
|
||||
if (durationSeconds === undefined || voltagePercent === undefined) return null
|
||||
if (durationSeconds <= 0 || voltagePercent < 0) return null
|
||||
|
||||
return {
|
||||
durationSeconds,
|
||||
voltagePercent
|
||||
}
|
||||
}
|
||||
|
||||
const interpolateVoltageThreshold = (boundaryPoints: VoltageTolerancePoint[], durationSeconds: number) => {
|
||||
const sortedPoints = [...boundaryPoints].sort((a, b) => a.durationSeconds - b.durationSeconds)
|
||||
const firstPoint = sortedPoints[0]
|
||||
const lastPoint = sortedPoints[sortedPoints.length - 1]
|
||||
|
||||
if (durationSeconds <= firstPoint.durationSeconds) return firstPoint.voltagePercent
|
||||
if (durationSeconds >= lastPoint.durationSeconds) return lastPoint.voltagePercent
|
||||
|
||||
const nextPointIndex = sortedPoints.findIndex(point => point.durationSeconds >= durationSeconds)
|
||||
const previousPoint = sortedPoints[nextPointIndex - 1]
|
||||
const nextPoint = sortedPoints[nextPointIndex]
|
||||
|
||||
if (!previousPoint || !nextPoint) return lastPoint.voltagePercent
|
||||
if (previousPoint.durationSeconds === nextPoint.durationSeconds) return nextPoint.voltagePercent
|
||||
|
||||
// ITIC 与 SEMI F47 坐标图通常按时间对数轴展示,边界段按对数时间插值能与图上位置一致。
|
||||
const previousLogDuration = Math.log10(previousPoint.durationSeconds)
|
||||
const nextLogDuration = Math.log10(nextPoint.durationSeconds)
|
||||
const currentLogDuration = Math.log10(durationSeconds)
|
||||
const ratio = (currentLogDuration - previousLogDuration) / (nextLogDuration - previousLogDuration)
|
||||
|
||||
return previousPoint.voltagePercent + (nextPoint.voltagePercent - previousPoint.voltagePercent) * ratio
|
||||
}
|
||||
|
||||
export const evaluateVoltageTolerance = (
|
||||
standardKey: VoltageToleranceStandardKey,
|
||||
event: EventVoltageSource
|
||||
): VoltageToleranceEvaluation | null => {
|
||||
const standard = resolveVoltageToleranceStandard(standardKey)
|
||||
const point = resolveVoltageTolerancePoint(event)
|
||||
|
||||
if (!point) return null
|
||||
|
||||
const thresholdVoltagePercent = interpolateVoltageThreshold(standard.boundaryPoints, point.durationSeconds)
|
||||
const marginPercent = point.voltagePercent - thresholdVoltagePercent
|
||||
const tolerable = marginPercent >= 0
|
||||
|
||||
return {
|
||||
standard,
|
||||
point,
|
||||
thresholdVoltagePercent,
|
||||
tolerable,
|
||||
marginPercent,
|
||||
statusText: tolerable ? '可容忍' : '不可容忍'
|
||||
}
|
||||
}
|
||||
|
||||
const formatChartPercent = (value: number) => {
|
||||
return `${Number(value.toFixed(2))}%`
|
||||
}
|
||||
|
||||
export const buildVoltageToleranceChartOption = (
|
||||
standardKey: VoltageToleranceStandardKey,
|
||||
event: EventVoltageSource
|
||||
) => {
|
||||
const standard = resolveVoltageToleranceStandard(standardKey)
|
||||
const point = resolveVoltageTolerancePoint(event)
|
||||
const evaluation = evaluateVoltageTolerance(standardKey, event)
|
||||
const eventDurationMax = point ? Math.max(100, point.durationSeconds * 2) : 100
|
||||
const eventVoltageMax = point ? Math.max(120, Math.ceil(point.voltagePercent / 10) * 10 + 10) : 120
|
||||
|
||||
return {
|
||||
color: ['#ff7e50', '#003078'],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { seriesName?: string; data?: number[] }) => {
|
||||
const [durationSeconds, voltagePercent] = params.data || []
|
||||
if (durationSeconds === undefined || voltagePercent === undefined) return ''
|
||||
|
||||
return [
|
||||
params.seriesName || '',
|
||||
`持续时间:${Number(durationSeconds.toFixed(4))} s`,
|
||||
`电压百分比:${formatChartPercent(voltagePercent)}`
|
||||
].join('<br/>')
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: 64,
|
||||
right: 28,
|
||||
top: 44,
|
||||
bottom: 58,
|
||||
containLabel: false
|
||||
},
|
||||
xAxis: {
|
||||
type: 'log',
|
||||
name: '持续时间(s)',
|
||||
min: 0.001,
|
||||
max: eventDurationMax,
|
||||
logBase: 10,
|
||||
minorTick: { show: true },
|
||||
minorSplitLine: { show: true },
|
||||
axisLabel: {
|
||||
formatter: (value: number) => Number(value.toPrecision(4)).toString()
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '电压百分比(%)',
|
||||
min: 0,
|
||||
max: eventVoltageMax,
|
||||
axisLabel: {
|
||||
formatter: '{value}%'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: standard.boundaryName,
|
||||
type: 'line',
|
||||
smooth: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#ff7e50'
|
||||
},
|
||||
areaStyle: {
|
||||
color: 'rgba(32, 178, 170, 0.08)',
|
||||
origin: 'end'
|
||||
},
|
||||
data: standard.boundaryPoints.map(item => [item.durationSeconds, item.voltagePercent])
|
||||
},
|
||||
{
|
||||
name: '事件点',
|
||||
type: 'scatter',
|
||||
symbolSize: 14,
|
||||
itemStyle: {
|
||||
color: evaluation?.tolerable ? '#20b2aa' : '#a0522d'
|
||||
},
|
||||
label: {
|
||||
show: Boolean(point),
|
||||
position: 'top',
|
||||
formatter: evaluation?.statusText || ''
|
||||
},
|
||||
data: point ? [[point.durationSeconds, point.voltagePercent]] : []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<section class="card checksquare-detail">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="section-title">连续性详情</div>
|
||||
<div class="section-description">
|
||||
{{ selectedItem ? resolveChecksquareRowName(selectedItem) : '请选择总览表中的指标' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!selectedItem" description="请选择指标查看缺失区间" />
|
||||
|
||||
<template v-else>
|
||||
<div class="stat-grid">
|
||||
<div v-for="statType in CHECKSQUARE_STAT_TYPES" :key="statType" class="stat-card">
|
||||
<span class="stat-name">{{ formatChecksquareStatType(statType) }}</span>
|
||||
<span class="stat-value">{{ formatStatMissingRate(selectedItem, statType) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table class="segment-table" :data="segments" size="small" max-height="220" empty-text="暂无缺失区间">
|
||||
<el-table-column prop="statType" label="统计类型" width="96">
|
||||
<template #default="{ row }">{{ formatChecksquareStatType(row.statType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="开始时间" min-width="160" />
|
||||
<el-table-column prop="endTime" label="结束时间" min-width="160" />
|
||||
<el-table-column prop="missingPointCount" label="缺失点数" width="100" align="right">
|
||||
<template #default="{ row }">{{ row.missingPointCount ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="durationMinutes" label="持续分钟" width="100" align="right">
|
||||
<template #default="{ row }">{{ row.durationMinutes ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import {
|
||||
CHECKSQUARE_STAT_TYPES,
|
||||
collectMissingSegments,
|
||||
formatChecksquareStatType,
|
||||
formatStatMissingRate,
|
||||
resolveChecksquareRowName
|
||||
} from '../utils/checksquareTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'ChecksquareDetailPanel'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
selectedItem: SteadyDataView.SteadyChecksquareItem | null
|
||||
}>()
|
||||
|
||||
const segments = computed(() => collectMissingSegments(props.selectedItem))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.checksquare-detail {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stat-name {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<section class="table-main card checksquare-summary">
|
||||
<div class="table-header">
|
||||
<div class="header-button-lf">
|
||||
<span class="section-title">指标校验结果</span>
|
||||
<span v-if="result" class="summary-meta">
|
||||
<el-tag size="small" effect="plain">{{ result.lineName || result.lineId || '未返回监测点' }}</el-tag>
|
||||
<el-tag v-if="result.intervalMinutes" size="small" effect="plain">
|
||||
{{ result.intervalMinutes }} 分钟间隔
|
||||
</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-button-ri">
|
||||
<el-button type="primary" plain :icon="Refresh" :loading="loading" @click="emit('refresh')">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
class="summary-table"
|
||||
height="100%"
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
row-key="itemKey"
|
||||
:tree-props="{ children: 'children' }"
|
||||
highlight-current-row
|
||||
empty-text="暂无校验结果"
|
||||
>
|
||||
<el-table-column prop="indicatorName" label="指标名称" min-width="208">
|
||||
<template #default="{ row }">
|
||||
<span class="indicator-name" :title="resolveChecksquareRowName(row)">
|
||||
{{ resolveChecksquareRowName(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hasData" label="是否有数据" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.hasData !== undefined" :type="row.hasData ? 'success' : 'danger'" effect="plain">
|
||||
{{ formatBooleanText(row.hasData) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="missingRate" label="总缺失率" min-width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatMissingRate(row.missingRate, row.missingRateText) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平均值缺失率" min-width="130" align="center">
|
||||
<template #default="{ row }">{{ formatStatMissingRate(row, 'AVG') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最大值缺失率" min-width="130" align="center">
|
||||
<template #default="{ row }">{{ formatStatMissingRate(row, 'MAX') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最小值缺失率" min-width="130" align="center">
|
||||
<template #default="{ row }">{{ formatStatMissingRate(row, 'MIN') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="CP95缺失率" min-width="140" align="center">
|
||||
<template #default="{ row }">{{ formatStatMissingRate(row, 'CP95') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="maxContinuousMissingMinutes" label="最大连续缺失" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.maxContinuousMissingMinutes ?? '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="96" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link :disabled="!hasChecksquareDetail(row)" @click="emit('detail', row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import {
|
||||
formatBooleanText,
|
||||
formatMissingRate,
|
||||
formatStatMissingRate,
|
||||
hasChecksquareDetail,
|
||||
resolveChecksquareRowName
|
||||
} from '../utils/checksquareTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'ChecksquareSummaryTable'
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
result: SteadyDataView.SteadyChecksquareQueryResult | null
|
||||
items: SteadyDataView.SteadyChecksquareItem[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
detail: [row: SteadyDataView.SteadyChecksquareItem]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.checksquare-summary {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
display: inline-flex;
|
||||
margin-left: 15px;
|
||||
gap: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.indicator-name {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="checksquare-layout" :class="{ 'is-ledger-collapsed': ledgerPanelCollapsed }">
|
||||
<aside class="selector-column">
|
||||
<div class="ledger-panel-body">
|
||||
<SteadyLedgerTree
|
||||
:key="selectorResetKey"
|
||||
:tree-data="ledgerTree"
|
||||
:loading="loading.ledger"
|
||||
:keyword="ledgerKeyword"
|
||||
:default-checked-keys="defaultLedgerCheckedKeys"
|
||||
:collapsed="ledgerPanelCollapsed"
|
||||
@refresh="emit('refreshLedger')"
|
||||
@search="emit('ledgerSearch', $event)"
|
||||
@change="emit('ledgerChange', $event)"
|
||||
@toggle="emit('update:ledgerPanelCollapsed', !ledgerPanelCollapsed)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="checksquare-main">
|
||||
<section class="card query-card">
|
||||
<div class="toolbar-field toolbar-field--time">
|
||||
<span class="toolbar-field__label">时间:</span>
|
||||
<TimePeriodSearch
|
||||
class="checksquare-time"
|
||||
:unit="form.timeUnit"
|
||||
:model-value="form.timeBaseDate"
|
||||
:range-value="form.timeRange"
|
||||
:visible-units="CHECKSQUARE_TIME_PERIOD_UNITS"
|
||||
@update:unit="handleTimeUnitChange"
|
||||
@update:model-value="handleTimeBaseDateChange"
|
||||
@update:range-value="handleTimeRangeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar-field indicator-form-item">
|
||||
<span class="toolbar-field__label">稳态指标:</span>
|
||||
<div class="indicator-select-row">
|
||||
<el-tree-select
|
||||
v-model="selectedIndicatorKeys"
|
||||
class="indicator-tree-select"
|
||||
:data="indicatorSelectTree"
|
||||
multiple
|
||||
show-checkbox
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
filterable
|
||||
clearable
|
||||
default-expand-all
|
||||
node-key="treeKey"
|
||||
value-key="treeKey"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
placeholder="请选择指标"
|
||||
@change="handleIndicatorSelectChange"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="indicator-select-node">
|
||||
<span class="indicator-select-node__name">{{ data.name }}</span>
|
||||
<el-tag v-if="data.unit" size="small" effect="plain">{{ data.unit }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree-select>
|
||||
<el-button type="primary" plain @click="handleSelectAllIndicators">全选</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="query-actions">
|
||||
<el-button type="primary" :icon="Search" :loading="loading.query" @click="emit('query')">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button type="primary" plain :icon="RefreshLeft" @click="emit('reset')">重置</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="checksquare-content">
|
||||
<ChecksquareSummaryTable
|
||||
class="content-summary"
|
||||
:result="result"
|
||||
:items="result?.items || []"
|
||||
:loading="loading.query"
|
||||
@refresh="emit('query')"
|
||||
@detail="openDetailDialog"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<el-dialog v-model="detailDialogVisible" title="连续性详情" width="760px" append-to-body>
|
||||
<ChecksquareDetailPanel :selected-item="selectedItem" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
||||
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||
import SteadyLedgerTree from '@/views/steady/steadyDataView/components/SteadyLedgerTree.vue'
|
||||
import { collectLeafIndicators } from '@/views/steady/steadyDataView/utils/selectionRules'
|
||||
import type { ChecksquareFormState } from '../utils/checksquarePayload'
|
||||
import ChecksquareDetailPanel from './ChecksquareDetailPanel.vue'
|
||||
import ChecksquareSummaryTable from './ChecksquareSummaryTable.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'ChecksquareWorkbench'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
form: ChecksquareFormState
|
||||
ledgerTree: SteadyDataView.SteadyLedgerNode[]
|
||||
indicatorTree: SteadyDataView.SteadyIndicatorNode[]
|
||||
result: SteadyDataView.SteadyChecksquareQueryResult | null
|
||||
selectedItem: SteadyDataView.SteadyChecksquareItem | null
|
||||
loading: {
|
||||
ledger: boolean
|
||||
indicator: boolean
|
||||
query: boolean
|
||||
}
|
||||
ledgerKeyword: string
|
||||
defaultLedgerCheckedKeys: string[]
|
||||
defaultIndicatorCheckedKeys: string[]
|
||||
ledgerPanelCollapsed: boolean
|
||||
selectorResetKey: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:form': [value: ChecksquareFormState]
|
||||
'update:ledgerPanelCollapsed': [value: boolean]
|
||||
refreshLedger: []
|
||||
ledgerSearch: [value: string]
|
||||
ledgerChange: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
||||
indicatorChange: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||
query: []
|
||||
reset: []
|
||||
selectItem: [item: SteadyDataView.SteadyChecksquareItem]
|
||||
}>()
|
||||
|
||||
const selectedIndicatorKeys = ref<string[]>([])
|
||||
const detailDialogVisible = ref(false)
|
||||
const CHECKSQUARE_TIME_PERIOD_UNITS: TimePeriodUnit[] = ['day', 'week', 'month', 'year', 'custom']
|
||||
|
||||
const normalizeIndicatorSelectTree = (
|
||||
nodes: SteadyDataView.SteadyIndicatorNode[],
|
||||
parentKey = ''
|
||||
): SteadyDataView.SteadyIndicatorNode[] => {
|
||||
return nodes.map((node, index) => {
|
||||
const treeKey = node.id || node.indicatorCode || `${parentKey}${node.groupCode || node.name || 'node'}-${index}`
|
||||
|
||||
return {
|
||||
...node,
|
||||
treeKey,
|
||||
children: node.children?.length ? normalizeIndicatorSelectTree(node.children, `${treeKey}-`) : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const indicatorSelectTree = computed(() => normalizeIndicatorSelectTree(props.indicatorTree))
|
||||
|
||||
const indicatorNodeMap = computed(() => {
|
||||
const nodeMap = new Map<string, SteadyDataView.SteadyIndicatorNode>()
|
||||
|
||||
const collect = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.treeKey) nodeMap.set(node.treeKey, node)
|
||||
if (node.children?.length) collect(node.children)
|
||||
})
|
||||
}
|
||||
|
||||
collect(indicatorSelectTree.value)
|
||||
|
||||
return nodeMap
|
||||
})
|
||||
|
||||
const collectAllIndicatorKeys = () => {
|
||||
return collectLeafIndicators(indicatorSelectTree.value).map(node => node.treeKey).filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
const emitSelectedIndicators = () => {
|
||||
const selectedNodes = selectedIndicatorKeys.value
|
||||
.map(key => indicatorNodeMap.value.get(key))
|
||||
.filter(Boolean) as SteadyDataView.SteadyIndicatorNode[]
|
||||
|
||||
emit('indicatorChange', collectLeafIndicators(selectedNodes))
|
||||
}
|
||||
|
||||
const handleIndicatorSelectChange = () => {
|
||||
emitSelectedIndicators()
|
||||
}
|
||||
|
||||
const handleSelectAllIndicators = () => {
|
||||
selectedIndicatorKeys.value = collectAllIndicatorKeys()
|
||||
emitSelectedIndicators()
|
||||
}
|
||||
|
||||
const openDetailDialog = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||
emit('selectItem', item)
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
|
||||
const timeRange = unit === 'custom' ? props.form.timeRange : buildTimePeriodRange(unit, baseDate)
|
||||
|
||||
emit('update:form', {
|
||||
...props.form,
|
||||
timeUnit: unit,
|
||||
timeBaseDate: baseDate,
|
||||
timeRange
|
||||
})
|
||||
}
|
||||
|
||||
const handleTimeUnitChange = (value: TimePeriodUnit) => {
|
||||
updateTimeRange(value, props.form.timeBaseDate)
|
||||
}
|
||||
|
||||
const handleTimeBaseDateChange = (value: Date) => {
|
||||
updateTimeRange(props.form.timeUnit, value)
|
||||
}
|
||||
|
||||
const handleTimeRangeChange = (value: string[]) => {
|
||||
emit('update:form', {
|
||||
...props.form,
|
||||
timeUnit: 'custom',
|
||||
timeRange: value
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.defaultIndicatorCheckedKeys, indicatorSelectTree.value, props.selectorResetKey],
|
||||
() => {
|
||||
selectedIndicatorKeys.value = props.defaultIndicatorCheckedKeys.filter(key => indicatorNodeMap.value.has(key))
|
||||
emitSelectedIndicators()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.checksquare-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.checksquare-layout.is-ledger-collapsed {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.selector-column {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checksquare-layout.is-ledger-collapsed .selector-column {
|
||||
z-index: 4;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ledger-panel-body {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checksquare-layout.is-ledger-collapsed .ledger-panel-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ledger-panel-body :deep(.steady-tree-card) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.checksquare-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.query-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(430px, 1.35fr) minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.toolbar-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-field--time {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-field__label {
|
||||
flex: 0 0 auto;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checksquare-time {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.checksquare-time :deep(.time-period-search__unit) {
|
||||
width: 88px;
|
||||
flex: 0 0 88px;
|
||||
}
|
||||
|
||||
.checksquare-time :deep(.time-period-search__picker) {
|
||||
width: 136px;
|
||||
flex: 0 0 136px;
|
||||
}
|
||||
|
||||
.indicator-form-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indicator-select-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.indicator-tree-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indicator-select-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indicator-select-node__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.query-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checksquare-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-summary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.checksquare-layout:not(.is-ledger-collapsed) {
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.query-card {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.query-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(currentDir, '../../../..')
|
||||
|
||||
const files = {
|
||||
api: path.resolve(rootDir, 'api/steady/steadyDataView/index.ts'),
|
||||
apiTypes: path.resolve(rootDir, 'api/steady/steadyDataView/interface/index.ts'),
|
||||
page: path.resolve(rootDir, 'views/steady/checksquare/index.vue'),
|
||||
workbench: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareWorkbench.vue'),
|
||||
summaryTable: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareSummaryTable.vue'),
|
||||
detailPanel: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareDetailPanel.vue'),
|
||||
payload: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquarePayload.ts'),
|
||||
table: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquareTable.ts')
|
||||
}
|
||||
|
||||
const read = file => (exists(file) ? fs.readFileSync(file, 'utf8') : '')
|
||||
const exists = file => fs.existsSync(file)
|
||||
|
||||
const checks = [
|
||||
['checksquare query api exists', () => /querySteadyChecksquare/.test(read(files.api))],
|
||||
[
|
||||
'checksquare api posts to expected endpoint',
|
||||
() => /\/steady\/data-view\/checksquare\/query/.test(read(files.api))
|
||||
],
|
||||
[
|
||||
'checksquare request type uses single lineId',
|
||||
() => /interface SteadyChecksquareQueryParams[\s\S]*lineId: string/.test(read(files.apiTypes))
|
||||
],
|
||||
[
|
||||
'checksquare request type supports per-order harmonic query only',
|
||||
() => {
|
||||
const typeBlock =
|
||||
read(files.apiTypes).match(/interface SteadyChecksquareQueryParams\s*\{[\s\S]*?\n {4}\}/)?.[0] || ''
|
||||
return /harmonicOrders\?: number\[\]/.test(typeBlock) && !/qualityFlag|statTypes|phases|lineIds/.test(typeBlock)
|
||||
}
|
||||
],
|
||||
['workbench component exists', () => exists(files.workbench)],
|
||||
['summary table component exists', () => exists(files.summaryTable)],
|
||||
['detail panel component exists', () => exists(files.detailPanel)],
|
||||
['payload utility exists', () => exists(files.payload)],
|
||||
['table utility exists', () => exists(files.table)],
|
||||
['page reuses steady ledger tree', () => /SteadyLedgerTree/.test(read(files.workbench))],
|
||||
['page reuses shared time period search', () => /TimePeriodSearch/.test(read(files.workbench))],
|
||||
['payload keeps shared time period unit state', () => /timeUnit:\s*TimePeriodUnit/.test(read(files.payload))],
|
||||
[
|
||||
'checksquare time search exposes day week month year custom units',
|
||||
() =>
|
||||
/CHECKSQUARE_TIME_PERIOD_UNITS\s*:\s*TimePeriodUnit\[\]\s*=\s*\['day',\s*'week',\s*'month',\s*'year',\s*'custom'\]/.test(
|
||||
read(files.workbench)
|
||||
) && /:visible-units="CHECKSQUARE_TIME_PERIOD_UNITS"/.test(read(files.workbench))
|
||||
],
|
||||
[
|
||||
'checksquare defaults to day range',
|
||||
() =>
|
||||
/timeRange:\s*buildTimePeriodRange\('day',\s*baseDate\)/.test(read(files.payload)) &&
|
||||
/timeUnit:\s*'day'/.test(read(files.payload))
|
||||
],
|
||||
['page no longer tracks floating indicator panel state', () => !/indicatorPanelCollapsed|indicator-panel-collapsed/.test(read(files.page))],
|
||||
[
|
||||
'query form uses tree select for steady indicators',
|
||||
() =>
|
||||
/<el-tree-select/.test(read(files.workbench)) &&
|
||||
/v-model="selectedIndicatorKeys"/.test(read(files.workbench)) &&
|
||||
/multiple/.test(read(files.workbench)) &&
|
||||
/show-checkbox/.test(read(files.workbench))
|
||||
],
|
||||
[
|
||||
'query form keeps steady indicator immediately after time selector',
|
||||
() => /class="toolbar-field toolbar-field--time"[\s\S]*class="toolbar-field indicator-form-item"/.test(read(files.workbench))
|
||||
],
|
||||
[
|
||||
'query form supports selecting all steady indicators',
|
||||
() =>
|
||||
/@click="handleSelectAllIndicators"/.test(read(files.workbench)) &&
|
||||
/collectAllIndicatorKeys/.test(read(files.workbench))
|
||||
],
|
||||
[
|
||||
'checksquare no longer renders floating indicator panel',
|
||||
() => !/SteadyIndicatorFloatingPanel|indicatorPanelCollapsedProxy|is-indicator-expanded/.test(read(files.workbench))
|
||||
],
|
||||
['summary table renders unsupported stats as dash', () => /formatStatMissingRate[\s\S]*'-'/.test(read(files.table))],
|
||||
[
|
||||
'summary table has localized AVG MAX MIN CP95 columns',
|
||||
() => /平均值缺失率[\s\S]*最大值缺失率[\s\S]*最小值缺失率[\s\S]*CP95缺失率/.test(read(files.summaryTable))
|
||||
],
|
||||
[
|
||||
'table utility localizes checksquare stat type names',
|
||||
() => /AVG:\s*'平均值'[\s\S]*MAX:\s*'最大值'[\s\S]*MIN:\s*'最小值'/.test(read(files.table))
|
||||
],
|
||||
['detail panel renders missing segments', () => /segments/.test(read(files.detailPanel))]
|
||||
,
|
||||
[
|
||||
'summary table title changed to check result',
|
||||
() => /指标校验结果/.test(read(files.summaryTable)) && !/指标校验总览/.test(read(files.summaryTable))
|
||||
],
|
||||
[
|
||||
'summary table shows monitor fallback and keeps meta 15px from title',
|
||||
() => {
|
||||
const summaryTable = read(files.summaryTable)
|
||||
return (
|
||||
/class="summary-meta"/.test(summaryTable) &&
|
||||
/result\.lineName\s*\|\|\s*result\.lineId\s*\|\|\s*'未返回监测点'/.test(summaryTable) &&
|
||||
/\.summary-meta\s*\{[\s\S]*margin-left:\s*15px/.test(summaryTable)
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
'summary table uses tree rows for harmonic results',
|
||||
() =>
|
||||
/row-key="itemKey"/.test(read(files.summaryTable)) &&
|
||||
/tree-props/.test(read(files.summaryTable)) &&
|
||||
/children/.test(read(files.summaryTable))
|
||||
],
|
||||
[
|
||||
'summary table keeps harmonic tree rows collapsed by default',
|
||||
() => !/default-expand-all/.test(read(files.summaryTable))
|
||||
],
|
||||
[
|
||||
'summary table removes harmonic order column',
|
||||
() => !/<el-table-column[^>]*prop="harmonicOrder"/.test(read(files.summaryTable))
|
||||
],
|
||||
[
|
||||
'summary table uses balanced column widths for check result',
|
||||
() => {
|
||||
const summaryTable = read(files.summaryTable)
|
||||
const indicatorColumn = summaryTable.match(/<el-table-column[^>]*prop="indicatorName"[^>]*>/)?.[0] || ''
|
||||
const hasDataColumn = summaryTable.match(/<el-table-column[^>]*prop="hasData"[^>]*>/)?.[0] || ''
|
||||
const missingRateColumn = summaryTable.match(/<el-table-column[^>]*prop="missingRate"[^>]*>/)?.[0] || ''
|
||||
const avgColumn = summaryTable.match(/<el-table-column[^>]*label="平均值缺失率"[^>]*>/)?.[0] || ''
|
||||
const maxColumn = summaryTable.match(/<el-table-column[^>]*label="最大值缺失率"[^>]*>/)?.[0] || ''
|
||||
const minColumn = summaryTable.match(/<el-table-column[^>]*label="最小值缺失率"[^>]*>/)?.[0] || ''
|
||||
const cp95Column = summaryTable.match(/<el-table-column[^>]*label="CP95缺失率"[^>]*>/)?.[0] || ''
|
||||
const maxMissingColumn =
|
||||
summaryTable.match(/<el-table-column[^>]*prop="maxContinuousMissingMinutes"[^>]*>/)?.[0] || ''
|
||||
const operationColumn = summaryTable.match(/<el-table-column[^>]*label="操作"[^>]*>/)?.[0] || ''
|
||||
const stretchColumns = [
|
||||
hasDataColumn,
|
||||
missingRateColumn,
|
||||
avgColumn,
|
||||
maxColumn,
|
||||
minColumn,
|
||||
cp95Column,
|
||||
maxMissingColumn
|
||||
]
|
||||
|
||||
return (
|
||||
/min-width="208"/.test(indicatorColumn) &&
|
||||
/min-width="120"/.test(hasDataColumn) &&
|
||||
/min-width="130"/.test(missingRateColumn) &&
|
||||
/min-width="130"/.test(avgColumn) &&
|
||||
/min-width="130"/.test(maxColumn) &&
|
||||
/min-width="130"/.test(minColumn) &&
|
||||
/min-width="140"/.test(cp95Column) &&
|
||||
/min-width="150"/.test(maxMissingColumn) &&
|
||||
/width="96"/.test(operationColumn) &&
|
||||
stretchColumns.every(column => /min-width=/.test(column) && !/\swidth=/.test(column)) &&
|
||||
stretchColumns.every(column => /align="center"/.test(column)) &&
|
||||
/align="center"/.test(operationColumn) &&
|
||||
!/align=/.test(indicatorColumn)
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
'workbench query card follows steady data view toolbar sizing',
|
||||
() => {
|
||||
const workbench = read(files.workbench)
|
||||
return (
|
||||
/\.query-card\s*\{[\s\S]*display:\s*grid[\s\S]*grid-template-columns:\s*minmax\(430px,\s*1\.35fr\)\s+minmax\(0,\s*1fr\)\s+auto[\s\S]*gap:\s*10px[\s\S]*align-items:\s*center[\s\S]*padding:\s*12px/.test(
|
||||
workbench
|
||||
) &&
|
||||
/\.checksquare-time\s*\{[\s\S]*flex:\s*1\s+1\s+0[\s\S]*min-width:\s*0/.test(workbench) &&
|
||||
/\.checksquare-time\s*:deep\(\.time-period-search__unit\)\s*\{[\s\S]*width:\s*88px[\s\S]*flex:\s*0\s+0\s+88px/.test(
|
||||
workbench
|
||||
) &&
|
||||
/\.checksquare-time\s*:deep\(\.time-period-search__picker\)\s*\{[\s\S]*width:\s*136px[\s\S]*flex:\s*0\s+0\s+136px/.test(
|
||||
workbench
|
||||
) &&
|
||||
/\.query-actions\s*\{[\s\S]*display:\s*flex[\s\S]*justify-content:\s*flex-end[\s\S]*gap:\s*8px/.test(workbench)
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
'summary table exposes detail action',
|
||||
() => /详情/.test(read(files.summaryTable)) && /emit\('detail'/.test(read(files.summaryTable))
|
||||
],
|
||||
[
|
||||
'workbench shows detail in dialog instead of inline panel',
|
||||
() =>
|
||||
/<el-dialog/.test(read(files.workbench)) &&
|
||||
/ChecksquareDetailPanel/.test(read(files.workbench)) &&
|
||||
!/class="content-detail"/.test(read(files.workbench))
|
||||
],
|
||||
[
|
||||
'page builds pending rows from selected indicators',
|
||||
() => /buildPendingChecksquareResult/.test(read(files.page)) && /refreshPendingResult/.test(read(files.page))
|
||||
],
|
||||
[
|
||||
'page queries indicators sequentially',
|
||||
() => /for \(const indicator of queryIndicators\)/.test(read(files.page)) && /mergeChecksquareIndicatorResult/.test(read(files.page))
|
||||
],
|
||||
[
|
||||
'page queries harmonic orders with controlled concurrency',
|
||||
() =>
|
||||
/CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY\s*=\s*6/.test(read(files.page)) &&
|
||||
/runChecksquareHarmonicQuery/.test(read(files.page)) &&
|
||||
/workers = Array\.from\(\{[\s\S]*length: Math\.min\(CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY/.test(read(files.page)) &&
|
||||
/await Promise\.all\(workers\)/.test(read(files.page)) &&
|
||||
/const harmonicOrders = \[\.\.\.CHECKSQUARE_HARMONIC_ORDERS\]/.test(read(files.page)) &&
|
||||
/if \(orderIndex >= harmonicOrders\.length\) return/.test(read(files.page))
|
||||
],
|
||||
[
|
||||
'table pre-creates harmonic rows from second to fiftieth order',
|
||||
() =>
|
||||
/CHECKSQUARE_HARMONIC_ORDER_MIN\s*=\s*2/.test(read(files.table)) &&
|
||||
/CHECKSQUARE_HARMONIC_ORDER_MAX\s*=\s*50/.test(read(files.table)) &&
|
||||
/CHECKSQUARE_HARMONIC_ORDER_MAX - CHECKSQUARE_HARMONIC_ORDER_MIN \+ 1/.test(read(files.table)) &&
|
||||
/children: isChecksquareHarmonicIndicator\(indicator\)\s*\?\s*buildPendingChecksquareHarmonicItems/.test(
|
||||
read(files.table)
|
||||
)
|
||||
],
|
||||
[
|
||||
'table only merges indicators whose harmonic order range intersects second to fiftieth order',
|
||||
() => {
|
||||
const table = read(files.table)
|
||||
return (
|
||||
/CHECKSQUARE_HARMONIC_ORDER_MIN/.test(table) &&
|
||||
/CHECKSQUARE_HARMONIC_ORDER_MAX/.test(table) &&
|
||||
/hasChecksquareHarmonicOrderRange/.test(table) &&
|
||||
/isChecksquareHarmonicIndicator[\s\S]*hasChecksquareHarmonicOrderRange\(indicator\)/.test(table) &&
|
||||
/const shouldMergeHarmonicItems\s*=\s*isChecksquareHarmonicIndicator\(indicator\)/.test(table) &&
|
||||
/const normalItems\s*=\s*shouldMergeHarmonicItems[\s\S]*resultItems/.test(table) &&
|
||||
/const harmonicItems\s*=\s*shouldMergeHarmonicItems[\s\S]*\[\]/.test(table)
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
'table summarizes harmonic parent after all orders finish',
|
||||
() =>
|
||||
/buildHarmonicParentSummary/.test(read(files.table)) &&
|
||||
/every\(item => isResolvedChecksquareItem\(item\)\)/.test(read(files.table)) &&
|
||||
/missingPointCount \/ expectedPointCount/.test(read(files.table))
|
||||
],
|
||||
[
|
||||
'table marks harmonic parent valid when every order child has data',
|
||||
() =>
|
||||
/hasData:\s*children\.every\(item => item\.hasData === true\),/.test(read(files.table)) &&
|
||||
!/children\.every\(item => \(item\.missingPointCount \|\| 0\) === 0\)/.test(read(files.table))
|
||||
],
|
||||
[
|
||||
'table keeps harmonic row keys stable while merging returned order results',
|
||||
() =>
|
||||
/normalizeChecksquareResultItemKey/.test(read(files.table)) &&
|
||||
/normalizeChecksquareResultItemKey\([\s\S]*child\.itemKey/.test(read(files.table)) &&
|
||||
/resolveChecksquareHarmonicOrder/.test(read(files.table)) &&
|
||||
/resolveChecksquareHarmonicOrder\(item\) === child\.harmonicOrder/.test(read(files.table))
|
||||
],
|
||||
[
|
||||
'table formats harmonic parent progress before final summary is ready',
|
||||
() =>
|
||||
/resolveChecksquareRowName[\s\S]*getHarmonicProgressText/.test(read(files.table)) &&
|
||||
/已完成 \$\{resolvedCount\}\/\$\{totalCount\}/.test(read(files.table))
|
||||
],
|
||||
[
|
||||
'page keeps selected checksquare detail synced after async row replacement',
|
||||
() =>
|
||||
/syncSelectedItemWithLatestResult/.test(read(files.page)) &&
|
||||
/selectedItem\.value\.itemKey/.test(read(files.page)) &&
|
||||
/mergeChecksquareIndicatorResult\(queryResult\.value/.test(read(files.page))
|
||||
]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('checksquare feature contract failed:')
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('checksquare feature contract passed')
|
||||
@@ -0,0 +1,63 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(currentDir, '../../../..')
|
||||
|
||||
const files = {
|
||||
page: path.resolve(rootDir, 'views/steady/checksquare/index.vue'),
|
||||
staticRouter: path.resolve(rootDir, 'routers/modules/staticRouter.ts'),
|
||||
dynamicRouter: path.resolve(rootDir, 'routers/modules/dynamicRouter.ts'),
|
||||
authStore: path.resolve(rootDir, 'stores/modules/auth.ts')
|
||||
}
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8')
|
||||
const exists = file => fs.existsSync(file)
|
||||
|
||||
const checks = [
|
||||
['checksquare page exists', () => exists(files.page)],
|
||||
['static router registers /checksquare/index', () => /path:\s*'\/checksquare\/index'/.test(read(files.staticRouter))],
|
||||
['static route name is checksquare', () => /name:\s*'checksquare'/.test(read(files.staticRouter))],
|
||||
[
|
||||
'static router imports checksquare page',
|
||||
() => /@\/views\/steady\/checksquare\/index\.vue/.test(read(files.staticRouter))
|
||||
],
|
||||
[
|
||||
'static router aliases steady check-square to checksquare',
|
||||
() => /\/steady\/check-square[\s\S]*\/steady\/checksquare/.test(read(files.staticRouter))
|
||||
],
|
||||
[
|
||||
'dynamic router aliases check-square to checksquare',
|
||||
() => /\/steady\/check-square[\s\S]*\/steady\/checksquare/.test(read(files.dynamicRouter))
|
||||
],
|
||||
[
|
||||
'dynamic router keeps checksquare static route from being overwritten',
|
||||
() => /STATIC_ROUTE_NAMES[\s\S]*'checksquare'/.test(read(files.dynamicRouter))
|
||||
],
|
||||
[
|
||||
'auth normalizes backend checksquare menu to static entry',
|
||||
() => /isChecksquareMenu[\s\S]*menu\.path\s*=\s*'\/checksquare\/index'/.test(read(files.authStore))
|
||||
],
|
||||
[
|
||||
'auth treats 数据验证 menu title as checksquare',
|
||||
() => /isChecksquareMenu[\s\S]*title\.includes\('数据验证'\)/.test(read(files.authStore))
|
||||
],
|
||||
[
|
||||
'business menu path resolver handles checksquare',
|
||||
() => /isChecksquareMenu\(menu\)[\s\S]*return\s+'\/checksquare\/index'/.test(read(files.authStore))
|
||||
]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('checksquare route contract failed:')
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('checksquare route contract passed')
|
||||
263
frontend/src/views/steady/checksquare/index.vue
Normal file
263
frontend/src/views/steady/checksquare/index.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="table-box checksquare-page">
|
||||
<ChecksquareWorkbench
|
||||
v-model:form="formState"
|
||||
v-model:ledger-panel-collapsed="ledgerPanelCollapsed"
|
||||
:ledger-tree="ledgerTree"
|
||||
:indicator-tree="indicatorTree"
|
||||
:result="queryResult"
|
||||
:selected-item="selectedItem"
|
||||
:loading="loading"
|
||||
:ledger-keyword="ledgerKeyword"
|
||||
:default-ledger-checked-keys="defaultLedgerCheckedKeys"
|
||||
:default-indicator-checked-keys="defaultIndicatorCheckedKeys"
|
||||
:selector-reset-key="selectorResetKey"
|
||||
@refresh-ledger="loadLedgerTree"
|
||||
@ledger-search="handleLedgerSearch"
|
||||
@ledger-change="handleLedgerChange"
|
||||
@indicator-change="handleIndicatorChange"
|
||||
@query="handleQuery"
|
||||
@reset="handleReset"
|
||||
@select-item="handleSelectItem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getSteadyTrendIndicatorTree,
|
||||
getSteadyTrendLedgerTree,
|
||||
querySteadyChecksquare
|
||||
} from '@/api/steady/steadyDataView'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import {
|
||||
collectLeafIndicators,
|
||||
collectSelectedLineIds,
|
||||
findFirstLeafIndicator,
|
||||
findFirstSelectableLedgerNode,
|
||||
sortSteadyIndicatorTree
|
||||
} from '@/views/steady/steadyDataView/utils/selectionRules'
|
||||
import { normalizeSteadyLedgerTree } from '@/views/steady/steadyDataView/utils/ledgerTree'
|
||||
import ChecksquareWorkbench from './components/ChecksquareWorkbench.vue'
|
||||
import {
|
||||
buildSteadyChecksquarePayload,
|
||||
defaultChecksquareFormState,
|
||||
validateChecksquareSelection
|
||||
} from './utils/checksquarePayload'
|
||||
import {
|
||||
CHECKSQUARE_HARMONIC_ORDERS,
|
||||
buildPendingChecksquareResult,
|
||||
isChecksquareHarmonicIndicator,
|
||||
mergeChecksquareIndicatorResult
|
||||
} from './utils/checksquareTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'ChecksquareView'
|
||||
})
|
||||
|
||||
const ledgerTree = ref<SteadyDataView.SteadyLedgerNode[]>([])
|
||||
const indicatorTree = ref<SteadyDataView.SteadyIndicatorNode[]>([])
|
||||
const selectedLedgerNodes = ref<SteadyDataView.SteadyLedgerNode[]>([])
|
||||
const selectedIndicators = ref<SteadyDataView.SteadyIndicatorNode[]>([])
|
||||
const queryResult = ref<SteadyDataView.SteadyChecksquareQueryResult | null>(null)
|
||||
const selectedItem = ref<SteadyDataView.SteadyChecksquareItem | null>(null)
|
||||
const formState = ref(defaultChecksquareFormState())
|
||||
const ledgerKeyword = ref('')
|
||||
const ledgerPanelCollapsed = ref(false)
|
||||
const selectorResetKey = ref(0)
|
||||
const defaultLedgerCheckedKeys = ref<string[]>([])
|
||||
const defaultIndicatorCheckedKeys = ref<string[]>([])
|
||||
const loading = reactive({
|
||||
ledger: false,
|
||||
indicator: false,
|
||||
query: false
|
||||
})
|
||||
let querySerial = 0
|
||||
let ledgerSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY = 6
|
||||
|
||||
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
|
||||
|
||||
const refreshPendingResult = () => {
|
||||
queryResult.value = buildPendingChecksquareResult(selectedIndicators.value, formState.value)
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
const unwrapData = <T,>(response: { data: T } | T): T => {
|
||||
if (response && typeof response === 'object' && 'data' in response) {
|
||||
return (response as { data: T }).data
|
||||
}
|
||||
|
||||
return response as T
|
||||
}
|
||||
|
||||
const cancelCurrentQuery = () => {
|
||||
querySerial += 1
|
||||
loading.query = false
|
||||
}
|
||||
|
||||
const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
|
||||
loading.ledger = true
|
||||
try {
|
||||
const response = await getSteadyTrendLedgerTree(keyword ? { keyword } : undefined)
|
||||
// 数据校验沿用稳态趋势台账树,搜索返回扁平节点时仍需恢复固定层级。
|
||||
ledgerTree.value = normalizeSteadyLedgerTree(unwrapData(response) || [])
|
||||
const firstLedgerNode = findFirstSelectableLedgerNode(ledgerTree.value)
|
||||
selectedLedgerNodes.value = firstLedgerNode ? [firstLedgerNode] : []
|
||||
defaultLedgerCheckedKeys.value = firstLedgerNode ? [firstLedgerNode.id] : []
|
||||
} finally {
|
||||
loading.ledger = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadIndicatorTree = async () => {
|
||||
loading.indicator = true
|
||||
try {
|
||||
const response = await getSteadyTrendIndicatorTree()
|
||||
indicatorTree.value = sortSteadyIndicatorTree(unwrapData(response) || [])
|
||||
const firstIndicator = findFirstLeafIndicator(indicatorTree.value)
|
||||
const firstIndicatorKey = firstIndicator?.id || firstIndicator?.indicatorCode
|
||||
selectedIndicators.value = firstIndicator ? [firstIndicator] : []
|
||||
defaultIndicatorCheckedKeys.value = firstIndicatorKey ? [firstIndicatorKey] : []
|
||||
} finally {
|
||||
loading.indicator = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLedgerSearch = (value: string) => {
|
||||
ledgerKeyword.value = value
|
||||
if (ledgerSearchTimer) clearTimeout(ledgerSearchTimer)
|
||||
ledgerSearchTimer = setTimeout(() => loadLedgerTree(value), 300)
|
||||
}
|
||||
|
||||
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
|
||||
cancelCurrentQuery()
|
||||
selectedLedgerNodes.value = nodes
|
||||
}
|
||||
|
||||
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
cancelCurrentQuery()
|
||||
selectedIndicators.value = collectLeafIndicators(nodes)
|
||||
refreshPendingResult()
|
||||
}
|
||||
|
||||
const handleSelectItem = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||
selectedItem.value = item
|
||||
}
|
||||
|
||||
const findChecksquareItemByKey = (
|
||||
items: SteadyDataView.SteadyChecksquareItem[],
|
||||
itemKey: string
|
||||
): SteadyDataView.SteadyChecksquareItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.itemKey === itemKey) return item
|
||||
const childItem = findChecksquareItemByKey(item.children || [], itemKey)
|
||||
if (childItem) return childItem
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const syncSelectedItemWithLatestResult = () => {
|
||||
if (!selectedItem.value?.itemKey || !queryResult.value) return
|
||||
|
||||
selectedItem.value = findChecksquareItemByKey(queryResult.value.items || [], selectedItem.value.itemKey)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
cancelCurrentQuery()
|
||||
formState.value = defaultChecksquareFormState()
|
||||
selectedLedgerNodes.value = []
|
||||
selectedIndicators.value = []
|
||||
defaultLedgerCheckedKeys.value = []
|
||||
defaultIndicatorCheckedKeys.value = []
|
||||
queryResult.value = null
|
||||
selectedItem.value = null
|
||||
selectorResetKey.value += 1
|
||||
}
|
||||
|
||||
const runChecksquareHarmonicQuery = async (
|
||||
indicator: SteadyDataView.SteadyIndicatorNode,
|
||||
currentQuerySerial: number
|
||||
) => {
|
||||
const harmonicOrders = [...CHECKSQUARE_HARMONIC_ORDERS]
|
||||
let nextOrderIndex = 0
|
||||
const workers = Array.from({
|
||||
length: Math.min(CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY, harmonicOrders.length)
|
||||
}).map(async () => {
|
||||
while (currentQuerySerial === querySerial) {
|
||||
const orderIndex = nextOrderIndex
|
||||
nextOrderIndex += 1
|
||||
if (orderIndex >= harmonicOrders.length) return
|
||||
|
||||
const harmonicOrder = harmonicOrders[orderIndex]
|
||||
|
||||
const payload = buildSteadyChecksquarePayload(lineIds.value[0], [indicator], formState.value, harmonicOrder)
|
||||
const response = await querySteadyChecksquare(payload)
|
||||
if (currentQuerySerial !== querySerial) return
|
||||
|
||||
// 谐波 2-50 次请求耗时差异较大,单次返回后立即合并,避免等待全部次数完成才刷新表格。
|
||||
queryResult.value = mergeChecksquareIndicatorResult(queryResult.value, indicator, unwrapData(response))
|
||||
syncSelectedItemWithLatestResult()
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
const handleQuery = async () => {
|
||||
const selectionError = validateChecksquareSelection({
|
||||
lineIds: lineIds.value,
|
||||
indicators: selectedIndicators.value,
|
||||
timeRange: formState.value.timeRange
|
||||
})
|
||||
if (selectionError) {
|
||||
ElMessage.warning(selectionError)
|
||||
return
|
||||
}
|
||||
|
||||
const currentQuerySerial = ++querySerial
|
||||
const queryIndicators = [...selectedIndicators.value]
|
||||
loading.query = true
|
||||
refreshPendingResult()
|
||||
selectedItem.value = null
|
||||
|
||||
try {
|
||||
// 按指标串行校验,保证结果列表能随单个指标完成逐步回填。
|
||||
for (const indicator of queryIndicators) {
|
||||
if (currentQuerySerial !== querySerial) return
|
||||
|
||||
if (isChecksquareHarmonicIndicator(indicator)) {
|
||||
await runChecksquareHarmonicQuery(indicator, currentQuerySerial)
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = buildSteadyChecksquarePayload(lineIds.value[0], [indicator], formState.value)
|
||||
const response = await querySteadyChecksquare(payload)
|
||||
if (currentQuerySerial !== querySerial) return
|
||||
|
||||
queryResult.value = mergeChecksquareIndicatorResult(queryResult.value, indicator, unwrapData(response))
|
||||
syncSelectedItemWithLatestResult()
|
||||
}
|
||||
} finally {
|
||||
if (currentQuerySerial === querySerial) {
|
||||
loading.query = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLedgerTree()
|
||||
loadIndicatorTree()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.checksquare-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||
|
||||
export interface ChecksquareFormState {
|
||||
timeRange: string[]
|
||||
timeUnit: TimePeriodUnit
|
||||
timeBaseDate: Date
|
||||
}
|
||||
|
||||
export const defaultChecksquareFormState = (): ChecksquareFormState => {
|
||||
const baseDate = new Date()
|
||||
|
||||
return {
|
||||
timeRange: buildTimePeriodRange('day', baseDate),
|
||||
timeUnit: 'day',
|
||||
timeBaseDate: baseDate
|
||||
}
|
||||
}
|
||||
|
||||
const padTimeValue = (value: number) => `${value}`.padStart(2, '0')
|
||||
|
||||
export const formatChecksquareTime = (date: Date) => {
|
||||
return `${date.getFullYear()}-${padTimeValue(date.getMonth() + 1)}-${padTimeValue(date.getDate())} ${padTimeValue(
|
||||
date.getHours()
|
||||
)}:${padTimeValue(date.getMinutes())}:${padTimeValue(date.getSeconds())}`
|
||||
}
|
||||
|
||||
export const collectChecksquareIndicatorCodes = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
return Array.from(new Set(indicators.map(item => item.indicatorCode).filter(Boolean))) as string[]
|
||||
}
|
||||
|
||||
export const buildSteadyChecksquarePayload = (
|
||||
lineId: string,
|
||||
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||
formState: ChecksquareFormState,
|
||||
harmonicOrder?: number
|
||||
): SteadyDataView.SteadyChecksquareQueryParams => {
|
||||
const payload: SteadyDataView.SteadyChecksquareQueryParams = {
|
||||
lineId,
|
||||
indicatorCodes: collectChecksquareIndicatorCodes(indicators),
|
||||
timeStart: (formState.timeRange[0] || '').replace(/\.[^.]+$/, ''),
|
||||
timeEnd: (formState.timeRange[1] || '').replace(/\.[^.]+$/, '')
|
||||
}
|
||||
|
||||
if (harmonicOrder) {
|
||||
payload.harmonicOrders = [harmonicOrder]
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export const validateChecksquareSelection = (params: {
|
||||
lineIds: string[]
|
||||
indicators: SteadyDataView.SteadyIndicatorNode[]
|
||||
timeRange: string[]
|
||||
}) => {
|
||||
const { lineIds, indicators, timeRange } = params
|
||||
|
||||
if (!lineIds.length) return '请选择监测点'
|
||||
if (lineIds.length > 1) return '数据校验一次只能选择一个监测点'
|
||||
if (!indicators.length) return '请选择指标'
|
||||
if (!timeRange[0]) return '请选择开始时间'
|
||||
if (!timeRange[1]) return '请选择结束时间'
|
||||
if (Date.parse(timeRange[0].replace(' ', 'T')) > Date.parse(timeRange[1].replace(' ', 'T'))) {
|
||||
return '开始时间不能大于结束时间'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
316
frontend/src/views/steady/checksquare/utils/checksquareTable.ts
Normal file
316
frontend/src/views/steady/checksquare/utils/checksquareTable.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
|
||||
export const CHECKSQUARE_STAT_TYPES: SteadyDataView.SteadyTrendStatType[] = ['AVG', 'MAX', 'MIN', 'CP95']
|
||||
export const CHECKSQUARE_HARMONIC_ORDER_MIN = 2
|
||||
export const CHECKSQUARE_HARMONIC_ORDER_MAX = 50
|
||||
export const CHECKSQUARE_HARMONIC_ORDERS = Array.from(
|
||||
{ length: CHECKSQUARE_HARMONIC_ORDER_MAX - CHECKSQUARE_HARMONIC_ORDER_MIN + 1 },
|
||||
(_item, index) => index + CHECKSQUARE_HARMONIC_ORDER_MIN
|
||||
)
|
||||
const CHECKSQUARE_STAT_LABEL_MAP: Record<SteadyDataView.SteadyTrendStatType, string> = {
|
||||
AVG: '平均值',
|
||||
MAX: '最大值',
|
||||
MIN: '最小值',
|
||||
CP95: 'CP95'
|
||||
}
|
||||
|
||||
export const formatChecksquareStatType = (statType: SteadyDataView.SteadyTrendStatType | string) => {
|
||||
return CHECKSQUARE_STAT_LABEL_MAP[statType as SteadyDataView.SteadyTrendStatType] || statType
|
||||
}
|
||||
|
||||
export const formatBooleanText = (value?: boolean | null) => {
|
||||
if (value === null || value === undefined) return '-'
|
||||
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
export const formatMissingRate = (value?: number | null, text?: string | null) => {
|
||||
if (text) return text
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '-'
|
||||
|
||||
return `${(Number(value) * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
export const findStatSummary = (
|
||||
item: SteadyDataView.SteadyChecksquareItem,
|
||||
statType: SteadyDataView.SteadyTrendStatType
|
||||
) => {
|
||||
return item.statSummaries?.find(summary => summary.statType === statType)
|
||||
}
|
||||
|
||||
export const formatStatMissingRate = (
|
||||
item: SteadyDataView.SteadyChecksquareItem,
|
||||
statType: SteadyDataView.SteadyTrendStatType
|
||||
) => {
|
||||
const summary = findStatSummary(item, statType)
|
||||
if (!summary || summary.supported === false) return '-'
|
||||
|
||||
return formatMissingRate(summary.missingRate, summary.missingRateText)
|
||||
}
|
||||
|
||||
export const resolveChecksquareRowName = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||
const progressText = getHarmonicProgressText(item)
|
||||
if (progressText) return `${item.indicatorName || item.indicatorCode}(${progressText})`
|
||||
if (!item.harmonicOrder) return item.indicatorName || item.indicatorCode
|
||||
|
||||
return `${item.harmonicOrder}次`
|
||||
}
|
||||
|
||||
export const getHarmonicProgressText = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||
const children = item.children || []
|
||||
if (!children.length || !children.some(child => child.harmonicOrder)) return ''
|
||||
|
||||
const totalCount = children.length
|
||||
const resolvedCount = children.filter(child => isResolvedChecksquareItem(child)).length
|
||||
if (resolvedCount >= totalCount) return ''
|
||||
|
||||
return `已完成 ${resolvedCount}/${totalCount}`
|
||||
}
|
||||
|
||||
export const collectMissingSegments = (item: SteadyDataView.SteadyChecksquareItem | null) => {
|
||||
if (!item) return []
|
||||
|
||||
return (item.statDetails || []).flatMap(detail =>
|
||||
(detail.segments || [])
|
||||
.filter(segment => segment.status === 'MISSING')
|
||||
.map(segment => ({
|
||||
...segment,
|
||||
statType: detail.statType
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export const hasChecksquareDetail = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||
return (item.statDetails || []).some(detail => (detail.segments || []).length)
|
||||
}
|
||||
|
||||
const hasChecksquareHarmonicOrderRange = (indicator: SteadyDataView.SteadyIndicatorNode) => {
|
||||
const start = Number(indicator.harmonicOrderStart)
|
||||
const end = Number(indicator.harmonicOrderEnd)
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return false
|
||||
|
||||
const rangeStart = Math.min(start, end)
|
||||
const rangeEnd = Math.max(start, end)
|
||||
|
||||
return rangeStart <= CHECKSQUARE_HARMONIC_ORDER_MAX && rangeEnd >= CHECKSQUARE_HARMONIC_ORDER_MIN
|
||||
}
|
||||
|
||||
export const isChecksquareHarmonicIndicator = (indicator: SteadyDataView.SteadyIndicatorNode) => {
|
||||
// 只有指标目录明确包含 2-50 次谐波范围时,才预建谐波子行并执行逐次合并。
|
||||
return hasChecksquareHarmonicOrderRange(indicator)
|
||||
}
|
||||
|
||||
export const buildPendingChecksquareItem = (
|
||||
indicator: SteadyDataView.SteadyIndicatorNode
|
||||
): SteadyDataView.SteadyChecksquareItem => {
|
||||
const indicatorCode = indicator.indicatorCode || indicator.id || indicator.treeKey || indicator.name
|
||||
|
||||
return {
|
||||
itemKey: `pending|${indicatorCode}`,
|
||||
indicatorCode,
|
||||
indicatorName: indicator.name || indicatorCode,
|
||||
children: isChecksquareHarmonicIndicator(indicator) ? buildPendingChecksquareHarmonicItems(indicator) : undefined,
|
||||
statSummaries: [],
|
||||
statDetails: []
|
||||
}
|
||||
}
|
||||
|
||||
export const buildPendingChecksquareHarmonicItems = (
|
||||
indicator: SteadyDataView.SteadyIndicatorNode
|
||||
): SteadyDataView.SteadyChecksquareItem[] => {
|
||||
const indicatorCode = indicator.indicatorCode || indicator.id || indicator.treeKey || indicator.name
|
||||
|
||||
return CHECKSQUARE_HARMONIC_ORDERS.map(harmonicOrder => ({
|
||||
itemKey: `pending|${indicatorCode}|${harmonicOrder}`,
|
||||
indicatorCode,
|
||||
indicatorName: indicator.name || indicatorCode,
|
||||
harmonicOrder,
|
||||
statSummaries: [],
|
||||
statDetails: []
|
||||
}))
|
||||
}
|
||||
|
||||
export const buildPendingChecksquareResult = (
|
||||
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||
formState: { timeRange: string[] }
|
||||
): SteadyDataView.SteadyChecksquareQueryResult => {
|
||||
return {
|
||||
lineId: '',
|
||||
timeStart: formState.timeRange[0] || '',
|
||||
timeEnd: formState.timeRange[1] || '',
|
||||
items: indicators.map(buildPendingChecksquareItem)
|
||||
}
|
||||
}
|
||||
|
||||
const isResolvedChecksquareItem = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||
return item.hasData !== undefined || item.expectedPointCount !== undefined || item.actualPointCount !== undefined
|
||||
}
|
||||
|
||||
const normalizeChecksquareResultItemKey = (
|
||||
item: SteadyDataView.SteadyChecksquareItem,
|
||||
itemKey: string
|
||||
): SteadyDataView.SteadyChecksquareItem => ({
|
||||
...item,
|
||||
itemKey
|
||||
})
|
||||
|
||||
const isValidChecksquareHarmonicOrder = (value: number) => {
|
||||
return Number.isInteger(value) && CHECKSQUARE_HARMONIC_ORDERS.includes(value)
|
||||
}
|
||||
|
||||
const parseChecksquareHarmonicOrder = (value?: string | number | null) => {
|
||||
const order = Number(value)
|
||||
|
||||
return isValidChecksquareHarmonicOrder(order) ? order : null
|
||||
}
|
||||
|
||||
const parseChecksquareHarmonicOrderFromText = (value?: string | null) => {
|
||||
if (!value) return null
|
||||
|
||||
const orderText = value.match(/(?:^|[^\d])([2-9]|[1-4]\d|50)(?:次|[^\d]|$)/)?.[1]
|
||||
|
||||
return parseChecksquareHarmonicOrder(orderText)
|
||||
}
|
||||
|
||||
const resolveChecksquareHarmonicOrder = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||
return (
|
||||
parseChecksquareHarmonicOrder(item.harmonicOrder) ||
|
||||
parseChecksquareHarmonicOrderFromText(item.itemKey) ||
|
||||
parseChecksquareHarmonicOrderFromText(item.indicatorName) ||
|
||||
parseChecksquareHarmonicOrderFromText(item.indicatorCode)
|
||||
)
|
||||
}
|
||||
|
||||
const sumNumber = (
|
||||
items: SteadyDataView.SteadyChecksquareItem[],
|
||||
getter: (item: SteadyDataView.SteadyChecksquareItem) => unknown
|
||||
) => {
|
||||
return items.reduce((total, item) => {
|
||||
const value = Number(getter(item))
|
||||
|
||||
return Number.isFinite(value) ? total + value : total
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const summarizeStatType = (
|
||||
items: SteadyDataView.SteadyChecksquareItem[],
|
||||
statType: SteadyDataView.SteadyTrendStatType
|
||||
): SteadyDataView.SteadyChecksquareStatSummary => {
|
||||
const summaries = items
|
||||
.map(item => findStatSummary(item, statType))
|
||||
.filter((summary): summary is SteadyDataView.SteadyChecksquareStatSummary => Boolean(summary))
|
||||
const supportedSummaries = summaries.filter(summary => summary.supported !== false)
|
||||
const expectedPointCount = supportedSummaries.reduce((total, summary) => total + (summary.expectedPointCount || 0), 0)
|
||||
const actualPointCount = supportedSummaries.reduce((total, summary) => total + (summary.actualPointCount || 0), 0)
|
||||
const missingPointCount = supportedSummaries.reduce((total, summary) => total + (summary.missingPointCount || 0), 0)
|
||||
const maxContinuousMissingMinutes = Math.max(
|
||||
0,
|
||||
...supportedSummaries.map(summary => summary.maxContinuousMissingMinutes || 0)
|
||||
)
|
||||
|
||||
return {
|
||||
statType,
|
||||
supported: supportedSummaries.length > 0,
|
||||
hasData: supportedSummaries.length > 0 && supportedSummaries.every(summary => summary.hasData === true),
|
||||
expectedPointCount,
|
||||
actualPointCount,
|
||||
missingPointCount,
|
||||
missingRate: expectedPointCount ? missingPointCount / expectedPointCount : null,
|
||||
maxContinuousMissingMinutes
|
||||
}
|
||||
}
|
||||
|
||||
export const buildHarmonicParentSummary = (
|
||||
parentItem: SteadyDataView.SteadyChecksquareItem,
|
||||
children: SteadyDataView.SteadyChecksquareItem[]
|
||||
): SteadyDataView.SteadyChecksquareItem => {
|
||||
if (!children.length || !children.every(item => isResolvedChecksquareItem(item))) {
|
||||
return {
|
||||
itemKey: parentItem.itemKey,
|
||||
indicatorCode: parentItem.indicatorCode,
|
||||
indicatorName: parentItem.indicatorName,
|
||||
statSummaries: [],
|
||||
statDetails: [],
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
const expectedPointCount = sumNumber(children, item => item.expectedPointCount)
|
||||
const actualPointCount = sumNumber(children, item => item.actualPointCount)
|
||||
const missingPointCount = sumNumber(children, item => item.missingPointCount)
|
||||
const maxContinuousMissingMinutes = Math.max(0, ...children.map(item => item.maxContinuousMissingMinutes || 0))
|
||||
const statSummaries = CHECKSQUARE_STAT_TYPES.map(statType => summarizeStatType(children, statType))
|
||||
|
||||
return {
|
||||
...parentItem,
|
||||
hasData: children.every(item => item.hasData === true),
|
||||
expectedPointCount,
|
||||
actualPointCount,
|
||||
missingPointCount,
|
||||
missingRate: expectedPointCount ? missingPointCount / expectedPointCount : null,
|
||||
missingRateText: expectedPointCount ? undefined : '-',
|
||||
maxContinuousMissingMinutes,
|
||||
statSummaries,
|
||||
statDetails: [],
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
export const mergeChecksquareIndicatorResult = (
|
||||
currentResult: SteadyDataView.SteadyChecksquareQueryResult | null,
|
||||
indicator: SteadyDataView.SteadyIndicatorNode,
|
||||
indicatorResult: SteadyDataView.SteadyChecksquareQueryResult
|
||||
): SteadyDataView.SteadyChecksquareQueryResult => {
|
||||
const pendingResult = currentResult || {
|
||||
lineId: indicatorResult.lineId || '',
|
||||
lineName: indicatorResult.lineName,
|
||||
timeStart: indicatorResult.timeStart || '',
|
||||
timeEnd: indicatorResult.timeEnd || '',
|
||||
intervalMinutes: indicatorResult.intervalMinutes,
|
||||
items: [buildPendingChecksquareItem(indicator)]
|
||||
}
|
||||
const indicatorCode = indicator.indicatorCode
|
||||
const resultItems = indicatorResult.items || []
|
||||
const shouldMergeHarmonicItems = isChecksquareHarmonicIndicator(indicator)
|
||||
const normalItems = shouldMergeHarmonicItems
|
||||
? resultItems.filter(item => !resolveChecksquareHarmonicOrder(item))
|
||||
: resultItems
|
||||
const harmonicItems = shouldMergeHarmonicItems
|
||||
? resultItems.filter(item => resolveChecksquareHarmonicOrder(item))
|
||||
: []
|
||||
const currentItem = pendingResult.items.find(item => item.indicatorCode === indicatorCode)
|
||||
const mergedItem = normalItems[0] || currentItem || buildPendingChecksquareItem(indicator)
|
||||
const currentChildren = currentItem?.children || mergedItem.children || []
|
||||
const mergedChildren = currentChildren.length
|
||||
? currentChildren.map(child => {
|
||||
const replacement = harmonicItems.find(item => resolveChecksquareHarmonicOrder(item) === child.harmonicOrder)
|
||||
|
||||
return replacement
|
||||
? normalizeChecksquareResultItemKey(
|
||||
{
|
||||
...replacement,
|
||||
harmonicOrder: child.harmonicOrder
|
||||
},
|
||||
child.itemKey
|
||||
)
|
||||
: child
|
||||
})
|
||||
: harmonicItems
|
||||
const replacement = {
|
||||
...mergedItem,
|
||||
itemKey: currentItem?.itemKey || mergedItem.itemKey,
|
||||
indicatorName: indicator.name || mergedItem.indicatorName || mergedItem.indicatorCode,
|
||||
children: mergedChildren.length ? mergedChildren : undefined
|
||||
} as SteadyDataView.SteadyChecksquareItem
|
||||
const finalReplacement = replacement.children?.length ? buildHarmonicParentSummary(replacement, replacement.children) : replacement
|
||||
|
||||
return {
|
||||
...pendingResult,
|
||||
lineId: indicatorResult.lineId || pendingResult.lineId,
|
||||
lineName: indicatorResult.lineName || pendingResult.lineName,
|
||||
timeStart: indicatorResult.timeStart || pendingResult.timeStart,
|
||||
timeEnd: indicatorResult.timeEnd || pendingResult.timeEnd,
|
||||
intervalMinutes: indicatorResult.intervalMinutes ?? pendingResult.intervalMinutes,
|
||||
items: pendingResult.items.map(item => (item.indicatorCode === indicatorCode ? finalReplacement : item))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const steadyDir = path.resolve(currentDir, '..')
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8')
|
||||
|
||||
const files = {
|
||||
checksquarePage: path.join(steadyDir, 'checksquare/index.vue'),
|
||||
dataViewPage: path.join(steadyDir, 'steadyDataView/index.vue'),
|
||||
dataViewSelectionRules: path.join(steadyDir, 'steadyDataView/utils/selectionRules.ts'),
|
||||
trendPage: path.join(steadyDir, 'steadyTrend/index.vue'),
|
||||
trendSelectionRules: path.join(steadyDir, 'steadyTrend/utils/selectionRules.ts')
|
||||
}
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'shared steadyDataView selection rules define voltage trend before current trend',
|
||||
() => /STEADY_INDICATOR_GROUP_ORDER\s*=\s*\[[\s\S]*电压趋势[\s\S]*电流趋势/.test(read(files.dataViewSelectionRules))
|
||||
],
|
||||
[
|
||||
'steadyTrend selection rules define voltage trend before current trend',
|
||||
() => /STEADY_INDICATOR_GROUP_ORDER\s*=\s*\[[\s\S]*电压趋势[\s\S]*电流趋势/.test(read(files.trendSelectionRules))
|
||||
],
|
||||
[
|
||||
'steadyDataView sorts indicator tree after loading',
|
||||
() => /indicatorTree\.value\s*=\s*sortSteadyIndicatorTree\(unwrapData\(response\)\s*\|\|\s*\[\]\)/.test(read(files.dataViewPage))
|
||||
],
|
||||
[
|
||||
'steadyTrend sorts indicator tree after loading',
|
||||
() => /indicatorTree\.value\s*=\s*sortSteadyIndicatorTree\(unwrapData\(response\)\s*\|\|\s*\[\]\)/.test(read(files.trendPage))
|
||||
],
|
||||
[
|
||||
'checksquare sorts indicator tree after loading',
|
||||
() => /indicatorTree\.value\s*=\s*sortSteadyIndicatorTree\(unwrapData\(response\)\s*\|\|\s*\[\]\)/.test(read(files.checksquarePage))
|
||||
]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steady indicator tree order contract failed:')
|
||||
failures.forEach(failure => console.error(`- ${failure}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steady indicator tree order contract passed')
|
||||
@@ -1,107 +0,0 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, 'index.vue')
|
||||
const apiFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/index.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/interface/index.ts')
|
||||
const componentDir = path.join(currentDir, 'components')
|
||||
const utilsDir = path.join(currentDir, 'utils')
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8')
|
||||
const pageSource = read(pageFile)
|
||||
const apiSource = read(apiFile)
|
||||
const interfaceSource = read(interfaceFile)
|
||||
const componentSource = fs.existsSync(componentDir)
|
||||
? fs
|
||||
.readdirSync(componentDir)
|
||||
.filter(file => file.endsWith('.vue'))
|
||||
.map(file => read(path.join(componentDir, file)))
|
||||
.join('\n')
|
||||
: ''
|
||||
const utilitySource = fs.existsSync(utilsDir)
|
||||
? fs
|
||||
.readdirSync(utilsDir)
|
||||
.filter(file => file.endsWith('.ts'))
|
||||
.map(file => read(path.join(utilsDir, file)))
|
||||
.join('\n')
|
||||
: ''
|
||||
|
||||
const expectations = [
|
||||
['page imports ledger tree panel', /SteadyLedgerTree/],
|
||||
['page imports indicator tree panel', /SteadyIndicatorTree/],
|
||||
['page imports trend toolbar', /SteadyTrendToolbar/],
|
||||
['page imports trend chart panel', /SteadyTrendChartPanel/],
|
||||
['page does not import trend summary panel', /SteadyTrendSummaryPanel/],
|
||||
['page does not import data table panel', /SteadyDataTablePanel/],
|
||||
['page renders floating indicator panel', /indicator-floating-panel/],
|
||||
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
|
||||
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
|
||||
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
|
||||
['API exposes trend query endpoint', /\/steady\/data-view\/trend\/query/],
|
||||
['API exposes trend day endpoint', /\/steady\/data-view\/trend\/day/],
|
||||
['API does not expose trend summary endpoint', /\/steady\/data-view\/trend\/summary/],
|
||||
['interfaces define trend query params', /interface\s+SteadyTrendQueryParams/],
|
||||
['interfaces define trend series', /interface\s+SteadyTrendSeries/],
|
||||
['interfaces do not define trend summary', /interface\s+SteadyTrendSummary/],
|
||||
['components render ledger checkbox tree', /show-checkbox[\s\S]*@check/],
|
||||
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
|
||||
['components reuse LineChart', /<LineChart/],
|
||||
['toolbar uses shared time period search', /TimePeriodSearch/],
|
||||
['toolbar labels phase options descriptively', /resolvePhaseLabel/],
|
||||
['toolbar labels bucket options descriptively', /bucketOptions[\s\S]*1分钟[\s\S]*1小时/],
|
||||
['toolbar labels quality options descriptively', /仅有效数据[\s\S]*仅无效数据/],
|
||||
['utilities collect selected line ids', /export const collectSelectedLineIds/],
|
||||
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
|
||||
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*6/],
|
||||
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
|
||||
['utilities build chart options', /export const buildSteadyTrendChartOptions/]
|
||||
]
|
||||
|
||||
const sourceByExpectation = [
|
||||
pageSource,
|
||||
pageSource,
|
||||
pageSource,
|
||||
pageSource,
|
||||
pageSource,
|
||||
pageSource,
|
||||
pageSource,
|
||||
pageSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
interfaceSource,
|
||||
interfaceSource,
|
||||
interfaceSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([name, pattern], index) => {
|
||||
const matched = pattern.test(sourceByExpectation[index])
|
||||
return name.includes('does not') || name.includes('do not') ? matched : !matched
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView trend contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView trend contract passed')
|
||||
@@ -1,59 +0,0 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, 'index.vue')
|
||||
const apiFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/index.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/interface/index.ts')
|
||||
|
||||
const source = fs.readFileSync(pageFile, 'utf8')
|
||||
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||
|
||||
const forbiddenPatterns = [
|
||||
['data detail tab is removed', /数据明细|name="detail"|SteadyDataTablePanel/, source],
|
||||
['detail ProTable is removed', /buildSteadyDataQueryParams|SteadyDataSearchParams/, source],
|
||||
['trend summary panel is removed', /SteadyTrendSummaryPanel|trendSummary|loading\.summary/, source],
|
||||
[
|
||||
'page detail API is removed',
|
||||
/getSteadyDataPage|getSteadyDataDetail|getSteadyDataTemplates|\/steady\/data-view\/page|\/steady\/data-view\/detail|\/steady\/data-view\/templates/,
|
||||
apiSource
|
||||
],
|
||||
['trend summary API is removed', /getSteadyTrendSummary|\/steady\/data-view\/trend\/summary/, apiSource],
|
||||
[
|
||||
'page detail types are removed',
|
||||
/PageResult|SteadyDataPageParams|SteadyDataDetailParams|SteadyDataTemplate|SteadyDataRecord/,
|
||||
interfaceSource
|
||||
],
|
||||
[
|
||||
'trend summary types are removed',
|
||||
/SteadyTrendSummary|SteadyTrendSummaryItem/,
|
||||
interfaceSource
|
||||
]
|
||||
]
|
||||
|
||||
const requiredPatterns = [
|
||||
['page defines SteadyDataView component name', /name:\s*'SteadyDataView'/, source],
|
||||
['page keeps trend chart panel', /SteadyTrendChartPanel/, source],
|
||||
['page keeps right floating indicator panel', /indicator-floating-panel/, source],
|
||||
['indicator panel defaults expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/, source],
|
||||
['indicator panel supports collapsed state', /is-collapsed/, source],
|
||||
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource]
|
||||
]
|
||||
|
||||
const failures = [
|
||||
...forbiddenPatterns.filter(([, pattern, target]) => pattern.test(target)),
|
||||
...requiredPatterns.filter(([, pattern, target]) => !pattern.test(target))
|
||||
]
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView visible contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView visible contract passed')
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<aside class="indicator-floating-panel" :class="[`is-${mode}`, { 'is-collapsed': collapsed }]">
|
||||
<el-button
|
||||
class="indicator-toggle"
|
||||
type="primary"
|
||||
:icon="collapsed ? ArrowLeft : ArrowRight"
|
||||
circle
|
||||
@click="emit('update:collapsed', !collapsed)"
|
||||
/>
|
||||
<div v-show="!collapsed" class="indicator-panel-body">
|
||||
<SteadyIndicatorTree
|
||||
:key="selectorResetKey"
|
||||
:tree-data="treeData"
|
||||
:default-checked-keys="defaultCheckedKeys"
|
||||
@change="emit('change', $event)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import SteadyIndicatorTree from './SteadyIndicatorTree.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'SteadyIndicatorFloatingPanel'
|
||||
})
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
collapsed: boolean
|
||||
treeData: SteadyDataView.SteadyIndicatorNode[]
|
||||
defaultCheckedKeys: string[]
|
||||
selectorResetKey: number
|
||||
mode?: 'floating' | 'inline'
|
||||
}>(),
|
||||
{
|
||||
mode: 'floating'
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:collapsed': [value: boolean]
|
||||
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.indicator-floating-panel {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 2;
|
||||
width: 300px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.indicator-floating-panel.is-collapsed {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.indicator-floating-panel.is-inline {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.indicator-floating-panel.is-inline.is-collapsed {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.indicator-toggle {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: -28px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.indicator-panel-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.indicator-panel-body :deep(.steady-tree-card) {
|
||||
height: 100%;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.indicator-floating-panel.is-collapsed .indicator-panel-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.indicator-floating-panel {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.indicator-floating-panel.is-inline {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,6 @@
|
||||
<section class="card steady-tree-card indicator-tree">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">稳态指标</span>
|
||||
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="tree-scrollbar">
|
||||
@@ -13,6 +12,7 @@
|
||||
node-key="treeKey"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
:default-checked-keys="defaultCheckedKeys"
|
||||
:expand-on-click-node="false"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="handleCheck"
|
||||
@@ -29,8 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { TreeInstance } from 'element-plus'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import { collectLeafIndicators } from '../utils/selectionRules'
|
||||
@@ -41,11 +40,10 @@ defineOptions({
|
||||
|
||||
const props = defineProps<{
|
||||
treeData: SteadyDataView.SteadyIndicatorNode[]
|
||||
loading: boolean
|
||||
defaultCheckedKeys: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||
}>()
|
||||
|
||||
@@ -67,9 +65,22 @@ const normalizedTreeData = computed(() => {
|
||||
})
|
||||
|
||||
const handleCheck = () => {
|
||||
const checkedNodes = (treeRef.value?.getCheckedNodes(false, true) || []) as SteadyDataView.SteadyIndicatorNode[]
|
||||
const checkedNodes = (treeRef.value?.getCheckedNodes(false, false) || []) as SteadyDataView.SteadyIndicatorNode[]
|
||||
emit('change', collectLeafIndicators(checkedNodes))
|
||||
}
|
||||
|
||||
const applyDefaultCheckedKeys = async () => {
|
||||
await nextTick()
|
||||
treeRef.value?.setCheckedKeys(props.defaultCheckedKeys, false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [normalizedTreeData.value, props.defaultCheckedKeys],
|
||||
() => {
|
||||
applyDefaultCheckedKeys()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -81,7 +92,6 @@ const handleCheck = () => {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -89,6 +99,13 @@ const handleCheck = () => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,47 +1,67 @@
|
||||
<template>
|
||||
<section class="card steady-tree-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">台账监测点</span>
|
||||
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
||||
<section class="card steady-tree-card" :class="{ 'is-collapsed': collapsed }">
|
||||
<div v-show="collapsed" class="collapsed-panel">
|
||||
<el-tooltip content="展开设备树" placement="right">
|
||||
<el-button class="panel-toggle" type="primary" :icon="ArrowRight" circle @click="emit('toggle')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
:model-value="keyword"
|
||||
clearable
|
||||
placeholder="搜索工程、项目、设备、监测点"
|
||||
@update:model-value="handleKeywordChange"
|
||||
/>
|
||||
<div v-show="!collapsed" class="expanded-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">设备树</span>
|
||||
<el-tooltip content="收缩设备树" placement="top">
|
||||
<el-button class="panel-toggle" type="primary" :icon="ArrowLeft" circle @click="emit('toggle')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="tree-scrollbar">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
class="ledger-tree"
|
||||
:data="treeData"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
:expand-on-click-node="false"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="handleCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node">
|
||||
<span class="node-name">{{ data.name }}</span>
|
||||
<span class="node-count">
|
||||
<template v-if="Number(data.deviceCount) || Number(data.lineCount)">
|
||||
{{ Number(data.deviceCount || 0) }} / {{ Number(data.lineCount || 0) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-scrollbar>
|
||||
<div class="tree-search-row">
|
||||
<el-input
|
||||
:model-value="keyword"
|
||||
clearable
|
||||
placeholder="搜索工程、项目、设备、监测点"
|
||||
@update:model-value="handleKeywordChange"
|
||||
></el-input>
|
||||
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="tree-scrollbar">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
class="ledger-tree"
|
||||
:data="treeData"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
:default-checked-keys="defaultCheckedKeys"
|
||||
:expand-on-click-node="false"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="handleCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node">
|
||||
<span class="node-main">
|
||||
<el-icon :class="['node-icon', `is-level-${normalizeLedgerLevel(data.level)}`]">
|
||||
<component :is="resolveLedgerIcon(data.level)" />
|
||||
</el-icon>
|
||||
<span class="node-name">{{ data.name }}</span>
|
||||
</span>
|
||||
<span class="node-count">
|
||||
<template v-if="shouldShowLedgerCount(data)">
|
||||
{{ resolveLedgerCountText(data) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { ArrowLeft, ArrowRight, Folder, Location, Monitor, OfficeBuilding, Refresh } from '@element-plus/icons-vue'
|
||||
import type { TreeInstance } from 'element-plus'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
|
||||
@@ -49,27 +69,75 @@ defineOptions({
|
||||
name: 'SteadyLedgerTree'
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
treeData: SteadyDataView.SteadyLedgerNode[]
|
||||
loading: boolean
|
||||
keyword: string
|
||||
defaultCheckedKeys: string[]
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
search: [value: string]
|
||||
change: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
const treeRef = ref<TreeInstance>()
|
||||
type LedgerLevel = SteadyDataView.SteadyLedgerNode['level']
|
||||
const ledgerIcons: Record<LedgerLevel, Component> = {
|
||||
0: OfficeBuilding,
|
||||
1: Folder,
|
||||
2: Monitor,
|
||||
3: Location
|
||||
}
|
||||
|
||||
const normalizeLedgerLevel = (value: unknown): LedgerLevel => {
|
||||
const level = Number(value)
|
||||
if (level === 0 || level === 1 || level === 2 || level === 3) {
|
||||
return level
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const resolveLedgerIcon = (value: unknown) => {
|
||||
return ledgerIcons[normalizeLedgerLevel(value)]
|
||||
}
|
||||
|
||||
const shouldShowLedgerCount = (data: 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)
|
||||
}
|
||||
|
||||
const handleCheck = () => {
|
||||
emit('change', (treeRef.value?.getCheckedNodes(false, true) || []) as SteadyDataView.SteadyLedgerNode[])
|
||||
emit('change', (treeRef.value?.getCheckedNodes(false, false) || []) as SteadyDataView.SteadyLedgerNode[])
|
||||
}
|
||||
|
||||
const applyDefaultCheckedKeys = async () => {
|
||||
await nextTick()
|
||||
treeRef.value?.setCheckedKeys(props.defaultCheckedKeys, false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.treeData, props.defaultCheckedKeys],
|
||||
() => {
|
||||
applyDefaultCheckedKeys()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -81,6 +149,46 @@ const handleCheck = () => {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.steady-tree-card:not(.is-collapsed) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.steady-tree-card.is-collapsed {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
align-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.collapsed-panel,
|
||||
.expanded-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.collapsed-panel {
|
||||
display: flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expanded-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.tree-node {
|
||||
display: flex;
|
||||
@@ -95,6 +203,21 @@ const handleCheck = () => {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tree-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-search-row :deep(.el-input) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-scrollbar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -105,6 +228,35 @@ const handleCheck = () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node-main {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
flex: none;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.node-icon.is-level-0 {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.node-icon.is-level-1 {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.node-icon.is-level-2 {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.node-icon.is-level-3 {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
|
||||
.node-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -1,26 +1,78 @@
|
||||
<template>
|
||||
<section class="card trend-chart-panel" v-loading="loading">
|
||||
<section class="card trend-chart-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">趋势图</span>
|
||||
<span class="panel-meta">
|
||||
<template v-if="trendResult">
|
||||
{{ trendResult.bucket || '-' }} / {{ trendResult.displayPointCount || 0 }} 点
|
||||
</template>
|
||||
</span>
|
||||
<SteadyTrendChartTools
|
||||
:tool-groups="trendToolGroups"
|
||||
:is-tool-active="isTrendToolActive"
|
||||
:is-tool-disabled="isTrendToolDisabled"
|
||||
:get-tool-tooltip="getTrendToolTooltip"
|
||||
@tool-action="handleTrendToolAction"
|
||||
/>
|
||||
<span v-if="trendResult" class="panel-meta">总点数:{{ trendResult.displayPointCount || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSeries" class="chart-body">
|
||||
<LineChart :options="chartOptions" />
|
||||
<div class="chart-panel-body" v-loading="loading">
|
||||
<div
|
||||
v-if="hasChartFrame"
|
||||
ref="chartExportTargetRef"
|
||||
class="chart-list steady-trend-export-target"
|
||||
:style="{ '--steady-trend-visible-chart-count': normalVisibleChartCount }"
|
||||
>
|
||||
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
||||
<div class="chart-body">
|
||||
<LineChart
|
||||
:options="group.options"
|
||||
:group="group.group"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else-if="hasQueriedWithoutData" class="chart-empty" description="暂无数据" />
|
||||
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
||||
</div>
|
||||
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
||||
|
||||
<SteadyTrendFullscreen
|
||||
v-model="fullscreenVisible"
|
||||
:chart-groups="chartGroups"
|
||||
:visible-chart-count="fullscreenVisibleChartCount"
|
||||
:tool-groups="fullscreenToolGroups"
|
||||
:is-tool-active="isTrendToolActive"
|
||||
:is-tool-disabled="isTrendToolDisabled"
|
||||
:get-tool-tooltip="getTrendToolTooltip"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@tool-action="handleTrendToolAction"
|
||||
/>
|
||||
|
||||
<SteadyTrendDataTableDialog v-model="dataTableVisible" :trend-result="trendResult" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
ArrowDownBold,
|
||||
ArrowLeftBold,
|
||||
ArrowRightBold,
|
||||
ArrowUpBold,
|
||||
Crop,
|
||||
DataAnalysis,
|
||||
DataLine,
|
||||
FullScreen,
|
||||
Mouse,
|
||||
Picture,
|
||||
Pointer,
|
||||
RefreshLeft
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import LineChart from '@/components/echarts/line/index.vue'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import { buildSteadyTrendChartOptions } from '../utils/trendOptions'
|
||||
import { buildSteadyTrendChartGroups, type SteadyTrendZoomRange } from '../utils/trendOptions'
|
||||
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||||
import SteadyTrendChartTools from './SteadyTrendChartTools.vue'
|
||||
import SteadyTrendDataTableDialog from './SteadyTrendDataTableDialog.vue'
|
||||
import SteadyTrendFullscreen from './SteadyTrendFullscreen.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'SteadyTrendChartPanel'
|
||||
@@ -31,16 +83,305 @@ const props = defineProps<{
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
type SteadyTrendInteractionMode = 'none' | 'box-zoom' | 'pan'
|
||||
const trendToolGroups: SteadyTrendToolGroup[] = [
|
||||
{
|
||||
key: 'viewport',
|
||||
items: [
|
||||
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
|
||||
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
|
||||
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
||||
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
||||
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
||||
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
||||
{ action: 'pan', label: '平移', icon: Pointer },
|
||||
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'view',
|
||||
items: [
|
||||
{ action: 'missing-data', label: '缺失数据', icon: DataLine },
|
||||
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
|
||||
{ action: 'download-image', label: '下载图片', icon: Picture },
|
||||
{ action: 'query-data', label: '数据查询', icon: DataAnalysis }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
|
||||
const DEFAULT_STEADY_TREND_X_ZOOM_RANGE: SteadyTrendZoomRange = { start: 0, end: 100 }
|
||||
const STEADY_TREND_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const STEADY_TREND_HALF_RANGE_DAYS = 20
|
||||
const STEADY_TREND_QUARTER_RANGE_DAYS = 30
|
||||
const STEADY_TREND_TENTH_RANGE_DAYS = 60
|
||||
const trendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
||||
const defaultTrendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
||||
const trendYZoomScale = ref(1)
|
||||
const activeTrendInteractionMode = ref<SteadyTrendInteractionMode>('none')
|
||||
const wheelZoomEnabled = ref(false)
|
||||
const missingDataEnabled = ref(true)
|
||||
const fullscreenVisible = ref(false)
|
||||
const dataTableVisible = ref(false)
|
||||
const chartExportTargetRef = ref<HTMLElement>()
|
||||
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
|
||||
const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResult?.series || []))
|
||||
const hasQueryTimeRange = computed(() => Boolean(props.trendResult?.queryTimeStart && props.trendResult?.queryTimeEnd))
|
||||
const hasDataPoints = computed(() =>
|
||||
Boolean(
|
||||
props.trendResult?.series?.some(series =>
|
||||
(series.points || []).some(point => typeof point.value === 'number' && Number.isFinite(point.value))
|
||||
)
|
||||
)
|
||||
)
|
||||
const hasChartFrame = computed(() => hasSeries.value || (hasQueryTimeRange.value && !props.trendResult?.queryCompleted))
|
||||
const hasQueriedWithoutData = computed(() => Boolean(props.trendResult?.queryCompleted && !hasDataPoints.value))
|
||||
const chartGroups = computed(() =>
|
||||
buildSteadyTrendChartGroups(props.trendResult?.series || [], trendXZoomRange.value, {
|
||||
activeTool: activeTrendInteractionMode.value,
|
||||
wheelZoomEnabled: wheelZoomEnabled.value,
|
||||
showMissingData: missingDataEnabled.value,
|
||||
yZoomScale: trendYZoomScale.value,
|
||||
queryTimeStart: props.trendResult?.queryTimeStart,
|
||||
queryTimeEnd: props.trendResult?.queryTimeEnd
|
||||
})
|
||||
)
|
||||
const normalVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 3), 1))
|
||||
const fullscreenVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 6), 1))
|
||||
const fullscreenToolGroups = computed(() =>
|
||||
trendToolGroups
|
||||
.map(group => ({
|
||||
...group,
|
||||
items: group.items.filter(item => item.action !== 'fullscreen')
|
||||
}))
|
||||
.filter(group => group.items.length)
|
||||
)
|
||||
const canPanTrendChart = computed(() => {
|
||||
const { start, end } = trendXZoomRange.value
|
||||
|
||||
return hasSeries.value && (start > 0 || end < 100)
|
||||
})
|
||||
const isDefaultTrendXZoomRange = computed(() => {
|
||||
const { start, end } = trendXZoomRange.value
|
||||
const defaultRange = defaultTrendXZoomRange.value
|
||||
|
||||
return start === defaultRange.start && end === defaultRange.end
|
||||
})
|
||||
const canResetTrendChart = computed(() => {
|
||||
const changedYZoom = trendYZoomScale.value !== 1
|
||||
const changedInteractionMode = activeTrendInteractionMode.value !== 'none'
|
||||
const changedWheelZoom = wheelZoomEnabled.value
|
||||
|
||||
return (
|
||||
hasSeries.value &&
|
||||
(!isDefaultTrendXZoomRange.value || changedYZoom || changedInteractionMode || changedWheelZoom)
|
||||
)
|
||||
})
|
||||
|
||||
const parseSteadyTrendTime = (value?: string) => {
|
||||
if (!value) return null
|
||||
|
||||
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||||
|
||||
return Number.isFinite(timestamp) ? timestamp : null
|
||||
}
|
||||
|
||||
const resolveSteadyTrendTimeRangeMs = (trendResult: SteadyDataView.SteadyTrendQueryResult | null) => {
|
||||
let minTime = Number.POSITIVE_INFINITY
|
||||
let maxTime = Number.NEGATIVE_INFINITY
|
||||
|
||||
trendResult?.series?.forEach(series => {
|
||||
series.points?.forEach(point => {
|
||||
const timestamp = parseSteadyTrendTime(point.time)
|
||||
|
||||
if (timestamp === null) return
|
||||
|
||||
minTime = Math.min(minTime, timestamp)
|
||||
maxTime = Math.max(maxTime, timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
return Number.isFinite(minTime) && Number.isFinite(maxTime) && maxTime > minTime ? maxTime - minTime : 0
|
||||
}
|
||||
|
||||
const resolveSteadyTrendDefaultZoomRange = (trendResult: SteadyDataView.SteadyTrendQueryResult | null) => {
|
||||
const timeRangeMs = resolveSteadyTrendTimeRangeMs(trendResult)
|
||||
const timeRangeDays = timeRangeMs / STEADY_TREND_DAY_MS
|
||||
|
||||
if (timeRangeDays > STEADY_TREND_TENTH_RANGE_DAYS) return { start: 0, end: 10 }
|
||||
if (timeRangeDays > STEADY_TREND_QUARTER_RANGE_DAYS) return { start: 0, end: 25 }
|
||||
if (timeRangeDays > STEADY_TREND_HALF_RANGE_DAYS) return { start: 0, end: 50 }
|
||||
|
||||
return { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
|
||||
}
|
||||
|
||||
const resetTrendToolState = () => {
|
||||
trendXZoomRange.value = { ...defaultTrendXZoomRange.value }
|
||||
trendYZoomScale.value = 1
|
||||
activeTrendInteractionMode.value = 'none'
|
||||
wheelZoomEnabled.value = false
|
||||
}
|
||||
|
||||
const zoomTrendXAxis = (ratio: number) => {
|
||||
const { start, end } = trendXZoomRange.value
|
||||
const center = (start + end) / 2
|
||||
const nextWidth = Math.min(Math.max((end - start) * ratio, 1), 100)
|
||||
const nextStart = clampPercent(center - nextWidth / 2)
|
||||
const nextEnd = clampPercent(center + nextWidth / 2)
|
||||
|
||||
if (nextStart === 0) {
|
||||
trendXZoomRange.value = { start: 0, end: nextWidth }
|
||||
return
|
||||
}
|
||||
|
||||
if (nextEnd === 100) {
|
||||
trendXZoomRange.value = { start: 100 - nextWidth, end: 100 }
|
||||
return
|
||||
}
|
||||
|
||||
trendXZoomRange.value = { start: nextStart, end: nextEnd }
|
||||
}
|
||||
|
||||
const isTrendToolActive = (action: SteadyTrendToolAction) => {
|
||||
if (action === 'fullscreen') return fullscreenVisible.value
|
||||
if (action === 'wheel-zoom') return wheelZoomEnabled.value
|
||||
if (action === 'missing-data') return missingDataEnabled.value
|
||||
if (action === 'box-zoom' || action === 'pan') return activeTrendInteractionMode.value === action
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const isTrendToolDisabled = (action: SteadyTrendToolAction) => {
|
||||
if (action === 'query-data') return false
|
||||
if (!hasChartFrame.value) return true
|
||||
if (action === 'pan') return !canPanTrendChart.value
|
||||
if (action === 'reset') return !canResetTrendChart.value
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const getTrendToolTooltip = (item: SteadyTrendToolItem) => {
|
||||
if (item.action === 'pan' && isTrendToolDisabled(item.action) && hasSeries.value) {
|
||||
return '请先放大 X 轴或框选局部区域后再平移'
|
||||
}
|
||||
|
||||
return item.label
|
||||
}
|
||||
|
||||
const downloadSteadyTrendImage = async () => {
|
||||
await nextTick()
|
||||
|
||||
const targetElement = fullscreenVisible.value
|
||||
? (document.querySelector('.steady-trend-fullscreen__chart-list') as HTMLElement | null)
|
||||
: chartExportTargetRef.value
|
||||
|
||||
if (!targetElement) {
|
||||
ElMessage.warning('暂无可下载的趋势图')
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = await html2canvas(targetElement, {
|
||||
backgroundColor: '#ffffff',
|
||||
scale: window.devicePixelRatio || 1,
|
||||
useCORS: true
|
||||
})
|
||||
const imageUrl = canvas.toDataURL('image/png')
|
||||
const exportFile = document.createElement('a')
|
||||
|
||||
exportFile.style.display = 'none'
|
||||
exportFile.download = `steady-trend-${Date.now()}.png`
|
||||
exportFile.href = imageUrl
|
||||
document.body.appendChild(exportFile)
|
||||
exportFile.click()
|
||||
document.body.removeChild(exportFile)
|
||||
|
||||
ElMessage.success('趋势图图片下载成功')
|
||||
}
|
||||
|
||||
const handleTrendToolAction = async (action: SteadyTrendToolAction) => {
|
||||
if (isTrendToolDisabled(action)) return
|
||||
|
||||
switch (action) {
|
||||
case 'x-zoom-in':
|
||||
zoomTrendXAxis(0.8)
|
||||
break
|
||||
case 'x-zoom-out':
|
||||
zoomTrendXAxis(1.25)
|
||||
break
|
||||
case 'y-zoom-in':
|
||||
trendYZoomScale.value = Math.max(trendYZoomScale.value * 0.8, 0.1)
|
||||
break
|
||||
case 'y-zoom-out':
|
||||
trendYZoomScale.value = Math.min(trendYZoomScale.value * 1.25, 10)
|
||||
break
|
||||
case 'box-zoom':
|
||||
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom'
|
||||
break
|
||||
case 'wheel-zoom':
|
||||
wheelZoomEnabled.value = !wheelZoomEnabled.value
|
||||
break
|
||||
case 'missing-data':
|
||||
missingDataEnabled.value = !missingDataEnabled.value
|
||||
break
|
||||
case 'pan':
|
||||
if (!canPanTrendChart.value) {
|
||||
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
|
||||
activeTrendInteractionMode.value = 'none'
|
||||
break
|
||||
}
|
||||
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'pan' ? 'none' : 'pan'
|
||||
break
|
||||
case 'reset':
|
||||
resetTrendToolState()
|
||||
break
|
||||
case 'fullscreen':
|
||||
fullscreenVisible.value = true
|
||||
break
|
||||
case 'download-image':
|
||||
await downloadSteadyTrendImage()
|
||||
break
|
||||
case 'query-data':
|
||||
if (!hasSeries.value) {
|
||||
ElMessage.warning('请先查询趋势数据')
|
||||
break
|
||||
}
|
||||
dataTableVisible.value = true
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
|
||||
trendXZoomRange.value = {
|
||||
start: clampPercent(value.start),
|
||||
end: clampPercent(value.end)
|
||||
}
|
||||
|
||||
if (!canPanTrendChart.value && activeTrendInteractionMode.value === 'pan') {
|
||||
activeTrendInteractionMode.value = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.trendResult,
|
||||
() => {
|
||||
// 新查询结果按当前数据量重置默认窗口,避免沿用上一批数据的局部缩放范围。
|
||||
defaultTrendXZoomRange.value = resolveSteadyTrendDefaultZoomRange(props.trendResult)
|
||||
resetTrendToolState()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.trend-chart-panel {
|
||||
--steady-trend-chart-gap: 8px;
|
||||
--steady-trend-visible-chart-count: 3;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -48,24 +389,55 @@ const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResu
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
margin-left: 15px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-panel-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--steady-trend-chart-gap);
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chart-group {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 0 0
|
||||
calc(
|
||||
(100% - var(--steady-trend-chart-gap) * (var(--steady-trend-visible-chart-count) - 1)) /
|
||||
var(--steady-trend-visible-chart-count)
|
||||
);
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="trend-tool-groups">
|
||||
<div v-for="group in toolGroups" :key="group.key" class="trend-tool-group">
|
||||
<el-tooltip v-for="item in group.items" :key="item.action" :content="getToolTooltip(item)" placement="top">
|
||||
<el-button
|
||||
:type="isToolActive(item.action) ? 'primary' : 'default'"
|
||||
:icon="item.icon"
|
||||
:disabled="isToolDisabled(item.action)"
|
||||
circle
|
||||
@click="emit('tool-action', item.action)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||||
|
||||
defineOptions({
|
||||
name: 'SteadyTrendChartTools'
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
toolGroups: SteadyTrendToolGroup[]
|
||||
isToolActive: (action: SteadyTrendToolAction) => boolean
|
||||
isToolDisabled: (action: SteadyTrendToolAction) => boolean
|
||||
getToolTooltip: (item: SteadyTrendToolItem) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'tool-action': [action: SteadyTrendToolAction]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.trend-tool-groups {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trend-tool-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trend-tool-group + .trend-tool-group {
|
||||
padding-left: 8px;
|
||||
border-left: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.trend-tool-group :deep(.el-button.is-circle) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visibleProxy"
|
||||
class="steady-trend-data-dialog"
|
||||
title="数据查询"
|
||||
width="86vw"
|
||||
top="7vh"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="table-main card steady-trend-data-table">
|
||||
<div class="table-header">
|
||||
<div class="header-button-lf">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Download"
|
||||
plain
|
||||
:loading="downloading"
|
||||
:disabled="!tableModel.timeValues.length"
|
||||
@click="downloadSteadyTrendData"
|
||||
>
|
||||
下载数据
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="header-button-ri"></div>
|
||||
</div>
|
||||
|
||||
<el-table :data="pagedRows" border stripe height="100%">
|
||||
<el-table-column prop="time" label="时间" min-width="170" fixed="left" align="center" />
|
||||
<el-table-column
|
||||
v-for="lineGroup in tableModel.lineGroups"
|
||||
:key="lineGroup.key"
|
||||
:label="lineGroup.label"
|
||||
align="center"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="indicatorGroup in lineGroup.indicatorGroups"
|
||||
:key="indicatorGroup.key"
|
||||
:label="indicatorGroup.label"
|
||||
align="center"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="column in indicatorGroup.columns"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
min-width="110"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ row[column.prop] ?? '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table-column>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[500, 1000, 2000, 5000]"
|
||||
:total="tableModel.timeValues.length"
|
||||
@size-change="currentPage = 1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import {
|
||||
buildSteadyTrendExcelHtml,
|
||||
buildSteadyTrendTableModel,
|
||||
buildSteadyTrendTableRows,
|
||||
createEmptySteadyTrendTableModel
|
||||
} from '../utils/trendTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'SteadyTrendDataTableDialog'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
trendResult: SteadyDataView.SteadyTrendQueryResult | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(500)
|
||||
const downloading = ref(false)
|
||||
const tableModel = shallowRef(createEmptySteadyTrendTableModel())
|
||||
const visibleProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value)
|
||||
})
|
||||
const pagedRows = computed(() =>
|
||||
buildSteadyTrendTableRows(tableModel.value, (currentPage.value - 1) * pageSize.value, pageSize.value)
|
||||
)
|
||||
|
||||
const downloadSteadyTrendData = async () => {
|
||||
if (!tableModel.value.timeValues.length || !tableModel.value.columns.length) {
|
||||
ElMessage.warning('暂无可下载的数据')
|
||||
return
|
||||
}
|
||||
|
||||
downloading.value = true
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
const excelContent = buildSteadyTrendExcelHtml(tableModel.value)
|
||||
const blob = new Blob([excelContent], { type: 'application/vnd.ms-excel;charset=utf-8;' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const exportFile = document.createElement('a')
|
||||
|
||||
exportFile.style.display = 'none'
|
||||
exportFile.download = `steady-trend-data-${Date.now()}.xls`
|
||||
exportFile.href = blobUrl
|
||||
document.body.appendChild(exportFile)
|
||||
exportFile.click()
|
||||
document.body.removeChild(exportFile)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
|
||||
ElMessage.success('数据下载成功')
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
visible => {
|
||||
if (visible) {
|
||||
tableModel.value = buildSteadyTrendTableModel(props.trendResult?.series || [])
|
||||
currentPage.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
// 弹窗关闭后释放当前页表格模型,避免大数据量结果长期占用额外内存。
|
||||
tableModel.value = createEmptySteadyTrendTableModel()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.steady-trend-data-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-button-lf,
|
||||
.header-button-ri {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-button-ri {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.steady-trend-data-table :deep(.el-table) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.steady-trend-data-table :deep(.el-table__inner-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
flex: none;
|
||||
justify-content: flex-end;
|
||||
padding-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="steady-trend-fullscreen">
|
||||
<header class="steady-trend-fullscreen__header">
|
||||
<span class="steady-trend-fullscreen__title">趋势图全屏展示</span>
|
||||
<el-button class="steady-trend-fullscreen__close" :icon="Close" text circle @click="closeFullscreen" />
|
||||
</header>
|
||||
|
||||
<main class="steady-trend-fullscreen__body">
|
||||
<div class="steady-trend-fullscreen__tool-row">
|
||||
<SteadyTrendChartTools
|
||||
class="steady-trend-fullscreen__tools"
|
||||
:tool-groups="toolGroups"
|
||||
:is-tool-active="isToolActive"
|
||||
:is-tool-disabled="isToolDisabled"
|
||||
:get-tool-tooltip="getToolTooltip"
|
||||
@tool-action="emit('tool-action', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="chartGroups.length"
|
||||
class="steady-trend-fullscreen__chart-list"
|
||||
:style="{ '--steady-trend-visible-chart-count': visibleChartCount }"
|
||||
>
|
||||
<div v-for="group in chartGroups" :key="group.key" class="steady-trend-fullscreen__chart-group">
|
||||
<div class="steady-trend-fullscreen__chart-body">
|
||||
<LineChart
|
||||
:options="group.options"
|
||||
:group="group.group"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else class="steady-trend-fullscreen__empty" description="请选择监测点和指标后查询趋势" />
|
||||
</main>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import LineChart from '@/components/echarts/line/index.vue'
|
||||
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||||
import SteadyTrendChartTools from './SteadyTrendChartTools.vue'
|
||||
import type { SteadyTrendChartGroup, SteadyTrendZoomRange } from '../utils/trendOptions'
|
||||
|
||||
defineOptions({
|
||||
name: 'SteadyTrendFullscreen'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
chartGroups: SteadyTrendChartGroup[]
|
||||
visibleChartCount: number
|
||||
toolGroups: SteadyTrendToolGroup[]
|
||||
isToolActive: (action: SteadyTrendToolAction) => boolean
|
||||
isToolDisabled: (action: SteadyTrendToolAction) => boolean
|
||||
getToolTooltip: (item: SteadyTrendToolItem) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'chart-data-zoom': [value: SteadyTrendZoomRange]
|
||||
'tool-action': [action: SteadyTrendToolAction]
|
||||
}>()
|
||||
|
||||
const closeFullscreen = () => {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!props.modelValue || event.key !== 'Escape') return
|
||||
|
||||
closeFullscreen()
|
||||
}
|
||||
|
||||
const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
|
||||
emit('chart-data-zoom', value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.steady-trend-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
color: #ffffff;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__close {
|
||||
flex: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__body {
|
||||
--steady-trend-chart-gap: 8px;
|
||||
--steady-trend-visible-chart-count: 6;
|
||||
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__tool-row {
|
||||
position: static;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__tools {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__chart-list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--steady-trend-chart-gap);
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__chart-group {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 0 0
|
||||
calc(
|
||||
(100% - var(--steady-trend-chart-gap) * (var(--steady-trend-visible-chart-count) - 1)) /
|
||||
var(--steady-trend-visible-chart-count)
|
||||
);
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__chart-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.steady-trend-fullscreen__empty {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,65 +1,53 @@
|
||||
<template>
|
||||
<section class="card trend-toolbar">
|
||||
<TimePeriodSearch
|
||||
class="trend-toolbar__time"
|
||||
:unit="modelValue.timeUnit"
|
||||
:model-value="modelValue.timeBaseDate"
|
||||
@update:unit="handleTimeUnitChange"
|
||||
@update:model-value="handleTimeBaseDateChange"
|
||||
/>
|
||||
<div class="toolbar-field toolbar-field--time">
|
||||
<span class="toolbar-field__label">时间:</span>
|
||||
<TimePeriodSearch
|
||||
class="trend-toolbar__time"
|
||||
:unit="modelValue.timeUnit"
|
||||
:model-value="modelValue.timeBaseDate"
|
||||
@update:unit="handleTimeUnitChange"
|
||||
@update:model-value="handleTimeBaseDateChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-select
|
||||
:model-value="modelValue.phases"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择相别"
|
||||
@update:model-value="updateField('phases', $event)"
|
||||
>
|
||||
<el-option v-for="item in phaseOptions" :key="item" :label="resolvePhaseLabel(item)" :value="item" />
|
||||
</el-select>
|
||||
<div class="toolbar-field">
|
||||
<span class="toolbar-field__label">统计:</span>
|
||||
<el-select
|
||||
:model-value="modelValue.statType"
|
||||
placeholder="选择统计类型"
|
||||
@update:model-value="updateField('statType', $event)"
|
||||
>
|
||||
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-select
|
||||
:model-value="modelValue.statTypes"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择统计类型"
|
||||
@update:model-value="updateField('statTypes', $event)"
|
||||
>
|
||||
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
|
||||
</el-select>
|
||||
<div class="toolbar-field">
|
||||
<span class="toolbar-field__label">数据质量:</span>
|
||||
<el-switch
|
||||
:model-value="modelValue.qualityFlag ?? 0"
|
||||
class="quality-switch"
|
||||
width="72"
|
||||
inline-prompt
|
||||
active-text="有效"
|
||||
inactive-text="无效"
|
||||
:active-value="0"
|
||||
:inactive-value="1"
|
||||
@update:model-value="handleQualityFlagChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-select
|
||||
:model-value="modelValue.bucket"
|
||||
placeholder="选择时间粒度"
|
||||
@update:model-value="updateField('bucket', $event)"
|
||||
>
|
||||
<el-option v-for="item in bucketOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
:model-value="modelValue.qualityFlag"
|
||||
clearable
|
||||
placeholder="选择数据质量"
|
||||
@update:model-value="updateField('qualityFlag', $event)"
|
||||
>
|
||||
<el-option label="仅有效数据" :value="1" />
|
||||
<el-option label="仅无效数据" :value="0" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-if="showHarmonicOrders"
|
||||
:model-value="modelValue.harmonicOrders"
|
||||
class="harmonic-select"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择谐波次数"
|
||||
@update:model-value="updateField('harmonicOrders', $event)"
|
||||
>
|
||||
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
|
||||
</el-select>
|
||||
<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">
|
||||
<span class="toolbar-field__label">谐波次数:</span>
|
||||
<el-select
|
||||
:model-value="modelValue.harmonicOrders"
|
||||
multiple
|
||||
placeholder="选择谐波次数"
|
||||
@update:model-value="handleHarmonicOrdersChange"
|
||||
>
|
||||
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<el-button type="primary" :loading="loading" @click="emit('query')">查询</el-button>
|
||||
@@ -69,9 +57,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
||||
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||
import { MAX_HARMONIC_ORDER_COUNT } from '../utils/selectionRules'
|
||||
import type { SteadyTrendFormState } from '../utils/trendPayload'
|
||||
|
||||
defineOptions({
|
||||
@@ -80,7 +70,6 @@ defineOptions({
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: SteadyTrendFormState
|
||||
phaseOptions: string[]
|
||||
statOptions: SteadyDataView.SteadyTrendStatType[]
|
||||
showHarmonicOrders: boolean
|
||||
loading: boolean
|
||||
@@ -92,13 +81,6 @@ const emit = defineEmits<{
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const bucketOptions = [
|
||||
{ label: '1分钟', value: '1m' },
|
||||
{ label: '5分钟', value: '5m' },
|
||||
{ label: '10分钟', value: '10m' },
|
||||
{ label: '30分钟', value: '30m' },
|
||||
{ label: '1小时', value: '1h' }
|
||||
]
|
||||
const harmonicOrderOptions = Array.from({ length: 49 }, (_item, index) => index + 2)
|
||||
const statLabelMap: Record<SteadyDataView.SteadyTrendStatType, string> = {
|
||||
AVG: '平均值',
|
||||
@@ -106,14 +88,6 @@ const statLabelMap: Record<SteadyDataView.SteadyTrendStatType, string> = {
|
||||
MIN: '最小值',
|
||||
CP95: '95%概率大值'
|
||||
}
|
||||
const phaseLabelMap: Record<string, string> = {
|
||||
A: 'A相',
|
||||
B: 'B相',
|
||||
C: 'C相',
|
||||
T: '总相'
|
||||
}
|
||||
|
||||
const resolvePhaseLabel = (phase: string) => phaseLabelMap[phase] || `${phase}相`
|
||||
|
||||
const updateField = <K extends keyof SteadyTrendFormState>(field: K, value: SteadyTrendFormState[K]) => {
|
||||
emit('update:modelValue', {
|
||||
@@ -122,6 +96,40 @@ const updateField = <K extends keyof SteadyTrendFormState>(field: K, value: Stea
|
||||
})
|
||||
}
|
||||
|
||||
const handleQualityFlagChange = (value: string | number | boolean) => {
|
||||
updateField('qualityFlag', Number(value))
|
||||
}
|
||||
|
||||
const normalizeHarmonicOrders = (orders: number[]) => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
orders
|
||||
.map(item => Number(item))
|
||||
.filter(item => Number.isInteger(item) && item >= 2 && item <= 50)
|
||||
)
|
||||
).sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
const updateHarmonicOrders = (orders: number[]) => {
|
||||
const nextOrders = normalizeHarmonicOrders(orders)
|
||||
|
||||
if (nextOrders.length > MAX_HARMONIC_ORDER_COUNT) {
|
||||
ElMessage.warning(`谐波次数最多选择 ${MAX_HARMONIC_ORDER_COUNT} 个`)
|
||||
const currentOrders = normalizeHarmonicOrders(props.modelValue.harmonicOrders)
|
||||
updateField(
|
||||
'harmonicOrders',
|
||||
currentOrders.length ? currentOrders : nextOrders.slice(0, MAX_HARMONIC_ORDER_COUNT)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
updateField('harmonicOrders', nextOrders)
|
||||
}
|
||||
|
||||
const handleHarmonicOrdersChange = (value: number[]) => {
|
||||
updateHarmonicOrders(value)
|
||||
}
|
||||
|
||||
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
@@ -143,22 +151,61 @@ const handleTimeBaseDateChange = (value: Date) => {
|
||||
<style scoped lang="scss">
|
||||
.trend-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1.3fr) repeat(4, minmax(132px, 0.7fr)) auto;
|
||||
grid-template-columns: minmax(360px, 1.35fr) repeat(3, minmax(0, 1fr)) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.toolbar-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-field--time {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-field__label {
|
||||
flex: 0 0 auto;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-field :deep(.el-select) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quality-switch {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.trend-toolbar__time {
|
||||
min-width: 260px;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trend-toolbar__time :deep(.time-period-search__unit) {
|
||||
width: 72px;
|
||||
flex: 0 0 72px;
|
||||
}
|
||||
|
||||
.trend-toolbar__time :deep(.time-period-search__picker) {
|
||||
width: 136px;
|
||||
flex: 0 0 136px;
|
||||
}
|
||||
|
||||
.harmonic-select {
|
||||
grid-column: span 2;
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
grid-column: 5;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="steady-trend-layout" :class="{ 'is-ledger-collapsed': ledgerPanelCollapsedProxy }">
|
||||
<aside class="selector-column">
|
||||
<div class="ledger-panel-body">
|
||||
<SteadyLedgerTree
|
||||
:key="selectorResetKey"
|
||||
:tree-data="ledgerTree"
|
||||
:loading="loading.ledger"
|
||||
:keyword="ledgerKeyword"
|
||||
:default-checked-keys="defaultLedgerCheckedKeys"
|
||||
:collapsed="ledgerPanelCollapsedProxy"
|
||||
@refresh="emit('refreshLedger')"
|
||||
@search="emit('ledgerSearch', $event)"
|
||||
@change="emit('ledgerChange', $event)"
|
||||
@toggle="emit('update:ledgerPanelCollapsed', !ledgerPanelCollapsedProxy)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="trend-main">
|
||||
<SteadyTrendToolbar
|
||||
v-model="trendFormProxy"
|
||||
:stat-options="statOptions"
|
||||
:show-harmonic-orders="showHarmonicOrders"
|
||||
:loading="loading.trend"
|
||||
@query="emit('queryTrend')"
|
||||
@reset="emit('resetTrend')"
|
||||
/>
|
||||
|
||||
<div class="trend-content">
|
||||
<SteadyTrendChartPanel :trend-result="trendResult" :loading="loading.trend" />
|
||||
|
||||
<SteadyIndicatorFloatingPanel
|
||||
v-model:collapsed="indicatorPanelCollapsedProxy"
|
||||
:selector-reset-key="selectorResetKey"
|
||||
:tree-data="indicatorTree"
|
||||
:default-checked-keys="defaultIndicatorCheckedKeys"
|
||||
@change="emit('indicatorChange', $event)"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import type { SteadyTrendFormState } from '../utils/trendPayload'
|
||||
import SteadyIndicatorFloatingPanel from './SteadyIndicatorFloatingPanel.vue'
|
||||
import SteadyLedgerTree from './SteadyLedgerTree.vue'
|
||||
import SteadyTrendChartPanel from './SteadyTrendChartPanel.vue'
|
||||
import SteadyTrendToolbar from './SteadyTrendToolbar.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'SteadyTrendWorkbench'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
ledgerTree: SteadyDataView.SteadyLedgerNode[]
|
||||
indicatorTree: SteadyDataView.SteadyIndicatorNode[]
|
||||
trendResult: SteadyDataView.SteadyTrendQueryResult | null
|
||||
trendForm: SteadyTrendFormState
|
||||
statOptions: SteadyDataView.SteadyTrendStatType[]
|
||||
showHarmonicOrders: boolean
|
||||
loading: {
|
||||
ledger: boolean
|
||||
indicator: boolean
|
||||
trend: boolean
|
||||
}
|
||||
ledgerKeyword: string
|
||||
defaultLedgerCheckedKeys: string[]
|
||||
defaultIndicatorCheckedKeys: string[]
|
||||
ledgerPanelCollapsed: boolean
|
||||
indicatorPanelCollapsed: boolean
|
||||
selectorResetKey: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:trendForm': [value: SteadyTrendFormState]
|
||||
'update:ledgerPanelCollapsed': [value: boolean]
|
||||
'update:indicatorPanelCollapsed': [value: boolean]
|
||||
refreshLedger: []
|
||||
ledgerSearch: [value: string]
|
||||
ledgerChange: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
||||
indicatorChange: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||
queryTrend: []
|
||||
resetTrend: []
|
||||
}>()
|
||||
|
||||
const trendFormProxy = computed({
|
||||
get: () => props.trendForm,
|
||||
set: value => emit('update:trendForm', value)
|
||||
})
|
||||
|
||||
const ledgerPanelCollapsedProxy = computed({
|
||||
get: () => props.ledgerPanelCollapsed,
|
||||
set: value => emit('update:ledgerPanelCollapsed', value)
|
||||
})
|
||||
|
||||
const indicatorPanelCollapsedProxy = computed({
|
||||
get: () => props.indicatorPanelCollapsed,
|
||||
set: value => emit('update:indicatorPanelCollapsed', value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.steady-trend-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.steady-trend-layout.is-ledger-collapsed {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.selector-column {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.steady-trend-layout.is-ledger-collapsed .selector-column {
|
||||
z-index: 4;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ledger-panel-body {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.steady-trend-layout.is-ledger-collapsed .ledger-panel-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ledger-panel-body :deep(.steady-tree-card) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.trend-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trend-content {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trend-content :deep(.trend-chart-panel) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.steady-trend-layout:not(.is-ledger-collapsed) {
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type SteadyTrendToolAction =
|
||||
| 'x-zoom-in'
|
||||
| 'x-zoom-out'
|
||||
| 'y-zoom-in'
|
||||
| 'y-zoom-out'
|
||||
| 'box-zoom'
|
||||
| 'wheel-zoom'
|
||||
| 'reset'
|
||||
| 'pan'
|
||||
| 'fullscreen'
|
||||
| 'download-image'
|
||||
| 'query-data'
|
||||
| 'missing-data'
|
||||
|
||||
export interface SteadyTrendToolItem {
|
||||
action: SteadyTrendToolAction
|
||||
label: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
export interface SteadyTrendToolGroup {
|
||||
key: string
|
||||
items: SteadyTrendToolItem[]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const trendOptionsFile = path.join(currentDir, '..', 'utils', 'trendOptions.ts')
|
||||
const trendOptionsSource = fs.readFileSync(trendOptionsFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
['steady y-axis upper padding uses 1.15', /const\s+STEADY_AXIS_EXPAND_RATIO\s*=\s*1\.15/],
|
||||
['steady y-axis lower padding uses 0.85', /const\s+STEADY_AXIS_SHRINK_RATIO\s*=\s*0\.85/],
|
||||
[
|
||||
'steady integer ranges still enter readable-axis normalization',
|
||||
/rawInterval\s*>=\s*STEADY_AXIS_SMALL_INTERVAL_THRESHOLD/,
|
||||
true
|
||||
],
|
||||
['steady y-axis keeps readable interval normalization', /getReadableAxisInterval\(axisRange\s*\/\s*currentSplitCount\)/],
|
||||
['steady y-axis keeps min label visible', /showMinLabel:\s*true/],
|
||||
['steady y-axis keeps max label visible', /showMaxLabel:\s*true/]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern, shouldBeMissing]) => {
|
||||
const exists = pattern.test(trendOptionsSource)
|
||||
return shouldBeMissing ? exists : !exists
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steady axis range contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steady axis range contract check passed')
|
||||
@@ -0,0 +1,506 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewDir = path.join(currentDir, '..')
|
||||
const read = file => {
|
||||
const filePath = path.join(viewDir, file)
|
||||
|
||||
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
|
||||
}
|
||||
|
||||
const toolbarSource = read('components/SteadyTrendToolbar.vue')
|
||||
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
|
||||
const fullscreenSource = read('components/SteadyTrendFullscreen.vue')
|
||||
const chartToolsSource = read('components/SteadyTrendChartTools.vue')
|
||||
const lineChartSource = fs.readFileSync(
|
||||
path.join(viewDir, '..', '..', '..', 'components', 'echarts', 'line', 'index.vue'),
|
||||
'utf8'
|
||||
)
|
||||
const trendPayloadSource = read('utils/trendPayload.ts')
|
||||
const trendOptionsSource = read('utils/trendOptions.ts')
|
||||
const selectionRulesSource = read('utils/selectionRules.ts')
|
||||
const sharedPhaseColorSource = fs.readFileSync(path.join(viewDir, '..', '..', '..', 'utils', 'phaseColors.ts'), 'utf8')
|
||||
const sharedStyleSource = fs.readFileSync(path.join(viewDir, '..', '..', '..', 'styles', 'var.scss'), 'utf8')
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
'toolbar uses a single active stat type',
|
||||
/:model-value="modelValue\.statType"[\s\S]*@update:model-value="updateField\('statType', \$event\)/,
|
||||
toolbarSource
|
||||
],
|
||||
[
|
||||
'toolbar no longer allows multiple stat types in one query',
|
||||
/<el-select(?![\s\S]*multiple[\s\S]*placeholder="[^"]*统计类型")[\s\S]*placeholder="[^"]*统计类型"/,
|
||||
toolbarSource
|
||||
],
|
||||
[
|
||||
'trend payload keeps API statTypes array but only sends active stat',
|
||||
/statTypes:\s*\[formState\.statType\]/,
|
||||
trendPayloadSource
|
||||
],
|
||||
[
|
||||
'selection rules cap monitoring points at six',
|
||||
/MAX_SELECTED_LINE_COUNT\s*=\s*6[\s\S]*lineIds\.length\s*>\s*MAX_SELECTED_LINE_COUNT/,
|
||||
selectionRulesSource
|
||||
],
|
||||
[
|
||||
'selection rules cap indicators at six',
|
||||
/MAX_SELECTED_INDICATOR_COUNT\s*=\s*6[\s\S]*indicators\.length\s*>\s*MAX_SELECTED_INDICATOR_COUNT/,
|
||||
selectionRulesSource
|
||||
],
|
||||
[
|
||||
'chart panel renders a list of grouped charts',
|
||||
/v-for="group in chartGroups"[\s\S]*:options="group\.options"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart loading mask stays outside the fullscreen dialog tree',
|
||||
/<section\s+class="card trend-chart-panel"(?![^>]*v-loading)[\s\S]*<div[^>]*class="chart-panel-body"[^>]*v-loading="loading"[\s\S]*<SteadyTrendFullscreen/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'normal chart list caps visible charts at three while smaller counts fill the viewport',
|
||||
/(?=[\s\S]*normalVisibleChartCount[\s\S]*Math\.min\(chartGroups\.value\.length,\s*3\))(?=[\s\S]*:style="\{\s*'--steady-trend-visible-chart-count':\s*normalVisibleChartCount\s*\}")/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'fullscreen chart list caps visible charts at six while smaller counts fill the viewport',
|
||||
/(?=[\s\S]*fullscreenVisibleChartCount[\s\S]*Math\.min\(chartGroups\.value\.length,\s*6\))(?=[\s\S]*:visible-chart-count="fullscreenVisibleChartCount")/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart groups divide the current viewport by visible chart count',
|
||||
/\.chart-group\s*\{[\s\S]*flex:\s*0 0\s*calc\(\s*\(100% - var\(--steady-trend-chart-gap\)\s*\*\s*\(var\(--steady-trend-visible-chart-count\)\s*-\s*1\)\)\s*\/\s*var\(--steady-trend-visible-chart-count\)\s*\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart groups include their border in the divided height to avoid false scrollbars',
|
||||
/\.chart-group\s*\{[\s\S]*box-sizing:\s*border-box/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel delegates fullscreen rendering to a dedicated component',
|
||||
/<SteadyTrendFullscreen[\s\S]*v-model="fullscreenVisible"[\s\S]*:chart-groups="chartGroups"[\s\S]*:visible-chart-count="fullscreenVisibleChartCount"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel passes shared trend tools into fullscreen',
|
||||
/<SteadyTrendFullscreen[\s\S]*:tool-groups="fullscreenToolGroups"[\s\S]*:is-tool-active="isTrendToolActive"[\s\S]*:is-tool-disabled="isTrendToolDisabled"[\s\S]*:get-tool-tooltip="getTrendToolTooltip"[\s\S]*@tool-action="handleTrendToolAction"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'fullscreen tool groups omit nested fullscreen action',
|
||||
/fullscreenToolGroups[\s\S]*item\.action\s*!==\s*'fullscreen'/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel does not use Element Plus dialog for steady fullscreen',
|
||||
/^(?![\s\S]*<el-dialog[\s\S]*steady-trend-fullscreen)(?![\s\S]*fullscreen-chart-body)[\s\S]*$/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'fullscreen component renders through a fixed body teleport',
|
||||
/<Teleport\s+to="body">[\s\S]*class="steady-trend-fullscreen"[\s\S]*position:\s*fixed[\s\S]*inset:\s*0/,
|
||||
fullscreenSource
|
||||
],
|
||||
[
|
||||
'fullscreen component has an explicit close button and Esc handling',
|
||||
/@click="closeFullscreen"[\s\S]*event\.key\s*!==\s*'Escape'[\s\S]*window\.addEventListener\('keydown',\s*handleKeydown\)/,
|
||||
fullscreenSource
|
||||
],
|
||||
[
|
||||
'fullscreen component gives chart body a concrete flex viewport',
|
||||
/\.steady-trend-fullscreen__body\s*\{[\s\S]*display:\s*flex[\s\S]*flex:\s*1\s+1\s+auto[\s\S]*min-height:\s*0[\s\S]*\.steady-trend-fullscreen__chart-list\s*\{[\s\S]*flex:\s*1\s+1\s+auto[\s\S]*overflow-y:\s*auto/,
|
||||
fullscreenSource
|
||||
],
|
||||
[
|
||||
'fullscreen keeps trend tool groups in a real workspace row below the title bar',
|
||||
/<main class="steady-trend-fullscreen__body">[\s\S]*<div class="steady-trend-fullscreen__tool-row">[\s\S]*<SteadyTrendChartTools[\s\S]*<\/div>[\s\S]*class="steady-trend-fullscreen__chart-list"/,
|
||||
fullscreenSource
|
||||
],
|
||||
[
|
||||
'fullscreen title header does not contain trend tool groups',
|
||||
/<header class="steady-trend-fullscreen__header">(?:(?!SteadyTrendChartTools)[\s\S])*<\/header>/,
|
||||
fullscreenSource
|
||||
],
|
||||
[
|
||||
'fullscreen tool row reserves layout space instead of floating over charts',
|
||||
/\.steady-trend-fullscreen__tool-row\s*\{(?=[\s\S]*position:\s*static)(?=[\s\S]*display:\s*flex)(?=[\s\S]*flex:\s*none)[\s\S]*\}/,
|
||||
fullscreenSource
|
||||
],
|
||||
[
|
||||
'chart panel syncs grouped charts through LineChart group and dataZoom events',
|
||||
/<LineChart[\s\S]*:options="group\.options"[\s\S]*:group="group\.group"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel shows total point count with explicit label',
|
||||
/class="panel-meta"[\s\S]*trendResult\.displayPointCount\s*\|\|\s*0/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel does not render bucket slash prefix before point count',
|
||||
/^(?![\s\S]*trendResult\.bucket[\s\S]*\/[\s\S]*trendResult\.displayPointCount)[\s\S]*$/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel renders shared trend tool groups',
|
||||
/<SteadyTrendChartTools[\s\S]*:tool-groups="trendToolGroups"[\s\S]*@tool-action="handleTrendToolAction"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel keeps total point count on the right of the trend toolbar with 15px spacing',
|
||||
/\.panel-meta\s*\{[\s\S]*margin-left:\s*15px/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'shared chart tools component renders the trend tool buttons',
|
||||
/trend-tool-groups[\s\S]*v-for="group in toolGroups"[\s\S]*v-for="item in group\.items"[\s\S]*@click="emit\('tool-action', item\.action\)"/,
|
||||
chartToolsSource
|
||||
],
|
||||
[
|
||||
'fullscreen renders shared trend tool groups',
|
||||
/<SteadyTrendChartTools[\s\S]*:tool-groups="toolGroups"[\s\S]*@tool-action="emit\('tool-action', \$event\)"/,
|
||||
fullscreenSource
|
||||
],
|
||||
[
|
||||
'chart panel exposes core trend toolbar actions except marker and data export',
|
||||
/x-zoom-in[\s\S]*x-zoom-out[\s\S]*y-zoom-in[\s\S]*y-zoom-out[\s\S]*box-zoom[\s\S]*reset[\s\S]*pan[\s\S]*fullscreen[\s\S]*download-image/,
|
||||
chartPanelSource
|
||||
],
|
||||
['chart panel exposes a wheel zoom toggle action', /wheel-zoom/, chartPanelSource],
|
||||
[
|
||||
'chart panel places wheel zoom after pan in the viewport toolbar',
|
||||
/items:\s*\[[\s\S]*action:\s*'pan'[\s\S]*action:\s*'wheel-zoom'/,
|
||||
chartPanelSource
|
||||
],
|
||||
['chart panel defaults wheel zoom disabled', /const\s+wheelZoomEnabled\s*=\s*ref\(false\)/, chartPanelSource],
|
||||
[
|
||||
'chart panel passes wheel zoom state into grouped options',
|
||||
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*wheelZoomEnabled:\s*wheelZoomEnabled\.value/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel keeps full range as fallback x zoom range',
|
||||
/DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*:\s*SteadyTrendZoomRange\s*=\s*\{\s*start:\s*0,\s*end:\s*100\s*\}/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel defaults x zoom range by 20 30 60 day thresholds',
|
||||
/STEADY_TREND_HALF_RANGE_DAYS\s*=\s*20[\s\S]*STEADY_TREND_QUARTER_RANGE_DAYS\s*=\s*30[\s\S]*STEADY_TREND_TENTH_RANGE_DAYS\s*=\s*60[\s\S]*resolveSteadyTrendDefaultZoomRange[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_TENTH_RANGE_DAYS[\s\S]*end:\s*10[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_QUARTER_RANGE_DAYS[\s\S]*end:\s*25[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_HALF_RANGE_DAYS[\s\S]*end:\s*50/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel stores shared x zoom range from the default range',
|
||||
/const trendXZoomRange\s*=\s*ref<SteadyTrendZoomRange>\(\{\s*\.\.\.DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*\}\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel compares reset state against the current data-derived x zoom range',
|
||||
/const defaultTrendXZoomRange\s*=\s*ref<SteadyTrendZoomRange>[\s\S]*const isDefaultTrendXZoomRange[\s\S]*defaultTrendXZoomRange\.value[\s\S]*const canResetTrendChart[\s\S]*!isDefaultTrendXZoomRange\.value/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel stores y zoom scale and active chart tool',
|
||||
/const trendYZoomScale\s*=\s*ref\(1\)[\s\S]*const activeTrendInteractionMode\s*=\s*ref<SteadyTrendInteractionMode>\('none'\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel passes active chart tool and y zoom scale into grouped options',
|
||||
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*activeTool:\s*activeTrendInteractionMode\.value[\s\S]*yZoomScale:\s*trendYZoomScale\.value/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel resets x zoom range to the current data-derived default when trend result changes',
|
||||
/const\s+resetTrendToolState\s*=\s*\(\)\s*=>\s*\{[\s\S]*trendXZoomRange\.value\s*=\s*\{\s*\.\.\.defaultTrendXZoomRange\.value\s*\}[\s\S]*watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*defaultTrendXZoomRange\.value\s*=\s*resolveSteadyTrendDefaultZoomRange\(props\.trendResult\)[\s\S]*resetTrendToolState\(\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel resets toolbar state when trend result changes',
|
||||
/watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*resetTrendToolState\(\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel can export the visible trend charts as an image',
|
||||
/steady-trend-export-target[\s\S]*html2canvas[\s\S]*downloadSteadyTrendImage/,
|
||||
chartPanelSource
|
||||
],
|
||||
['chart utilities expose grouped chart options', /export const buildSteadyTrendChartGroups/, trendOptionsSource],
|
||||
[
|
||||
'chart utilities carry one shared ECharts group for steady multi-chart sync',
|
||||
/group:\s*STEADY_TREND_CHART_GROUP/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart utilities accept shared x zoom range when building options',
|
||||
/buildSteadyTrendChartOptions\s*=\s*\([^)]*zoomRange:\s*SteadyTrendZoomRange/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart utilities accept active tool and y zoom options',
|
||||
/interface\s+SteadyTrendChartBuildOptions[\s\S]*activeTool\?:[\s\S]*yZoomScale\?:/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options expose activeTool for LineChart interactions',
|
||||
/activeTool:\s*chartOptions\.activeTool\s*\|\|\s*'none'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options apply y zoom scale to steady y axis',
|
||||
/applySteadyYAxisZoom\([\s\S]*buildSteadyTrendAxisConfig\(values,\s*unit\)[\s\S]*chartOptions\.yZoomScale/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options bind dataZoom to shared x zoom range',
|
||||
/dataZoom:\s*\[[\s\S]*start:\s*zoomRange\.start[\s\S]*end:\s*zoomRange\.end/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options enlarge slider dataZoom handles for easier horizontal dragging',
|
||||
/handleSize:\s*'300%'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options highlight slider dataZoom handles on hover',
|
||||
/emphasis:\s*\{[\s\S]*handleStyle:\s*\{[\s\S]*borderColor:\s*'#409eff'[\s\S]*shadowBlur:\s*6/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options show pointer cursor on slider dataZoom handles',
|
||||
/handleSize:\s*'300%'[\s\S]*cursor:\s*'pointer'[\s\S]*handleStyle:/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart slider dataZoom only syncs after dragging settles for dense trend charts',
|
||||
/handleSize:\s*'300%'[\s\S]*realtime:\s*false[\s\S]*cursor:\s*'pointer'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'line chart overrides slider dataZoom handle cursor to pointer on hover',
|
||||
/isSliderDataZoomResizeHandle[\s\S]*target\?\.type\s*===\s*'path'[\s\S]*viewportRoot\.style\.cursor\s*=\s*'pointer'/,
|
||||
lineChartSource
|
||||
],
|
||||
[
|
||||
'chart options accept wheel zoom option',
|
||||
/interface\s+SteadyTrendChartBuildOptions[\s\S]*wheelZoomEnabled\?:\s*boolean/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options only zoom on mouse wheel when enabled',
|
||||
/zoomOnMouseWheel:\s*chartOptions\.wheelZoomEnabled\s*===\s*true[\s\S]*moveOnMouseWheel:\s*false/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options keep hover axis pointer as a vertical line',
|
||||
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options hide hover x-axis pointer labels to avoid overlapping time axis labels',
|
||||
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'[\s\S]*label:\s*\{[\s\S]*show:\s*false/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart tooltip avoids heavy immediate transitions on dense trend charts',
|
||||
/tooltip:\s*\{[\s\S]*showDelay:\s*80[\s\S]*hideDelay:\s*80[\s\S]*transitionDuration:\s*0/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options register hidden toolbox dataZoom for external box zoom',
|
||||
/toolbox:\s*\{[\s\S]*show:\s*true[\s\S]*itemSize:\s*0[\s\S]*left:\s*-100[\s\S]*feature:\s*\{[\s\S]*dataZoom:\s*\{[\s\S]*yAxisIndex:\s*'none'[\s\S]*brushStyle:/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options calculate visible point count from shared zoom range',
|
||||
/resolveSteadyTrendVisiblePointCount[\s\S]*zoomRange\.end\s*-\s*zoomRange\.start[\s\S]*Math\.ceil/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart series line width uses visible point count after x zoom',
|
||||
/const\s+pointCount\s*=\s*series\.points\?\.length\s*\|\|\s*0[\s\S]*const\s+visiblePointCount\s*=\s*resolveSteadyTrendVisiblePointCount\(pointCount,\s*zoomRange\)[\s\S]*width:\s*resolveSteadyTrendLineWidth\(visiblePointCount\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart grouping splits by indicator for one monitoring point',
|
||||
/lineIds\.length\s*===\s*1[\s\S]*indicatorCodes\.length\s*>\s*1[\s\S]*'indicator'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart grouping splits by monitoring point for one indicator',
|
||||
/lineIds\.length\s*>\s*1[\s\S]*indicatorCodes\.length\s*===\s*1[\s\S]*'line'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options keep fixed left grid for multi-chart alignment',
|
||||
/left:\s*STEADY_TREND_GRID_LEFT[\s\S]*containLabel:\s*false/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options use compact top grid to reduce title legend height',
|
||||
/STEADY_TREND_GRID_TOP\s*=\s*28[\s\S]*top:\s*STEADY_TREND_GRID_TOP/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart panel does not render external chart title rows',
|
||||
/^(?![\s\S]*<div class="chart-title">)[\s\S]*$/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart options render centered group title through ECharts title',
|
||||
/title:\s*\{[\s\S]*text:\s*chartTitle[\s\S]*left:\s*'center'[\s\S]*top:\s*0/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart grouping passes resolved title into ECharts options',
|
||||
/const\s+groupTitle\s*=\s*resolveGroupTitle\(groupSeries\)[\s\S]*buildSteadyTrendChartOptions\(groupSeries,\s*zoomRange,\s*isLastChart,\s*chartOptions,\s*groupTitle\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options explicitly show min and max y labels',
|
||||
/showMinLabel:\s*true[\s\S]*showMaxLabel:\s*true/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart y axis keeps waveform unit placement style without changing unit content',
|
||||
/nameLocation:\s*'middle'[\s\S]*nameGap:\s*42[\s\S]*nameTextStyle:/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart x axis formats time axis labels by range span',
|
||||
/STEADY_TREND_ONE_DAY_MS[\s\S]*buildSteadyTimeAxisLabelFormatter[\s\S]*isRangeOverOneDay[\s\S]*formatSteadyTimeAxisDailyLabel[\s\S]*formatSteadyTimeAxisHourMinuteLabel/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart x axis keeps only the first year for same-year daily labels',
|
||||
/formatSteadyTimeAxisDailyLabel[\s\S]*date\.getFullYear\(\)\s*===\s*firstYear[\s\S]*formatSteadyTimeAxisShortDateLabel[\s\S]*const\s+firstYear/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart x axis first label uses rich padding to avoid touching the y axis line',
|
||||
/formatSteadyTimeAxisFirstLabel[\s\S]*return `\{first\|\$\{label\}\}`[\s\S]*rich:\s*\{[\s\S]*first:\s*\{[\s\S]*padding:\s*\[0,\s*0,\s*0,\s*6\]/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart x axis label layout follows waveform except unit display',
|
||||
/axisLabel:\s*\{[\s\S]*hideOverlap:\s*true[\s\S]*showMinLabel:\s*true[\s\S]*showMaxLabel:\s*false[\s\S]*margin:\s*showTimeAxis\s*\?\s*16\s*:\s*0[\s\S]*alignMinLabel:\s*'left'[\s\S]*alignMaxLabel:\s*'right'[\s\S]*width:\s*72[\s\S]*overflow:\s*'truncate'[\s\S]*formatter:\s*buildSteadyTimeAxisLabelFormatter\(timeRange\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart x axis label moves down without increasing bottom grid space',
|
||||
/bottom:\s*showTimeAxis\s*\?\s*40\s*:\s*8[\s\S]*axisLabel:\s*\{[\s\S]*margin:\s*showTimeAxis\s*\?\s*16\s*:\s*0/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options allow hiding the x axis for non-final grouped charts',
|
||||
/buildSteadyTrendChartOptions\s*=\s*\([^)]*showTimeAxis\s*=\s*true/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart x axis keeps labels visible but hides baseline and dense ticks',
|
||||
/axisLine:\s*\{[\s\S]*show:\s*false[\s\S]*axisLabel:\s*\{[\s\S]*show:\s*showTimeAxis[\s\S]*axisTick:\s*\{[\s\S]*show:\s*false/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart grouping only shows the x axis on the final grouped chart',
|
||||
/const\s+groupEntries\s*=\s*Array\.from\(groupMap\.entries\(\)\)[\s\S]*const\s+isLastChart\s*=\s*index\s*===\s*groupEntries\.length\s*-\s*1[\s\S]*buildSteadyTrendChartOptions\(groupSeries,\s*zoomRange,\s*isLastChart,\s*chartOptions,\s*groupTitle\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options use waveform line width buckets including the final three widths',
|
||||
/resolveSteadyTrendLineWidth[\s\S]*200000\)\s*return\s*0\.35[\s\S]*100000\)\s*return\s*0\.45[\s\S]*50000\)\s*return\s*0\.55[\s\S]*20000\)\s*return\s*0\.65[\s\S]*10000\)\s*return\s*0\.75[\s\S]*5000\)\s*return\s*0\.9[\s\S]*2000\)\s*return\s*1[\s\S]*800\)\s*return\s*1\.1[\s\S]*200\)\s*return\s*1\.2[\s\S]*return\s*STEADY_TREND_LINE_MAX_WIDTH/,
|
||||
trendOptionsSource
|
||||
],
|
||||
['chart options read phase colors from shared theme utility', /resolvePhaseThemeColor/, trendOptionsSource],
|
||||
[
|
||||
'chart legend combines harmonic order and phase when harmonic order exists',
|
||||
/const formatSeriesName[\s\S]*const harmonicOrder = resolveHarmonicOrder\(series\)[\s\S]*return harmonicOrder \? `\$\{harmonicOrder\}次_\$\{phaseLabel\}` : phaseLabel/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart series are sorted by harmonic order before phase so same orders stay together in the legend',
|
||||
/const sortSteadyTrendSeries[\s\S]*resolveHarmonicOrder\(left\.series\)[\s\S]*resolvePhaseOrder\(left\.series\)[\s\S]*const sortedSeriesList = sortSteadyTrendSeries\((?:seriesList|displaySeriesList)\)[\s\S]*series: sortedSeriesList\.map/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options keep phase colors while styling harmonic orders by line type',
|
||||
/lineStyle:\s*\{[\s\S]*type:\s*resolveHarmonicLineType\(series,\s*pointCount\)[\s\S]*opacity:\s*resolveHarmonicLineOpacity\(series\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options force solid harmonic lines for large point counts',
|
||||
/STEADY_TREND_LARGE_POINT_COUNT\s*=\s*20000[\s\S]*resolveHarmonicLineType[\s\S]*pointCount\s*>=\s*STEADY_TREND_LARGE_POINT_COUNT[\s\S]*return 'solid'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart series disable animation and use lttb sampling for large point counts',
|
||||
/resolveSteadyTrendSampling[\s\S]*pointCount\s*>=\s*STEADY_TREND_LARGE_POINT_COUNT\s*\?\s*'lttb'[\s\S]*animation:\s*false[\s\S]*sampling:\s*resolveSteadyTrendSampling\(pointCount,\s*visiblePointCount\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart disables lttb sampling after x zoom reduces visible points',
|
||||
/STEADY_TREND_RAW_VISIBLE_POINT_COUNT[\s\S]*resolveSteadyTrendSampling\s*=\s*\([^)]*visiblePointCount[\s\S]*visiblePointCount\s*<=\s*STEADY_TREND_RAW_VISIBLE_POINT_COUNT[\s\S]*return undefined[\s\S]*const\s+visiblePointCount\s*=\s*resolveSteadyTrendVisiblePointCount\(pointCount,\s*zoomRange\)[\s\S]*sampling:\s*resolveSteadyTrendSampling\(pointCount,\s*visiblePointCount\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart group title joins monitoring point and indicator with underscore',
|
||||
/\[firstSeries\.lineName[\s\S]*firstSeries\.indicatorName[\s\S]*\][\s\S]*\.filter\(Boolean\)[\s\S]*\.join\('_'\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis above-one upper padding uses 1.05',
|
||||
/STEADY_AXIS_EXPAND_RATIO_ABOVE_ONE\s*=\s*1\.05/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis above-one lower padding uses 0.95',
|
||||
/STEADY_AXIS_SHRINK_RATIO_ABOVE_ONE\s*=\s*0\.95/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis compact padding uses 1.015 for narrow above-one ranges',
|
||||
/STEADY_AXIS_COMPACT_EXPAND_RATIO\s*=\s*1\.015/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis compact padding uses 0.985 for narrow above-one ranges',
|
||||
/STEADY_AXIS_COMPACT_SHRINK_RATIO\s*=\s*0\.985/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis compact split penalty stays low',
|
||||
/STEADY_AXIS_COMPACT_EXTRA_SPLIT_SCORE\s*=\s*0\.05/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis enables compact readable range for narrow above-one data',
|
||||
/shouldUseCompactReadableAxisRange[\s\S]*maxAbs\s*>\s*1[\s\S]*STEADY_AXIS_COMPACT_RANGE_RATIO/,
|
||||
trendOptionsSource
|
||||
],
|
||||
['shared phase colors read the global T phase theme variable', /T:\s*'--cn-color-phase-t'/, sharedPhaseColorSource],
|
||||
['shared phase colors keep T phase black fallback', /T:\s*'#000000'/, sharedPhaseColorSource],
|
||||
['shared phase colors map AB line voltage to phase A color', /AB:\s*'--cn-color-phase-ab'/, sharedPhaseColorSource],
|
||||
['shared phase colors map BC line voltage to phase B color', /BC:\s*'--cn-color-phase-bc'/, sharedPhaseColorSource],
|
||||
['shared phase colors map CA line voltage to phase C color', /CA:\s*'--cn-color-phase-ca'/, sharedPhaseColorSource],
|
||||
['shared phase colors keep AB fallback aligned with phase A', /AB:\s*'#daa520'/, sharedPhaseColorSource],
|
||||
['shared phase colors keep BC fallback aligned with phase B', /BC:\s*'#2e8b57'/, sharedPhaseColorSource],
|
||||
['shared phase colors keep CA fallback aligned with phase C', /CA:\s*'#a52a2a'/, sharedPhaseColorSource],
|
||||
['global style defines AB line voltage color variable', /--cn-color-phase-ab:\s*#daa520/, sharedStyleSource],
|
||||
['global style defines BC line voltage color variable', /--cn-color-phase-bc:\s*#2e8b57/, sharedStyleSource],
|
||||
['global style defines CA line voltage color variable', /--cn-color-phase-ca:\s*#a52a2a/, sharedStyleSource]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern, source]) => !pattern.test(source))
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView chart display contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView chart display contract passed')
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.resolve(currentDir, '../index.vue')
|
||||
const chartPanelFile = path.resolve(currentDir, '../components/SteadyTrendChartPanel.vue')
|
||||
const trendOptionsFile = path.resolve(currentDir, '../utils/trendOptions.ts')
|
||||
const payloadFile = path.resolve(currentDir, '../utils/trendPayload.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../../api/steady/steadyDataView/interface/index.ts')
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8')
|
||||
|
||||
const pageSource = read(pageFile)
|
||||
const chartPanelSource = read(chartPanelFile)
|
||||
const trendOptionsSource = read(trendOptionsFile)
|
||||
const payloadSource = read(payloadFile)
|
||||
const interfaceSource = read(interfaceFile)
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'steadyDataView imports the day query endpoint for chunked loading',
|
||||
/import\s*\{[^}]*querySteadyTrendDay[^}]*\}\s*from\s*'@\/api\/steady\/steadyDataView'/,
|
||||
pageSource
|
||||
],
|
||||
[
|
||||
'steadyDataView routes long ranges through chunked trend loading',
|
||||
/isSteadyTrendRangeOverChunkLimit\(payload\.timeStart,\s*payload\.timeEnd\)[\s\S]*querySteadyTrendInChunks/,
|
||||
pageSource
|
||||
],
|
||||
[
|
||||
'steadyDataView prevents stale chunk responses from overwriting newer queries',
|
||||
/trendQuerySerial[\s\S]*currentQuerySerial[\s\S]*currentQuerySerial\s*!==\s*trendQuerySerial/,
|
||||
pageSource
|
||||
],
|
||||
['chunk helper uses a three day maximum window', /STEADY_TREND_CHUNK_DAYS\s*=\s*3/, payloadSource],
|
||||
[
|
||||
'long range chunk query initializes a full time range result before data arrives',
|
||||
/buildEmptySteadyTrendQueryResult\(payload\.timeStart,\s*payload\.timeEnd\)/,
|
||||
pageSource
|
||||
],
|
||||
[
|
||||
'long range chunk query avoids trend loading overlay',
|
||||
/loading\.trend\s*=\s*!isSteadyTrendRangeOverChunkLimit\(payload\.timeStart,\s*payload\.timeEnd\)/,
|
||||
pageSource
|
||||
],
|
||||
['chunk helper exports a long range predicate', /export\s+const\s+isSteadyTrendRangeOverChunkLimit/, payloadSource],
|
||||
['chunk helper exports query chunk builder', /export\s+const\s+buildSteadyTrendQueryChunks/, payloadSource],
|
||||
['chunk helper exports incremental result merger', /export\s+const\s+mergeSteadyTrendQueryResult/, payloadSource],
|
||||
[
|
||||
'chunk helper exports empty range result builder',
|
||||
/export\s+const\s+buildEmptySteadyTrendQueryResult/,
|
||||
payloadSource
|
||||
],
|
||||
['chunk helper exports no data predicate', /export\s+const\s+hasSteadyTrendResultData/, payloadSource],
|
||||
[
|
||||
'trend result carries full query time range metadata',
|
||||
/queryTimeStart\?:\s*string[\s\S]*queryTimeEnd\?:\s*string/,
|
||||
interfaceSource
|
||||
],
|
||||
['chart panel enables missing data by default', /const\s+missingDataEnabled\s*=\s*ref\(true\)/, chartPanelSource],
|
||||
[
|
||||
'chart panel shows no data state after completed empty query',
|
||||
/hasQueriedWithoutData[\s\S]*description="暂无数据"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart options use full query time range for x axis min and max',
|
||||
/queryTimeStart[\s\S]*queryTimeEnd[\s\S]*xAxis[\s\S]*min:[\s\S]*max:/,
|
||||
trendOptionsSource
|
||||
]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([_name, pattern, source]) => !pattern.test(source))
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView chunked query contract failed:')
|
||||
failures.forEach(([name]) => console.error(`- ${name}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView chunked query contract passed')
|
||||
@@ -0,0 +1,96 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewDir = path.join(currentDir, '..')
|
||||
const read = file => {
|
||||
const targetFile = path.join(viewDir, file)
|
||||
|
||||
return fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : ''
|
||||
}
|
||||
|
||||
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
|
||||
const dialogSource = read('components/SteadyTrendDataTableDialog.vue')
|
||||
const tableUtilSource = read('utils/trendTable.ts')
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
'chart panel exposes data query action in the existing trend tool groups',
|
||||
/trendToolGroups[\s\S]*query-data[\s\S]*handleTrendToolAction/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel opens data table dialog from current trend result',
|
||||
/SteadyTrendDataTableDialog[\s\S]*v-model="dataTableVisible"[\s\S]*:trend-result="trendResult"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel warns before opening data table without series data',
|
||||
/case\s+'query-data'[\s\S]*if\s*\(!hasSeries\.value\)[\s\S]*ElMessage\.warning/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'data table dialog follows dictdata table-main card table-header structure',
|
||||
/class="table-main card steady-trend-data-table"[\s\S]*class="table-header"[\s\S]*class="header-button-lf"[\s\S]*class="header-button-ri"/,
|
||||
dialogSource
|
||||
],
|
||||
[
|
||||
'data table dialog provides full Excel download action on the left header',
|
||||
/class="header-button-lf"[\s\S]*type="primary"[\s\S]*:icon="Download"[\s\S]*plain[\s\S]*downloadSteadyTrendData/,
|
||||
dialogSource
|
||||
],
|
||||
['data table dialog no longer renders refresh button', /:icon="Refresh"|refreshKey/, dialogSource, true],
|
||||
[
|
||||
'data table dialog renders fixed common time column',
|
||||
/<el-table-column\s+prop="time"[\s\S]*fixed="left"/,
|
||||
dialogSource
|
||||
],
|
||||
[
|
||||
'data table dialog renders monitoring point then indicator then phase columns',
|
||||
/v-for="lineGroup in tableModel\.lineGroups"[\s\S]*v-for="indicatorGroup in lineGroup\.indicatorGroups"[\s\S]*v-for="column in indicatorGroup\.columns"/,
|
||||
dialogSource
|
||||
],
|
||||
[
|
||||
'data table dialog renders paged rows instead of full model rows',
|
||||
/:data="pagedRows"[\s\S]*<el-pagination[\s\S]*v-model:current-page="currentPage"[\s\S]*v-model:page-size="pageSize"/,
|
||||
dialogSource
|
||||
],
|
||||
['data table dialog defaults to 500 rows per page', /const\s+pageSize\s*=\s*ref\(500\)/, dialogSource],
|
||||
['data table dialog uses missing value placeholder', /row\[column\.prop\]\s*\?\?\s*'-'/, dialogSource],
|
||||
[
|
||||
'trend table utility carries indicator units into labels',
|
||||
/resolveIndicatorLabel[\s\S]*series\.unit[\s\S]*unit/,
|
||||
tableUtilSource
|
||||
],
|
||||
[
|
||||
'trend table utility groups columns by monitoring point before indicator',
|
||||
/lineGroupMap[\s\S]*indicatorGroupMap[\s\S]*columns\.push/,
|
||||
tableUtilSource
|
||||
],
|
||||
[
|
||||
'trend table utility aligns rows by shared sorted time values without building all rows eagerly',
|
||||
/timeValues:\s*Array\.from\(timeSet\)[\s\S]*\.sort\(\)[\s\S]*buildSteadyTrendTableRows/,
|
||||
tableUtilSource
|
||||
],
|
||||
[
|
||||
'trend table utility builds full Excel html export',
|
||||
/colspan="\$\{lineColspan\}"[\s\S]*rowspan="3"[\s\S]*buildSteadyTrendTableRows\(model,\s*0,\s*model\.timeValues\.length\)[\s\S]*buildSteadyTrendExcelHtml/,
|
||||
tableUtilSource
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern, source, forbidden]) =>
|
||||
forbidden ? pattern.test(source) : !pattern.test(source)
|
||||
)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView data table contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView data table contract passed')
|
||||
@@ -0,0 +1,26 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const toolbarFile = path.resolve(currentDir, '../components/SteadyTrendToolbar.vue')
|
||||
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
|
||||
const harmonicSelectSource = toolbarSource.match(
|
||||
/<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">[\s\S]*?<\/div>/
|
||||
)?.[0] || ''
|
||||
|
||||
const checks = [
|
||||
['harmonic order select exists', /class="toolbar-field harmonic-select"/],
|
||||
['harmonic order select keeps multiple mode', /<el-select[\s\S]*multiple/],
|
||||
['harmonic order select shows every selected tag', /^(?![\s\S]*collapse-tags)[\s\S]*$/]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, pattern]) => !pattern.test(harmonicSelectSource)).map(([message]) => message)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('steadyDataView harmonic tags contract failed:')
|
||||
failed.forEach(message => console.error(`- ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView harmonic tags contract passed')
|
||||
@@ -0,0 +1,64 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const componentDir = path.join(currentDir, '..', 'components')
|
||||
|
||||
const read = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||
const ledgerTreeSource = read('SteadyLedgerTree.vue')
|
||||
const workbenchSource = read('SteadyTrendWorkbench.vue')
|
||||
|
||||
const expectations = [
|
||||
['ledger panel title is renamed to device tree', /<span class="panel-title">设备树<\/span>/, ledgerTreeSource],
|
||||
['ledger panel no longer renders old title', !/台账监测点/.test(ledgerTreeSource), ledgerTreeSource],
|
||||
[
|
||||
'ledger refresh button is placed in the search row after the input',
|
||||
/<div class="tree-search-row">[\s\S]*<el-input[\s\S]*<\/el-input>[\s\S]*<el-button[\s\S]*@click="emit\('refresh'\)"[\s\S]*<\/div>/,
|
||||
ledgerTreeSource
|
||||
],
|
||||
['ledger panel receives collapsed state', /collapsed:\s*boolean/, ledgerTreeSource],
|
||||
['ledger panel emits toggle event', /toggle:\s*\[\]/, ledgerTreeSource],
|
||||
['ledger panel renders internal collapse or expand button', /@click="emit\('toggle'\)"/, ledgerTreeSource],
|
||||
[
|
||||
'ledger collapse buttons use primary theme color',
|
||||
/class="panel-toggle"[\s\S]*type="primary"[\s\S]*@click="emit\('toggle'\)"/,
|
||||
ledgerTreeSource
|
||||
],
|
||||
['workbench passes collapsed state to ledger tree', /:collapsed="ledgerPanelCollapsedProxy"/, workbenchSource],
|
||||
['workbench wires ledger tree toggle event', /@toggle="emit\('update:ledgerPanelCollapsed', !ledgerPanelCollapsedProxy\)"/, workbenchSource],
|
||||
['workbench no longer renders external ledger toggle button', !/class="ledger-toggle"/.test(workbenchSource), workbenchSource],
|
||||
['workbench collapsed ledger column does not reserve button width', /grid-template-columns:\s*0\s+minmax\(0,\s*1fr\)/, workbenchSource],
|
||||
['workbench selector column passes full height to the ledger card', /\.selector-column\s*{[^}]*height:\s*100%/, workbenchSource],
|
||||
['workbench ledger panel body passes full height to the ledger card', /\.ledger-panel-body\s*{[^}]*height:\s*100%/, workbenchSource],
|
||||
[
|
||||
'workbench selector column no longer reserves an external toggle column',
|
||||
!/grid-template-columns:\s*minmax\(0,\s*1fr\)\s+36px/.test(workbenchSource),
|
||||
workbenchSource
|
||||
],
|
||||
[
|
||||
'ledger collapsed state only floats the expand button',
|
||||
/\.steady-tree-card\.is-collapsed[\s\S]*position:\s*absolute[\s\S]*width:\s*36px[\s\S]*height:\s*36px/,
|
||||
ledgerTreeSource
|
||||
],
|
||||
[
|
||||
'expanded ledger panel remains in normal layout',
|
||||
/\.steady-tree-card:not\(\.is-collapsed\)[\s\S]*height:\s*100%/,
|
||||
ledgerTreeSource
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, expectation, source]) => {
|
||||
return expectation instanceof RegExp ? !expectation.test(source) : !expectation
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView ledger panel layout contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView ledger panel layout contract passed')
|
||||
@@ -0,0 +1,187 @@
|
||||
/* 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 aliasedNodes = [
|
||||
{ engineeringId: 'engineering-alias', engineeringName: '别名工程', level: 0, deviceCount: 1, lineCount: 1 },
|
||||
{
|
||||
projectId: 'project-alias',
|
||||
parentId: 'engineering-alias',
|
||||
projectName: '别名项目',
|
||||
level: 1,
|
||||
deviceCount: 1,
|
||||
lineCount: 1
|
||||
},
|
||||
{
|
||||
equipmentId: 'device-alias',
|
||||
parentId: 'project-alias',
|
||||
equipmentName: '别名设备',
|
||||
level: 2,
|
||||
lineCount: 1
|
||||
},
|
||||
{
|
||||
lineId: 'line-alias',
|
||||
deviceId: 'device-alias',
|
||||
lineName: '别名监测点',
|
||||
level: 3,
|
||||
selectable: true
|
||||
}
|
||||
]
|
||||
|
||||
const snakeCaseNodes = [
|
||||
{
|
||||
engineering_id: 'engineering-snake',
|
||||
engineering_name: 'Snake Engineering',
|
||||
level: 0,
|
||||
equipment_count: 1,
|
||||
monitor_count: 1,
|
||||
childrenList: [
|
||||
{
|
||||
project_id: 'project-snake',
|
||||
parent_id: 'engineering-snake',
|
||||
project_name: 'Snake Project',
|
||||
level: 1,
|
||||
equipment_count: 1,
|
||||
monitor_count: 1,
|
||||
childrenList: [
|
||||
{
|
||||
device_id: 'device-snake',
|
||||
parent_id: 'project-snake',
|
||||
device_name: 'Snake Device',
|
||||
level: 2,
|
||||
monitor_count: 1,
|
||||
childrenList: [
|
||||
{
|
||||
line_id: 'line-snake',
|
||||
device_id: 'device-snake',
|
||||
line_name: 'Snake Line',
|
||||
level: 3,
|
||||
selectable: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const normalized = normalizeSteadyLedgerTree(flatNodes)
|
||||
const expectedPath = normalized[0]?.children?.[0]?.children?.[0]?.children?.map(item => item.name)
|
||||
const aliasedNormalized = normalizeSteadyLedgerTree(aliasedNodes)
|
||||
const aliasedLine = aliasedNormalized[0]?.children?.[0]?.children?.[0]?.children?.[0]
|
||||
const snakeCaseNormalized = normalizeSteadyLedgerTree(snakeCaseNodes)
|
||||
const snakeCaseDevice = snakeCaseNormalized[0]?.children?.[0]?.children?.[0]
|
||||
const snakeCaseLine = snakeCaseDevice?.children?.[0]
|
||||
|
||||
const expectations = [
|
||||
['flat nodes rebuild to one root engineering node', normalized.length === 1 && normalized[0].id === 'engineering-1'],
|
||||
['project is nested under engineering', normalized[0]?.children?.[0]?.id === 'project-1'],
|
||||
['device is nested under project', normalized[0]?.children?.[0]?.children?.[0]?.id === 'device-1'],
|
||||
['lines are nested under device', JSON.stringify(expectedPath) === JSON.stringify(['监测点_1', '监测点_2'])],
|
||||
[
|
||||
'line nodes keep selectable query identity',
|
||||
normalized[0]?.children?.[0]?.children?.[0]?.children?.every(item => item.selectable && item.level === 3)
|
||||
],
|
||||
[
|
||||
'unselectable line nodes are removed from steady query tree',
|
||||
!normalized[0]?.children?.[0]?.children?.[0]?.children?.some(item => item.id === 'line-disabled')
|
||||
],
|
||||
[
|
||||
'backend alias fields keep monitor point nodes',
|
||||
aliasedLine?.id === 'line-alias' && aliasedLine?.name === '别名监测点' && aliasedLine?.parentId === 'device-alias'
|
||||
],
|
||||
[
|
||||
'snake case backend fields and childrenList keep the full ledger hierarchy',
|
||||
snakeCaseNormalized[0]?.id === 'engineering-snake' &&
|
||||
snakeCaseNormalized[0]?.deviceCount === 1 &&
|
||||
snakeCaseNormalized[0]?.lineCount === 1 &&
|
||||
snakeCaseDevice?.id === 'device-snake' &&
|
||||
snakeCaseDevice?.lineCount === 1 &&
|
||||
snakeCaseLine?.id === 'line-snake' &&
|
||||
snakeCaseLine?.name === 'Snake Line' &&
|
||||
snakeCaseLine?.parentId === 'device-snake'
|
||||
],
|
||||
[
|
||||
'ledger tree component hides count text on monitor point leaves',
|
||||
/shouldShowLedgerCount[\s\S]*Number\(data\.level\)\s*<\s*3/.test(read(ledgerTreeComponentFile))
|
||||
],
|
||||
['page uses normalized ledger tree data', /normalizeSteadyLedgerTree/.test(read(pageFile))],
|
||||
[
|
||||
'page assigns normalized ledger tree data outside comments',
|
||||
read(pageFile)
|
||||
.split(/\r?\n/)
|
||||
.some(line => /^\s*ledgerTree\.value\s*=\s*normalizeSteadyLedgerTree/.test(line))
|
||||
],
|
||||
[
|
||||
'page assigns default selected ledger node outside comments',
|
||||
read(pageFile)
|
||||
.split(/\r?\n/)
|
||||
.some(line => /^\s*selectedLedgerNodes\.value\s*=\s*firstLedgerNode/.test(line))
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, matched]) => !matched)
|
||||
|
||||
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')
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const chartPanelFile = path.resolve(currentDir, '../components/SteadyTrendChartPanel.vue')
|
||||
const trendOptionsFile = path.resolve(currentDir, '../utils/trendOptions.ts')
|
||||
|
||||
const chartPanelSource = fs.readFileSync(chartPanelFile, 'utf8')
|
||||
const trendOptionsSource = fs.readFileSync(trendOptionsFile, 'utf8')
|
||||
|
||||
const checks = [
|
||||
['chart panel defines missing data tool action', /'missing-data'/, chartPanelSource],
|
||||
['chart panel labels missing data action', /action:\s*'missing-data'[\s\S]*label:/, chartPanelSource],
|
||||
[
|
||||
'chart panel defaults missing data enabled for every query',
|
||||
/const\s+missingDataEnabled\s*=\s*ref\(true\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel marks missing data action active only when enabled',
|
||||
/action\s*===\s*'missing-data'[\s\S]*return\s+missingDataEnabled\.value/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel passes missing data state into chart options',
|
||||
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*showMissingData:\s*missingDataEnabled\.value/,
|
||||
chartPanelSource
|
||||
],
|
||||
['chart options accept missing data option', /showMissingData\?:\s*boolean/, trendOptionsSource],
|
||||
[
|
||||
'chart options only fills missing data when enabled',
|
||||
/chartOptions\.showMissingData\s*===\s*true\s*\?\s*fillSteadyTrendMissingPoints\(/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'missing data filler inserts null values',
|
||||
/fillSteadyTrendMissingPoints[\s\S]*value:[\s\S]*:\s*null/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'missing data filler infers interval from existing time gaps',
|
||||
/resolveSteadyTrendPointIntervalMs[\s\S]*gaps[\s\S]*Math\.min/,
|
||||
trendOptionsSource
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, pattern, source]) => !pattern.test(source)).map(([message]) => message)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('steadyDataView missing data contract failed:')
|
||||
failed.forEach(message => console.error(`- ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView missing data contract passed')
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const toolbarFile = path.resolve(currentDir, '../components/SteadyTrendToolbar.vue')
|
||||
const payloadFile = path.resolve(currentDir, '../utils/trendPayload.ts')
|
||||
|
||||
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
|
||||
const payloadSource = fs.readFileSync(payloadFile, 'utf8')
|
||||
|
||||
const checks = [
|
||||
['toolbar labels the filter as data quality', /toolbar-field__label">数据质量:<\/span>/],
|
||||
['toolbar renders quality flag with switch', /<el-switch[\s\S]*:model-value="modelValue\.qualityFlag \?\? 0"/],
|
||||
['quality switch maps valid data to zero', /active-text="有效"[\s\S]*:active-value="0"/],
|
||||
['quality switch maps invalid data to one', /inactive-text="无效"[\s\S]*:inactive-value="1"/],
|
||||
['quality switch updates qualityFlag', /@update:model-value="handleQualityFlagChange"/],
|
||||
['quality switch reserves enough prompt width', /\.quality-switch\s*\{[\s\S]*min-width:\s*72px/],
|
||||
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
|
||||
['utilities send quality flag in trend query payload', /qualityFlag:\s*formState\.qualityFlag/]
|
||||
]
|
||||
|
||||
const failed = checks
|
||||
.filter(([, pattern], index) => !pattern.test(index < 6 ? toolbarSource : payloadSource))
|
||||
.map(([message]) => message)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('steadyDataView quality switch contract failed:')
|
||||
failed.forEach(message => console.error(`- ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView quality switch contract passed')
|
||||
@@ -0,0 +1,48 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageSource = fs.readFileSync(path.join(currentDir, '..', 'index.vue'), 'utf8')
|
||||
|
||||
const readFunctionBody = name => {
|
||||
const match = pageSource.match(
|
||||
new RegExp(`const\\s+${name}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{([\\s\\S]*?)\\n\\}`)
|
||||
)
|
||||
|
||||
return match?.[1] || ''
|
||||
}
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
'ledger selection change keeps current trend result until next query',
|
||||
source => !/trendResult\.value\s*=\s*null/.test(source),
|
||||
readFunctionBody('handleLedgerChange')
|
||||
],
|
||||
[
|
||||
'indicator selection change keeps current trend result until next query',
|
||||
source => !/trendResult\.value\s*=\s*null/.test(source),
|
||||
readFunctionBody('handleIndicatorChange')
|
||||
],
|
||||
[
|
||||
'new trend query clears stale trend result before deciding loading mode',
|
||||
source =>
|
||||
/trendResult\.value\s*=\s*null[\s\S]*useChunkedQuery[\s\S]*loading\.trend\s*=\s*!isSteadyTrendRangeOverChunkLimit/.test(
|
||||
source
|
||||
),
|
||||
readFunctionBody('handleQueryTrend')
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, check, source]) => !check(source))
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView query state contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView query state contract passed')
|
||||
@@ -4,7 +4,7 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const srcRoot = path.resolve(currentDir, '../../..')
|
||||
const srcRoot = path.resolve(currentDir, '../../../..')
|
||||
const staticRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/staticRouter.ts'), 'utf8')
|
||||
const dynamicRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/dynamicRouter.ts'), 'utf8')
|
||||
const authStore = fs.readFileSync(path.join(srcRoot, 'stores/modules/auth.ts'), 'utf8')
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const componentDir = path.join(currentDir, '..', 'components')
|
||||
|
||||
const read = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||
const pageSource = fs.readFileSync(path.join(currentDir, '..', 'index.vue'), 'utf8')
|
||||
const selectionRulesSource = fs.readFileSync(path.join(currentDir, '..', 'utils', 'selectionRules.ts'), 'utf8')
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
'ledger tree excludes half-checked parents when collecting checked nodes',
|
||||
/getCheckedNodes\(\s*false\s*,\s*false\s*\)/,
|
||||
read('SteadyLedgerTree.vue')
|
||||
],
|
||||
[
|
||||
'indicator tree excludes half-checked parents when collecting checked nodes',
|
||||
/getCheckedNodes\(\s*false\s*,\s*false\s*\)/,
|
||||
read('SteadyIndicatorTree.vue')
|
||||
],
|
||||
[
|
||||
'selection rules expose the first selectable ledger resolver',
|
||||
/export const findFirstSelectableLedgerNode/,
|
||||
selectionRulesSource
|
||||
],
|
||||
[
|
||||
'selection rules expose the first leaf indicator resolver',
|
||||
/export const findFirstLeafIndicator/,
|
||||
selectionRulesSource
|
||||
],
|
||||
[
|
||||
'steady page applies default selected ledger keys after ledger tree load',
|
||||
/defaultLedgerCheckedKeys\.value\s*=/,
|
||||
pageSource
|
||||
],
|
||||
[
|
||||
'steady page applies default selected indicator keys after indicator tree load',
|
||||
/defaultIndicatorCheckedKeys\.value\s*=/,
|
||||
pageSource
|
||||
],
|
||||
['ledger tree receives default checked keys', /defaultCheckedKeys/, read('SteadyLedgerTree.vue')],
|
||||
[
|
||||
'ledger tree renders level icons',
|
||||
/<component\s+:is="resolveLedgerIcon\(data\.level\)"/,
|
||||
read('SteadyLedgerTree.vue')
|
||||
],
|
||||
[
|
||||
'ledger tree resolves icons by ledger level',
|
||||
/const\s+ledgerIcons[\s\S]*0:[\s\S]*1:[\s\S]*2:[\s\S]*3:/,
|
||||
read('SteadyLedgerTree.vue')
|
||||
],
|
||||
['indicator tree receives default checked keys', /defaultCheckedKeys/, read('SteadyIndicatorTree.vue')],
|
||||
[
|
||||
'selection rules dedupe leaf indicators collected from checked parents and children',
|
||||
/seenIndicatorKeys[\s\S]*node\.indicatorCode[\s\S]*seenIndicatorKeys\.has[\s\S]*seenIndicatorKeys\.add/,
|
||||
selectionRulesSource
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern, source]) => !pattern.test(source))
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView selection contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView selection contract passed')
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(currentDir, '../../../..')
|
||||
|
||||
const files = {
|
||||
staticRouter: path.resolve(rootDir, 'routers/modules/staticRouter.ts'),
|
||||
dynamicRouter: path.resolve(rootDir, 'routers/modules/dynamicRouter.ts'),
|
||||
authStore: path.resolve(rootDir, 'stores/modules/auth.ts'),
|
||||
page: path.resolve(rootDir, 'views/steady/steadyTrend/index.vue'),
|
||||
api: path.resolve(rootDir, 'api/steady/steadyTrend/index.ts'),
|
||||
apiInterface: path.resolve(rootDir, 'api/steady/steadyTrend/interface/index.ts')
|
||||
}
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8')
|
||||
const exists = file => fs.existsSync(file)
|
||||
|
||||
const checks = [
|
||||
['steadyTrend page exists', () => exists(files.page)],
|
||||
['steadyTrend API exists', () => exists(files.api)],
|
||||
['steadyTrend API interface exists', () => exists(files.apiInterface)],
|
||||
['static router registers /steadyTrend/index', () => /path:\s*'\/steadyTrend\/index'/.test(read(files.staticRouter))],
|
||||
['static route name is steadyTrend', () => /name:\s*'steadyTrend'/.test(read(files.staticRouter))],
|
||||
['static router imports steadyTrend page', () => /@\/views\/steady\/steadyTrend\/index\.vue/.test(read(files.staticRouter))],
|
||||
[
|
||||
'dynamic router aliases steady-trend to steadyTrend',
|
||||
() => /\/steady\/steady-trend[\s\S]*\/steady\/steadyTrend/.test(read(files.dynamicRouter))
|
||||
],
|
||||
[
|
||||
'dynamic router keeps steadyTrend static route from being overwritten',
|
||||
() => /STATIC_ROUTE_NAMES[\s\S]*'steadyTrend'/.test(read(files.dynamicRouter))
|
||||
],
|
||||
[
|
||||
'auth normalizes backend steadyTrend menu to static entry',
|
||||
() => /isSteadyTrendMenu[\s\S]*menu\.path\s*=\s*'\/steadyTrend\/index'/.test(read(files.authStore))
|
||||
],
|
||||
[
|
||||
'business menu path resolver handles steadyTrend',
|
||||
() => /isSteadyTrendMenu\(menu\)[\s\S]*return\s+'\/steadyTrend\/index'/.test(read(files.authStore))
|
||||
]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyTrend route contract failed:')
|
||||
failures.forEach(name => console.error(`- ${name}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyTrend route contract passed')
|
||||
@@ -0,0 +1,67 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewDir = path.join(currentDir, '..')
|
||||
const read = file => fs.readFileSync(path.join(viewDir, file), 'utf8')
|
||||
|
||||
const trendOptionsSource = read('utils/trendOptions.ts')
|
||||
const lineChartSource = fs.readFileSync(
|
||||
path.join(viewDir, '..', '..', '..', 'components', 'echarts', 'line', 'index.vue'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'steady trend x axis uses ECharts time axis',
|
||||
/xAxis:\s*\{[\s\S]*type:\s*'time'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady trend chart no longer builds global timeLabels for default rendering',
|
||||
/^(?![\s\S]*const\s+timeLabels\s*=\s*Array\.from[\s\S]*new Set\(displaySeriesList\.flatMap)[\s\S]*$/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady trend series passes timestamp value pairs directly to ECharts',
|
||||
/data:\s*buildSteadyTrendSeriesData\(series\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady trend default rendering no longer pads every series by shared time labels',
|
||||
/^(?![\s\S]*data:\s*timeLabels\.map\(time\s*=>\s*pointMap\.get\(time\)\s*\?\?\s*null\))[\s\S]*$/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady trend series enables progressive rendering for dense data',
|
||||
/progressive:\s*STEADY_TREND_PROGRESSIVE_CHUNK_SIZE[\s\S]*progressiveThreshold:\s*STEADY_TREND_PROGRESSIVE_THRESHOLD/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'time axis label formatter formats timestamp values without timeLabels',
|
||||
/buildSteadyTimeAxisLabelFormatter\s*=\s*\([^)]*\)\s*=>[\s\S]*value:\s*string\s*\|\s*number/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'one-day time axis labels use compact hour-minute text to avoid truncating repeated dates',
|
||||
/formatSteadyTimeAxisMinuteLabel[\s\S]*formatSteadyTimeAxisHourMinuteLabel[\s\S]*isRangeOverOneDay[\s\S]*formatSteadyTimeAxisHourMinuteLabel/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'line chart can resolve dataZoom startValue and endValue without category xAxis data',
|
||||
/resolveZoomRangeFromTimeAxisValues[\s\S]*getSeriesTimeRange[\s\S]*resolveChartDataZoomRange/,
|
||||
lineChartSource
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, pattern, source]) => !pattern.test(source)).map(([message]) => message)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('steadyDataView time axis contract failed:')
|
||||
failed.forEach(message => console.error(`- ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView time axis contract passed')
|
||||
@@ -0,0 +1,153 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||
const apiFile = path.resolve(currentDir, '../../../../api/steady/steadyDataView/index.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../../api/steady/steadyDataView/interface/index.ts')
|
||||
const componentDir = path.join(currentDir, '..', 'components')
|
||||
const utilsDir = path.join(currentDir, '..', 'utils')
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8')
|
||||
const pageSource = read(pageFile)
|
||||
const apiSource = read(apiFile)
|
||||
const interfaceSource = read(interfaceFile)
|
||||
const componentSource = fs.existsSync(componentDir)
|
||||
? fs
|
||||
.readdirSync(componentDir)
|
||||
.filter(file => file.endsWith('.vue'))
|
||||
.map(file => read(path.join(componentDir, file)))
|
||||
.join('\n')
|
||||
: ''
|
||||
const utilitySource = fs.existsSync(utilsDir)
|
||||
? fs
|
||||
.readdirSync(utilsDir)
|
||||
.filter(file => file.endsWith('.ts'))
|
||||
.map(file => read(path.join(utilsDir, file)))
|
||||
.join('\n')
|
||||
: ''
|
||||
|
||||
const expectations = [
|
||||
['page imports extracted trend workbench', /SteadyTrendWorkbench/],
|
||||
['trend workbench component exists', fs.existsSync(path.join(componentDir, 'SteadyTrendWorkbench.vue'))],
|
||||
['floating indicator panel component exists', fs.existsSync(path.join(componentDir, 'SteadyIndicatorFloatingPanel.vue'))],
|
||||
['components import ledger tree panel', /SteadyLedgerTree/],
|
||||
['components import indicator tree panel', /SteadyIndicatorTree/],
|
||||
['components import trend toolbar', /SteadyTrendToolbar/],
|
||||
['components import trend chart panel', /SteadyTrendChartPanel/],
|
||||
['page does not import trend summary panel', /SteadyTrendSummaryPanel/],
|
||||
['page does not import data table panel', /SteadyDataTablePanel/],
|
||||
['components render floating indicator panel', /indicator-floating-panel/],
|
||||
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
|
||||
['page restores default harmonic order when harmonic filter becomes visible', /DEFAULT_HARMONIC_ORDERS[\s\S]*showHarmonicOrders\.value[\s\S]*trendForm\.value\.harmonicOrders\.length[\s\S]*DEFAULT_HARMONIC_ORDERS/],
|
||||
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
|
||||
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
|
||||
['API exposes trend query endpoint', /\/steady\/data-view\/trend\/query/],
|
||||
['API exposes trend day endpoint', /\/steady\/data-view\/trend\/day/],
|
||||
['API disables global loading for trend query', /querySteadyTrend[\s\S]*\/steady\/data-view\/trend\/query[\s\S]*loading:\s*false/],
|
||||
['API disables global loading for trend day query', /querySteadyTrendDay[\s\S]*\/steady\/data-view\/trend\/day[\s\S]*loading:\s*false/],
|
||||
['API does not expose trend summary endpoint', /\/steady\/data-view\/trend\/summary/],
|
||||
['interfaces define trend query params', /interface\s+SteadyTrendQueryParams/],
|
||||
['interfaces define trend series', /interface\s+SteadyTrendSeries/],
|
||||
['interfaces do not define trend summary', /interface\s+SteadyTrendSummary/],
|
||||
['components render ledger checkbox tree', /show-checkbox[\s\S]*@check/],
|
||||
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
|
||||
['components reuse LineChart', /<LineChart/],
|
||||
['toolbar uses shared time period search', /TimePeriodSearch/],
|
||||
['toolbar labels stat quality filters', /toolbar-field__label[\s\S]*统计:[\s\S]*toolbar-field__label[\s\S]*数据质量:/],
|
||||
['toolbar does not render bucket selector', /modelValue\.bucket|bucketOptions|粒度:|选择时间粒度/],
|
||||
['toolbar does not render phase selector', /modelValue\.phases|phaseOptions|resolvePhaseLabel/],
|
||||
['toolbar renders quality flag with switch', /<el-switch[\s\S]*@update:model-value="handleQualityFlagChange"/],
|
||||
['toolbar maps valid quality flag to zero', /active-text="有效"[\s\S]*:active-value="0"/],
|
||||
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
|
||||
['utilities default harmonic order to second harmonic', /DEFAULT_HARMONIC_ORDERS\s*=\s*\[2\]/],
|
||||
['utilities collect selected line ids', /export const collectSelectedLineIds/],
|
||||
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
|
||||
['utilities do not require phase selection', /if\s*\(!phases\.length\)/],
|
||||
['utilities cap harmonic order count at three', /MAX_HARMONIC_ORDER_COUNT\s*=\s*3/],
|
||||
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*最多选择 \$\{MAX_HARMONIC_ORDER_COUNT\} 个/],
|
||||
['utilities count harmonic orders as one indicator in selection estimates', /const harmonicMultiplier\s*=\s*1/],
|
||||
[
|
||||
'toolbar does not provide harmonic quick groups',
|
||||
/HARMONIC_ORDER_QUICK_GROUPS|harmonic-select__quick|appendHarmonicQuickOrders/
|
||||
],
|
||||
[
|
||||
'toolbar warns when harmonic selection exceeds three instead of using silent multiple-limit',
|
||||
/(?=[\s\S]*MAX_HARMONIC_ORDER_COUNT)(?=[\s\S]*ElMessage\.warning\(`谐波次数最多选择 \$\{MAX_HARMONIC_ORDER_COUNT\} 个`\))(?![\s\S]*multiple-limit)/
|
||||
],
|
||||
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
|
||||
['utilities strip milliseconds from trend query time', /formatSteadyTrendQueryTime[\s\S]*replace\(\s*\/\\\.\[\^.\]\+\$\//],
|
||||
['utilities do not send bucket in trend query payload', /bucket:\s*formState\.bucket/],
|
||||
['utilities do not send phases in trend query payload', /phases:\s*formState\.phases/],
|
||||
['trend query params do not include bucket', /interface\s+SteadyTrendQueryParams\s*{[^}]*bucket\??:\s*string/],
|
||||
['trend query params do not include phases', /phases:\s*string\[\]/],
|
||||
['utilities build chart options', /export const buildSteadyTrendChartOptions/]
|
||||
]
|
||||
|
||||
const sourceByExpectation = [
|
||||
pageSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
pageSource,
|
||||
componentSource,
|
||||
pageSource,
|
||||
pageSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
apiSource,
|
||||
interfaceSource,
|
||||
interfaceSource,
|
||||
interfaceSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
interfaceSource,
|
||||
interfaceSource,
|
||||
utilitySource,
|
||||
utilitySource
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([name, pattern], index) => {
|
||||
const matched = typeof pattern === 'boolean' ? pattern : pattern.test(sourceByExpectation[index])
|
||||
return name.includes('does not') || name.includes('do not') ? matched : !matched
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView trend contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView trend contract passed')
|
||||
@@ -0,0 +1,119 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||
const componentDir = path.join(currentDir, '..', 'components')
|
||||
const apiFile = path.resolve(currentDir, '../../../../api/steady/steadyDataView/index.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../../api/steady/steadyDataView/interface/index.ts')
|
||||
|
||||
const source = fs.readFileSync(pageFile, 'utf8')
|
||||
const componentSource = fs.existsSync(componentDir)
|
||||
? fs
|
||||
.readdirSync(componentDir)
|
||||
.filter(file => file.endsWith('.vue'))
|
||||
.map(file => fs.readFileSync(path.join(componentDir, file), 'utf8'))
|
||||
.join('\n')
|
||||
: ''
|
||||
const readComponent = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||
const toolbarSource = readComponent('SteadyTrendToolbar.vue')
|
||||
const chartPanelSource = readComponent('SteadyTrendChartPanel.vue')
|
||||
const workbenchSource = readComponent('SteadyTrendWorkbench.vue')
|
||||
const floatingPanelSource = readComponent('SteadyIndicatorFloatingPanel.vue')
|
||||
const ledgerTreeSource = readComponent('SteadyLedgerTree.vue')
|
||||
const indicatorTreeSource = readComponent('SteadyIndicatorTree.vue')
|
||||
const viewSource = `${source}\n${componentSource}`
|
||||
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||
|
||||
const forbiddenPatterns = [
|
||||
['data detail tab is removed', /数据明细|name="detail"|SteadyDataTablePanel/, source],
|
||||
['detail ProTable is removed', /buildSteadyDataQueryParams|SteadyDataSearchParams/, source],
|
||||
['trend summary panel is removed', /SteadyTrendSummaryPanel|trendSummary|loading\.summary/, source],
|
||||
[
|
||||
'page detail API is removed',
|
||||
/getSteadyDataPage|getSteadyDataDetail|getSteadyDataTemplates|\/steady\/data-view\/page|\/steady\/data-view\/detail|\/steady\/data-view\/templates/,
|
||||
apiSource
|
||||
],
|
||||
['trend summary API is removed', /getSteadyTrendSummary|\/steady\/data-view\/trend\/summary/, apiSource],
|
||||
[
|
||||
'page detail types are removed',
|
||||
/PageResult|SteadyDataPageParams|SteadyDataDetailParams|SteadyDataTemplate|SteadyDataRecord/,
|
||||
interfaceSource
|
||||
],
|
||||
[
|
||||
'trend summary types are removed',
|
||||
/SteadyTrendSummary|SteadyTrendSummaryItem/,
|
||||
interfaceSource
|
||||
],
|
||||
['chart panel title text is removed', /panel-title/, chartPanelSource],
|
||||
['collapsed indicator vertical trigger is removed', /indicator-collapsed-trigger/, floatingPanelSource],
|
||||
['collapsed indicator label is removed', /collapsedLabel/, floatingPanelSource],
|
||||
['indicator tree refresh button is removed', /@click="emit\('refresh'\)"|:icon="Refresh"/, indicatorTreeSource],
|
||||
['floating indicator panel refresh passthrough is removed', /@refresh="emit\('refresh'\)"|refresh:\s*\[\]/, floatingPanelSource],
|
||||
['workbench indicator refresh passthrough is removed', /@refresh="emit\('refreshIndicator'\)"|refreshIndicator:\s*\[\]/, workbenchSource],
|
||||
['page indicator refresh binding is removed', /@refresh-indicator="loadIndicatorTree"/, source]
|
||||
]
|
||||
|
||||
const requiredPatterns = [
|
||||
['page defines SteadyDataView component name', /name:\s*'SteadyDataView'/, source],
|
||||
['page renders extracted trend workbench', /<SteadyTrendWorkbench/, source],
|
||||
['trend workbench component exists', /SteadyTrendWorkbench/, viewSource],
|
||||
['floating indicator panel component exists', /SteadyIndicatorFloatingPanel/, viewSource],
|
||||
['components keep trend chart panel', /SteadyTrendChartPanel/, viewSource],
|
||||
['components keep right floating indicator panel', /indicator-floating-panel/, viewSource],
|
||||
['indicator panel defaults expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/, source],
|
||||
['indicator panel supports collapsed state', /is-collapsed/, viewSource],
|
||||
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource],
|
||||
[
|
||||
'trend toolbar gives the time selector a wider first column',
|
||||
/grid-template-columns:\s*minmax\(360px,\s*1\.35fr\)\s+repeat\(3,\s*minmax\(0,\s*1fr\)\)\s*auto/,
|
||||
toolbarSource
|
||||
],
|
||||
['trend toolbar keeps actions after four search columns', /grid-column:\s*5/, toolbarSource],
|
||||
[
|
||||
'trend toolbar widens the shared time period unit selector',
|
||||
/\.trend-toolbar__time\s*:deep\(\.time-period-search__unit\)[\s\S]*width:\s*72px[\s\S]*flex:\s*0 0 72px/,
|
||||
toolbarSource
|
||||
],
|
||||
[
|
||||
'trend toolbar widens the shared time period date picker',
|
||||
/\.trend-toolbar__time\s*:deep\(\.time-period-search__picker\)[\s\S]*width:\s*136px[\s\S]*flex:\s*0 0 136px/,
|
||||
toolbarSource
|
||||
],
|
||||
['floating indicator panel expanded width is reduced', /width:\s*300px/, floatingPanelSource],
|
||||
['floating indicator collapsed state keeps icon only', /width:\s*0/, floatingPanelSource],
|
||||
['floating indicator body is hidden when collapsed', /\.indicator-floating-panel\.is-collapsed\s+\.indicator-panel-body/, floatingPanelSource],
|
||||
['floating indicator toggle keeps enough distance from title', /left:\s*-28px/, floatingPanelSource],
|
||||
['floating indicator toggle uses primary theme color', /class="indicator-toggle"[\s\S]*type="primary"/, floatingPanelSource],
|
||||
['ledger collapse buttons use primary theme color', /class="panel-toggle"[\s\S]*type="primary"/, ledgerTreeSource],
|
||||
['page tracks collapsed ledger panel state', /ledgerPanelCollapsed\s*=\s*ref\(false\)/, source],
|
||||
['page passes collapsed ledger state to workbench', /v-model:ledger-panel-collapsed="ledgerPanelCollapsed"/, source],
|
||||
['query collapses floating indicator panel', /indicatorPanelCollapsed\.value\s*=\s*true[\s\S]*querySteadyTrend/, source],
|
||||
['workbench exposes collapsed ledger panel model', /ledgerPanelCollapsed[\s\S]*update:ledgerPanelCollapsed/, workbenchSource],
|
||||
['workbench applies collapsed ledger layout class', /is-ledger-collapsed/, workbenchSource],
|
||||
['ledger panel stays in normal layout instead of floating', /\.selector-column[\s\S]*position:\s*relative/, workbenchSource],
|
||||
['collapsed ledger panel does not reserve trigger column width', /grid-template-columns:\s*0\s+minmax\(0,\s*1fr\)/, workbenchSource],
|
||||
[
|
||||
'collapsed ledger panel allows only the expand button to float',
|
||||
/\.steady-trend-layout\.is-ledger-collapsed\s+\.selector-column[\s\S]*overflow:\s*visible/,
|
||||
workbenchSource
|
||||
]
|
||||
]
|
||||
|
||||
const failures = [
|
||||
...forbiddenPatterns.filter(([, pattern, target]) => pattern.test(target)),
|
||||
...requiredPatterns.filter(([, pattern, target]) => !pattern.test(target))
|
||||
]
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView visible contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView visible contract passed')
|
||||
@@ -1,81 +1,60 @@
|
||||
<template>
|
||||
<div class="table-box steady-data-view-page">
|
||||
<div class="steady-trend-layout">
|
||||
<aside class="selector-column">
|
||||
<SteadyLedgerTree
|
||||
:key="selectorResetKey"
|
||||
:tree-data="ledgerTree"
|
||||
:loading="loading.ledger"
|
||||
:keyword="ledgerKeyword"
|
||||
@refresh="loadLedgerTree"
|
||||
@search="handleLedgerSearch"
|
||||
@change="handleLedgerChange"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main class="trend-main">
|
||||
<SteadyTrendToolbar
|
||||
v-model="trendForm"
|
||||
:phase-options="phaseOptions"
|
||||
:stat-options="statOptions"
|
||||
:show-harmonic-orders="showHarmonicOrders"
|
||||
:loading="loading.trend"
|
||||
@query="handleQueryTrend"
|
||||
@reset="resetTrendState"
|
||||
/>
|
||||
|
||||
<div class="trend-content">
|
||||
<SteadyTrendChartPanel :trend-result="trendResult" :loading="loading.trend" />
|
||||
|
||||
<aside class="indicator-floating-panel" :class="{ 'is-collapsed': indicatorPanelCollapsed }">
|
||||
<el-button
|
||||
class="indicator-toggle"
|
||||
:icon="indicatorPanelCollapsed ? ArrowLeft : ArrowRight"
|
||||
circle
|
||||
@click="indicatorPanelCollapsed = !indicatorPanelCollapsed"
|
||||
/>
|
||||
<button
|
||||
v-if="indicatorPanelCollapsed"
|
||||
class="indicator-collapsed-trigger"
|
||||
type="button"
|
||||
@click="indicatorPanelCollapsed = false"
|
||||
>
|
||||
稳态指标
|
||||
</button>
|
||||
<div v-show="!indicatorPanelCollapsed" class="indicator-panel-body">
|
||||
<SteadyIndicatorTree
|
||||
:key="selectorResetKey"
|
||||
:tree-data="indicatorTree"
|
||||
:loading="loading.indicator"
|
||||
@refresh="loadIndicatorTree"
|
||||
@change="handleIndicatorChange"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<SteadyTrendWorkbench
|
||||
v-model:trend-form="trendForm"
|
||||
v-model:ledger-panel-collapsed="ledgerPanelCollapsed"
|
||||
v-model:indicator-panel-collapsed="indicatorPanelCollapsed"
|
||||
:ledger-tree="ledgerTree"
|
||||
:indicator-tree="indicatorTree"
|
||||
:trend-result="trendResult"
|
||||
:stat-options="statOptions"
|
||||
:show-harmonic-orders="showHarmonicOrders"
|
||||
:loading="loading"
|
||||
:ledger-keyword="ledgerKeyword"
|
||||
:default-ledger-checked-keys="defaultLedgerCheckedKeys"
|
||||
:default-indicator-checked-keys="defaultIndicatorCheckedKeys"
|
||||
:selector-reset-key="selectorResetKey"
|
||||
@refresh-ledger="loadLedgerTree"
|
||||
@ledger-search="handleLedgerSearch"
|
||||
@ledger-change="handleLedgerChange"
|
||||
@indicator-change="handleIndicatorChange"
|
||||
@query-trend="handleQueryTrend"
|
||||
@reset-trend="resetTrendState"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { getSteadyTrendIndicatorTree, getSteadyTrendLedgerTree, querySteadyTrend } from '@/api/steady/steadyDataView'
|
||||
import {
|
||||
getSteadyTrendIndicatorTree,
|
||||
getSteadyTrendLedgerTree,
|
||||
querySteadyTrend,
|
||||
querySteadyTrendDay
|
||||
} from '@/api/steady/steadyDataView'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import SteadyIndicatorTree from './components/SteadyIndicatorTree.vue'
|
||||
import SteadyLedgerTree from './components/SteadyLedgerTree.vue'
|
||||
import SteadyTrendChartPanel from './components/SteadyTrendChartPanel.vue'
|
||||
import SteadyTrendToolbar from './components/SteadyTrendToolbar.vue'
|
||||
import SteadyTrendWorkbench from './components/SteadyTrendWorkbench.vue'
|
||||
import {
|
||||
collectSelectedLineIds,
|
||||
findFirstLeafIndicator,
|
||||
findFirstSelectableLedgerNode,
|
||||
hasHarmonicIndicator,
|
||||
resolveAvailablePhases,
|
||||
resolveAvailableStats,
|
||||
sortSteadyIndicatorTree,
|
||||
validateTrendSelection
|
||||
} from './utils/selectionRules'
|
||||
import { buildSteadyTrendQueryPayload, defaultTrendFormState } from './utils/trendPayload'
|
||||
import { normalizeSteadyLedgerTree } from './utils/ledgerTree'
|
||||
import {
|
||||
DEFAULT_HARMONIC_ORDERS,
|
||||
buildEmptySteadyTrendQueryResult,
|
||||
buildSteadyTrendQueryChunks,
|
||||
buildSteadyTrendQueryPayload,
|
||||
defaultTrendFormState,
|
||||
hasSteadyTrendResultData,
|
||||
isSteadyTrendRangeOverChunkLimit,
|
||||
mergeSteadyTrendQueryResult
|
||||
} from './utils/trendPayload'
|
||||
|
||||
defineOptions({
|
||||
name: 'SteadyDataView'
|
||||
@@ -88,20 +67,20 @@ const selectedIndicators = ref<SteadyDataView.SteadyIndicatorNode[]>([])
|
||||
const trendResult = ref<SteadyDataView.SteadyTrendQueryResult | null>(null)
|
||||
const trendForm = ref(defaultTrendFormState())
|
||||
const ledgerKeyword = ref('')
|
||||
const ledgerPanelCollapsed = ref(false)
|
||||
const indicatorPanelCollapsed = ref(false)
|
||||
const selectorResetKey = ref(0)
|
||||
const defaultLedgerCheckedKeys = ref<string[]>([])
|
||||
const defaultIndicatorCheckedKeys = ref<string[]>([])
|
||||
const loading = reactive({
|
||||
ledger: false,
|
||||
indicator: false,
|
||||
trend: false
|
||||
})
|
||||
let trendQuerySerial = 0
|
||||
|
||||
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
|
||||
const showHarmonicOrders = computed(() => hasHarmonicIndicator(selectedIndicators.value))
|
||||
const phaseOptions = computed(() => {
|
||||
const phases = resolveAvailablePhases(selectedIndicators.value)
|
||||
return phases.length ? phases : ['A', 'B', 'C', 'T']
|
||||
})
|
||||
const statOptions = computed<SteadyDataView.SteadyTrendStatType[]>(() => {
|
||||
const stats = resolveAvailableStats(selectedIndicators.value)
|
||||
return stats.length ? stats : ['AVG', 'MAX', 'MIN', 'CP95']
|
||||
@@ -119,7 +98,12 @@ 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] : []
|
||||
defaultLedgerCheckedKeys.value = firstLedgerNode ? [firstLedgerNode.id] : []
|
||||
} finally {
|
||||
loading.ledger = false
|
||||
}
|
||||
@@ -129,7 +113,12 @@ const loadIndicatorTree = async () => {
|
||||
loading.indicator = true
|
||||
try {
|
||||
const response = await getSteadyTrendIndicatorTree()
|
||||
indicatorTree.value = unwrapData(response) || []
|
||||
indicatorTree.value = sortSteadyIndicatorTree(unwrapData(response) || [])
|
||||
const firstIndicator = findFirstLeafIndicator(indicatorTree.value)
|
||||
const firstIndicatorKey = firstIndicator?.id || firstIndicator?.indicatorCode
|
||||
// 指标树加载后默认选中第一个叶子指标,并同步驱动统计类型默认值。
|
||||
selectedIndicators.value = firstIndicator ? [firstIndicator] : []
|
||||
defaultIndicatorCheckedKeys.value = firstIndicatorKey ? [firstIndicatorKey] : []
|
||||
} finally {
|
||||
loading.indicator = false
|
||||
}
|
||||
@@ -144,26 +133,55 @@ const handleLedgerSearch = (value: string) => {
|
||||
|
||||
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
|
||||
selectedLedgerNodes.value = nodes
|
||||
// 监测点切换只更新待查询条件,趋势数据保留到用户再次点击查询时再清理。
|
||||
}
|
||||
|
||||
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
selectedIndicators.value = nodes
|
||||
// 指标切换只更新待查询条件,趋势数据保留到用户再次点击查询时再清理。
|
||||
}
|
||||
|
||||
const resetTrendState = () => {
|
||||
trendQuerySerial += 1
|
||||
trendForm.value = defaultTrendFormState()
|
||||
selectedLedgerNodes.value = []
|
||||
selectedIndicators.value = []
|
||||
defaultLedgerCheckedKeys.value = []
|
||||
defaultIndicatorCheckedKeys.value = []
|
||||
trendResult.value = null
|
||||
selectorResetKey.value += 1
|
||||
}
|
||||
|
||||
const querySteadyTrendInChunks = async (payload: SteadyDataView.SteadyTrendQueryParams, currentQuerySerial: number) => {
|
||||
const chunks = buildSteadyTrendQueryChunks(payload)
|
||||
|
||||
trendResult.value = buildEmptySteadyTrendQueryResult(payload.timeStart, payload.timeEnd)
|
||||
|
||||
for (const chunkPayload of chunks) {
|
||||
if (currentQuerySerial !== trendQuerySerial) return
|
||||
|
||||
const trendResponse = await querySteadyTrendDay(chunkPayload)
|
||||
|
||||
if (currentQuerySerial !== trendQuerySerial) return
|
||||
|
||||
trendResult.value = mergeSteadyTrendQueryResult(trendResult.value, unwrapData(trendResponse))
|
||||
}
|
||||
|
||||
if (currentQuerySerial === trendQuerySerial && trendResult.value) {
|
||||
trendResult.value = {
|
||||
...trendResult.value,
|
||||
queryCompleted: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleQueryTrend = async () => {
|
||||
indicatorPanelCollapsed.value = true
|
||||
|
||||
const selectionError = validateTrendSelection({
|
||||
lineIds: lineIds.value,
|
||||
indicators: selectedIndicators.value,
|
||||
phases: trendForm.value.phases,
|
||||
statTypes: trendForm.value.statTypes,
|
||||
statType: trendForm.value.statType,
|
||||
harmonicOrders: trendForm.value.harmonicOrders
|
||||
})
|
||||
if (selectionError) {
|
||||
@@ -171,40 +189,61 @@ const handleQueryTrend = async () => {
|
||||
return
|
||||
}
|
||||
if (!trendForm.value.timeRange[0] || !trendForm.value.timeRange[1]) {
|
||||
ElMessage.warning('请选择趋势时间范围')
|
||||
ElMessage.warning('\u8bf7\u9009\u62e9\u8d8b\u52bf\u65f6\u95f4\u8303\u56f4')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = buildSteadyTrendQueryPayload(lineIds.value, selectedIndicators.value, trendForm.value)
|
||||
trendResult.value = null
|
||||
const currentQuerySerial = ++trendQuerySerial
|
||||
const useChunkedQuery = isSteadyTrendRangeOverChunkLimit(payload.timeStart, payload.timeEnd)
|
||||
|
||||
loading.trend = true
|
||||
loading.trend = !isSteadyTrendRangeOverChunkLimit(payload.timeStart, payload.timeEnd)
|
||||
try {
|
||||
// 趋势查询只驱动主图,右侧稳态指标作为筛选面板独立加载,避免额外摘要请求拖慢页面响应。
|
||||
if (useChunkedQuery) {
|
||||
// 超过 3 天的趋势查询拆成小窗口逐段加载,先撑起完整横坐标框架再增量更新。
|
||||
await querySteadyTrendInChunks(payload, currentQuerySerial)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentQuerySerial !== trendQuerySerial) return
|
||||
|
||||
// 趋势查询只驱动主图,右侧指标树作为筛选面板独立加载,避免额外请求拖慢页面响应。
|
||||
const trendResponse = await querySteadyTrend(payload)
|
||||
trendResult.value = unwrapData(trendResponse)
|
||||
|
||||
if (currentQuerySerial !== trendQuerySerial) return
|
||||
|
||||
const nextResult = unwrapData(trendResponse)
|
||||
trendResult.value = {
|
||||
...nextResult,
|
||||
queryTimeStart: payload.timeStart,
|
||||
queryTimeEnd: payload.timeEnd,
|
||||
queryCompleted: true
|
||||
}
|
||||
|
||||
if (!hasSteadyTrendResultData(trendResult.value)) {
|
||||
trendResult.value = buildEmptySteadyTrendQueryResult(payload.timeStart, payload.timeEnd, true)
|
||||
}
|
||||
} finally {
|
||||
loading.trend = false
|
||||
if (currentQuerySerial === trendQuerySerial) {
|
||||
loading.trend = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedIndicators,
|
||||
() => {
|
||||
const availablePhases = phaseOptions.value
|
||||
const availableStats = statOptions.value
|
||||
|
||||
trendForm.value = {
|
||||
...trendForm.value,
|
||||
phases: trendForm.value.phases.filter(phase => availablePhases.includes(phase)),
|
||||
statTypes: trendForm.value.statTypes.filter(stat => availableStats.includes(stat)),
|
||||
harmonicOrders: showHarmonicOrders.value ? trendForm.value.harmonicOrders : []
|
||||
}
|
||||
|
||||
if (!trendForm.value.phases.length) {
|
||||
trendForm.value.phases = availablePhases.slice(0, Math.min(3, availablePhases.length))
|
||||
}
|
||||
if (!trendForm.value.statTypes.length) {
|
||||
trendForm.value.statTypes = availableStats.slice(0, 1)
|
||||
statType: availableStats.includes(trendForm.value.statType) ? trendForm.value.statType : availableStats[0],
|
||||
harmonicOrders: showHarmonicOrders.value
|
||||
? trendForm.value.harmonicOrders.length
|
||||
? trendForm.value.harmonicOrders
|
||||
: [...DEFAULT_HARMONIC_ORDERS]
|
||||
: []
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -218,104 +257,8 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.steady-data-view-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.steady-trend-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.selector-column {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trend-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trend-content {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trend-content :deep(.trend-chart-panel) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.indicator-floating-panel {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 2;
|
||||
width: 360px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.indicator-floating-panel.is-collapsed {
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.indicator-toggle {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: -18px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.indicator-panel-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.indicator-panel-body :deep(.steady-tree-card) {
|
||||
height: 100%;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.indicator-collapsed-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
background: var(--el-bg-color);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.indicator-collapsed-trigger:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.steady-trend-layout {
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.indicator-floating-panel {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
213
frontend/src/views/steady/steadyDataView/utils/ledgerTree.ts
Normal file
213
frontend/src/views/steady/steadyDataView/utils/ledgerTree.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
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 rawChildren = rawNode.children ?? rawNode.Children ?? rawNode.childrenList ?? rawNode.childList
|
||||
const children = Array.isArray(rawChildren) ? (rawChildren as SteadyDataView.SteadyLedgerNode[]) : []
|
||||
const level = normalizeLevel(rawNode.level ?? rawNode.Level)
|
||||
const id = resolveText(
|
||||
rawNode,
|
||||
'id',
|
||||
'Id',
|
||||
'lineId',
|
||||
'line_id',
|
||||
'equipmentId',
|
||||
'equipment_id',
|
||||
'deviceId',
|
||||
'device_id',
|
||||
'projectId',
|
||||
'project_id',
|
||||
'engineeringId',
|
||||
'engineering_id'
|
||||
)
|
||||
const parentId =
|
||||
resolveText(
|
||||
rawNode,
|
||||
'parentId',
|
||||
'parent_id',
|
||||
'pid',
|
||||
'Pid',
|
||||
level === 3 ? 'deviceId' : '',
|
||||
level === 3 ? 'device_id' : '',
|
||||
level === 2 ? 'projectId' : '',
|
||||
level === 2 ? 'project_id' : ''
|
||||
) ||
|
||||
inheritedParentId
|
||||
const rawSelectable = resolveBoolean(rawNode, 'selectable', 'Selectable')
|
||||
|
||||
if (!id) return
|
||||
if (level === 3 && rawSelectable === false) return
|
||||
|
||||
output.push({
|
||||
id,
|
||||
parentId,
|
||||
parentIds: resolveText(rawNode, 'parentIds', 'pids', 'Pids'),
|
||||
name:
|
||||
resolveText(
|
||||
rawNode,
|
||||
'name',
|
||||
'Name',
|
||||
'lineName',
|
||||
'line_name',
|
||||
'equipmentName',
|
||||
'equipment_name',
|
||||
'deviceName',
|
||||
'device_name',
|
||||
'projectName',
|
||||
'project_name',
|
||||
'engineeringName',
|
||||
'engineering_name'
|
||||
) ||
|
||||
id,
|
||||
level,
|
||||
sort: resolveNumber(rawNode, 'sort', 'Sort'),
|
||||
deviceCount: resolveNumber(rawNode, 'deviceCount', 'DeviceCount', 'equipmentCount', 'equipment_count'),
|
||||
lineCount: resolveNumber(rawNode, 'lineCount', 'LineCount', 'monitorCount', 'monitor_count'),
|
||||
selectable: level === 3 ? rawSelectable !== false : rawSelectable === true,
|
||||
children: [],
|
||||
__order: output.length
|
||||
})
|
||||
|
||||
flattenLedgerNodes(children, output, id)
|
||||
})
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const resolveExpectedParentId = (node: IndexedLedgerNode, nodeMap: Map<string, IndexedLedgerNode>) => {
|
||||
if (node.level === 0) return ''
|
||||
|
||||
const parentNode = node.parentId ? nodeMap.get(node.parentId) : undefined
|
||||
if (parentNode && parentNode.level === node.level - 1) {
|
||||
return parentNode.id
|
||||
}
|
||||
|
||||
// 后端搜索可能返回扁平节点或错误嵌套,优先按 parentIds 中的上一层节点恢复固定台账层级。
|
||||
const parentIds = splitParentIds(node.parentIds).reverse()
|
||||
const matchedParent = parentIds.map(id => nodeMap.get(id)).find(item => item && item.level === node.level - 1)
|
||||
|
||||
return matchedParent?.id || node.parentId || ''
|
||||
}
|
||||
|
||||
const sortLedgerNodes = (left: IndexedLedgerNode, right: IndexedLedgerNode) => {
|
||||
const leftSort = Number.isFinite(left.sort) ? Number(left.sort) : left.__order
|
||||
const rightSort = Number.isFinite(right.sort) ? Number(right.sort) : right.__order
|
||||
if (leftSort !== rightSort) return leftSort - rightSort
|
||||
|
||||
return left.__order - right.__order
|
||||
}
|
||||
|
||||
const stripInternalFields = (node: IndexedLedgerNode): 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)
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
|
||||
export const MAX_TREND_SERIES_COUNT = 24
|
||||
export const MAX_HARMONIC_ORDER_COUNT = 6
|
||||
export const MAX_SELECTED_LINE_COUNT = 6
|
||||
export const MAX_SELECTED_INDICATOR_COUNT = 6
|
||||
export const MAX_HARMONIC_ORDER_COUNT = 3
|
||||
const STEADY_INDICATOR_GROUP_ORDER = ['电压趋势', '电流趋势']
|
||||
|
||||
const isSelectableLineNode = (node: 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)
|
||||
}
|
||||
|
||||
@@ -21,6 +28,7 @@ export const collectSelectedLineIds = (nodes: SteadyDataView.SteadyLedgerNode[])
|
||||
|
||||
export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
const indicators: SteadyDataView.SteadyIndicatorNode[] = []
|
||||
const seenIndicatorKeys = new Set<string>()
|
||||
|
||||
const collect = (node: SteadyDataView.SteadyIndicatorNode) => {
|
||||
if (node.children?.length) {
|
||||
@@ -28,9 +36,11 @@ export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[
|
||||
return
|
||||
}
|
||||
|
||||
if (node.indicatorCode) {
|
||||
indicators.push(node)
|
||||
}
|
||||
const indicatorKey = node.indicatorCode || node.treeKey || node.id
|
||||
if (!indicatorKey || seenIndicatorKeys.has(indicatorKey)) return
|
||||
|
||||
seenIndicatorKeys.add(indicatorKey)
|
||||
indicators.push(node)
|
||||
}
|
||||
|
||||
nodes.forEach(collect)
|
||||
@@ -38,18 +48,65 @@ export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[
|
||||
return indicators
|
||||
}
|
||||
|
||||
export const hasHarmonicIndicator = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
return indicators.some(item => item.harmonic || Boolean(item.harmonicOrderStart || item.harmonicOrderEnd))
|
||||
const resolveSteadyIndicatorGroupOrder = (node: SteadyDataView.SteadyIndicatorNode) => {
|
||||
const orderName = String(node.name || '').trim()
|
||||
const orderIndex = STEADY_INDICATOR_GROUP_ORDER.findIndex(name => orderName === name)
|
||||
|
||||
return orderIndex === -1 ? STEADY_INDICATOR_GROUP_ORDER.length : orderIndex
|
||||
}
|
||||
|
||||
export const resolveAvailablePhases = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
const phaseSet = new Set<string>()
|
||||
export const sortSteadyIndicatorTree = (
|
||||
nodes: SteadyDataView.SteadyIndicatorNode[]
|
||||
): SteadyDataView.SteadyIndicatorNode[] => {
|
||||
return nodes
|
||||
.map((node, index) => ({ node, index }))
|
||||
.sort((left, right) => {
|
||||
const leftOrder = resolveSteadyIndicatorGroupOrder(left.node)
|
||||
const rightOrder = resolveSteadyIndicatorGroupOrder(right.node)
|
||||
|
||||
indicators.forEach(indicator => {
|
||||
indicator.phaseCodes?.forEach(phase => phaseSet.add(phase))
|
||||
})
|
||||
return leftOrder === rightOrder ? left.index - right.index : leftOrder - rightOrder
|
||||
})
|
||||
.map(({ node }) => ({
|
||||
...node,
|
||||
children: node.children?.length ? sortSteadyIndicatorTree(node.children) : node.children
|
||||
}))
|
||||
}
|
||||
|
||||
return Array.from(phaseSet)
|
||||
export const findFirstSelectableLedgerNode = (
|
||||
nodes: SteadyDataView.SteadyLedgerNode[]
|
||||
): SteadyDataView.SteadyLedgerNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (isSelectableLineNode(node)) {
|
||||
return node
|
||||
}
|
||||
|
||||
const childNode = findFirstSelectableLedgerNode(node.children || [])
|
||||
if (childNode) return childNode
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const findFirstLeafIndicator = (
|
||||
nodes: SteadyDataView.SteadyIndicatorNode[]
|
||||
): SteadyDataView.SteadyIndicatorNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.children?.length) {
|
||||
const childNode = findFirstLeafIndicator(node.children)
|
||||
if (childNode) return childNode
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.indicatorCode) {
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const hasHarmonicIndicator = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
return indicators.some(item => item.harmonic || Boolean(item.harmonicOrderStart || item.harmonicOrderEnd))
|
||||
}
|
||||
|
||||
export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
@@ -65,28 +122,24 @@ export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicator
|
||||
export const estimateTrendSeriesCount = (
|
||||
lineIds: string[],
|
||||
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||
phases: string[],
|
||||
statTypes: SteadyDataView.SteadyTrendStatType[],
|
||||
statType: SteadyDataView.SteadyTrendStatType,
|
||||
harmonicOrders: number[]
|
||||
) => {
|
||||
const harmonicMultiplier = hasHarmonicIndicator(indicators) ? Math.max(harmonicOrders.length, 1) : 1
|
||||
void harmonicOrders
|
||||
const harmonicMultiplier = 1
|
||||
|
||||
return indicators.reduce((count, indicator) => {
|
||||
const indicatorPhases = indicator.phaseCodes?.length ? indicator.phaseCodes : phases
|
||||
const selectedPhaseCount = indicatorPhases.filter(phase => phases.includes(phase)).length || indicatorPhases.length || 1
|
||||
const phaseCount = indicator.phaseCodes?.length || 1
|
||||
const fieldCount = Math.max(indicator.seriesFields?.length || indicator.baseFields?.length || 1, 1)
|
||||
|
||||
return count + lineIds.length * selectedPhaseCount * Math.max(statTypes.length, 1) * fieldCount * harmonicMultiplier
|
||||
return count + lineIds.length * phaseCount * (statType ? 1 : 0) * fieldCount * harmonicMultiplier
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export const validateHarmonicOrders = (
|
||||
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||
harmonicOrders: number[]
|
||||
) => {
|
||||
export const validateHarmonicOrders = (indicators: SteadyDataView.SteadyIndicatorNode[], harmonicOrders: number[]) => {
|
||||
if (!hasHarmonicIndicator(indicators)) return ''
|
||||
if (!harmonicOrders.length) return '谐波指标必须选择谐波次数'
|
||||
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return '谐波次数最多选择 6 个'
|
||||
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return `谐波次数最多选择 ${MAX_HARMONIC_ORDER_COUNT} 个`
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -94,24 +147,24 @@ export const validateHarmonicOrders = (
|
||||
export const validateTrendSelection = (params: {
|
||||
lineIds: string[]
|
||||
indicators: SteadyDataView.SteadyIndicatorNode[]
|
||||
phases: string[]
|
||||
statTypes: SteadyDataView.SteadyTrendStatType[]
|
||||
statType: SteadyDataView.SteadyTrendStatType
|
||||
harmonicOrders: number[]
|
||||
}) => {
|
||||
const { lineIds, indicators, phases, statTypes, harmonicOrders } = params
|
||||
const { lineIds, indicators, statType, harmonicOrders } = params
|
||||
|
||||
if (!lineIds.length) return '请选择监测点'
|
||||
if (!indicators.length) return '请选择趋势指标'
|
||||
if (lineIds.length > 1 && indicators.length > 1) return '多监测点查询时只能选择 1 个指标'
|
||||
if (!statTypes.length) return '请选择统计类型'
|
||||
if (!phases.length) return '请选择相别'
|
||||
if (lineIds.length > MAX_SELECTED_LINE_COUNT) return '监测点最多选择 6 个'
|
||||
if (indicators.length > MAX_SELECTED_INDICATOR_COUNT) return '趋势指标最多选择 6 个'
|
||||
if (!statType) return '请选择统计类型'
|
||||
|
||||
const harmonicError = validateHarmonicOrders(indicators, harmonicOrders)
|
||||
if (harmonicError) return harmonicError
|
||||
|
||||
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, phases, statTypes, harmonicOrders)
|
||||
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, statType, harmonicOrders)
|
||||
if (seriesCount > MAX_TREND_SERIES_COUNT) {
|
||||
return '趋势曲线数量不能超过 24 条,请缩小监测点、指标、相别或统计类型范围'
|
||||
return '趋势曲线数量不能超过 24 条,请缩小监测点、指标或统计类型范围'
|
||||
}
|
||||
|
||||
return ''
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user