Compare commits
12 Commits
6687cf0339
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ef80aff151 | |||
| 81f90ce0f2 | |||
| 8622f25048 | |||
| 3dff953b8d | |||
| d055a8e1a0 | |||
| 055e69fff7 | |||
| b9ddfb5275 | |||
| f1eaabae0e | |||
| 6755476969 | |||
| f9ed6c6245 | |||
| 609fdd5379 | |||
| 8b19e4a779 |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
docs/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
out/
|
out/
|
||||||
logs/
|
logs/
|
||||||
@@ -10,3 +11,14 @@ public/electron/
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
/public/dist/
|
/public/dist/
|
||||||
|
/build/extraResources/influxdb-1.7.0/data/_internal/_series/00/0000
|
||||||
|
/build/extraResources/influxdb-1.7.0/meta/meta.db
|
||||||
|
/build/extraResources/influxdb-1.7.0/influxdb.runtime.conf
|
||||||
|
/build/extraResources/influxdb-1.7.0/wal/_internal/monitor/1/_00001.wal
|
||||||
|
/build/extraResources/influxdb-1.7.0/data/_internal/_series/07/0000
|
||||||
|
/build/extraResources/influxdb-1.7.0/data/_internal/_series/06/0000
|
||||||
|
/build/extraResources/influxdb-1.7.0/data/_internal/_series/05/0000
|
||||||
|
/build/extraResources/influxdb-1.7.0/data/_internal/_series/04/0000
|
||||||
|
/build/extraResources/influxdb-1.7.0/data/_internal/_series/03/0000
|
||||||
|
/build/extraResources/influxdb-1.7.0/data/_internal/_series/02/0000
|
||||||
|
/build/extraResources/influxdb-1.7.0/data/_internal/_series/01/0000
|
||||||
|
|||||||
10
AGENTS.md
10
AGENTS.md
@@ -43,7 +43,7 @@
|
|||||||
- `components/` 放当前页面专属展示块、弹窗、表格、工具栏和信息面板。组件通过 props / emits 与入口页通信,不直接越级调用页面接口状态。
|
- `components/` 放当前页面专属展示块、弹窗、表格、工具栏和信息面板。组件通过 props / emits 与入口页通信,不直接越级调用页面接口状态。
|
||||||
- `utils/` 放当前页面专属纯函数或弱状态工具,包括请求参数构造、接口返回归一化、树节点/表单模型转换、枚举选项、时间/数值格式化、图表坐标和导出数据拼装等。
|
- `utils/` 放当前页面专属纯函数或弱状态工具,包括请求参数构造、接口返回归一化、树节点/表单模型转换、枚举选项、时间/数值格式化、图表坐标和导出数据拼装等。
|
||||||
- 页面级类型优先复用 `api/**/interface/`;只服务页面内部组件的 UI 类型可放在当前页面 `components/types.ts` 或 `utils/*.ts`,不要扩散到全局类型。
|
- 页面级类型优先复用 `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 检查。
|
- `waveform` 趋势图规则仍以本文件后续“趋势图”章节为准,图表坐标、线宽、多图对齐等计算逻辑拆分后也必须保留对应 contract 检查。
|
||||||
|
|
||||||
@@ -81,7 +81,8 @@ PR 应包含:
|
|||||||
|
|
||||||
- 必须显示纵坐标最大值和最小值,图表配置中应显式保留 `showMaxLabel` 与 `showMinLabel`。
|
- 必须显示纵坐标最大值和最小值,图表配置中应显式保留 `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`。
|
- 当数据同时包含正负值且正负幅值接近时,纵坐标最大值和最小值应尽量对称,按较大绝对值向外取整后取 `±同一边界`,例如最大值 `178`、最小值 `-177` 时显示为 `180` 与 `-180`。
|
||||||
- 当最大值、最小值相同或数据接近 `0` 时,需要补充兜底范围,避免坐标轴退化为一条线;小于 `1` 的小数范围按实际小数精度保留,不强制取整。
|
- 当最大值、最小值相同或数据接近 `0` 时,需要补充兜底范围,避免坐标轴退化为一条线;小于 `1` 的小数范围按实际小数精度保留,不强制取整。
|
||||||
- 当纵坐标区间较小且均分后出现冗长小数时,应优先使用 `1`、`2`、`2.5`、`5` 等可读步长归一化刻度;必要时可少量增加分段,但必须继续保证刻度均分、最大最小值显示、真实数据完整落在坐标范围内。
|
- 当纵坐标区间较小且均分后出现冗长小数时,应优先使用 `1`、`2`、`2.5`、`5` 等可读步长归一化刻度;必要时可少量增加分段,但必须继续保证刻度均分、最大最小值显示、真实数据完整落在坐标范围内。
|
||||||
@@ -94,6 +95,7 @@ PR 应包含:
|
|||||||
- 多图不得让 ECharts 按各自纵坐标标签宽度自动改变绘图区起点;应使用统一的 `grid.left`,并显式配置 `grid.containLabel: false` 或等效方案,避免 `150`、`2`、`-100` 等标签宽度差异导致曲线区域错位。
|
- 多图不得让 ECharts 按各自纵坐标标签宽度自动改变绘图区起点;应使用统一的 `grid.left`,并显式配置 `grid.containLabel: false` 或等效方案,避免 `150`、`2`、`-100` 等标签宽度差异导致曲线区域错位。
|
||||||
- 纵坐标标签宽度预留应按同组图中最长标签统一评估,必要时增加统一的左侧 `grid.left`,不能为单张图单独调整左边距。
|
- 纵坐标标签宽度预留应按同组图中最长标签统一评估,必要时增加统一的左侧 `grid.left`,不能为单张图单独调整左边距。
|
||||||
- 横坐标首尾标签、单位文字或底部留白只能影响底部显示空间,不应改变绘图区左边界;调整 `grid.bottom`、`axisLabel.margin`、`nameGap` 时,需要同步检查多图 x=0 起点是否仍然对齐。
|
- 横坐标首尾标签、单位文字或底部留白只能影响底部显示空间,不应改变绘图区左边界;调整 `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))`。
|
- 当前可见点数按横向缩放范围计算:`ceil(seriesDataLength * ((dataZoom.end - dataZoom.start) / 100))`。
|
||||||
- 初始全量展示时,点数越多线越细;横向放大后可见点数减少,线宽可逐步变粗;横向缩小或重置后线宽恢复到对应细线档位。
|
- 初始全量展示时,点数越多线越细;横向放大后可见点数减少,线宽可逐步变粗;横向缩小或重置后线宽恢复到对应细线档位。
|
||||||
- Y 轴缩放、测量模式、峰值显示不改变主线线宽,避免状态切换造成额外视觉跳动。
|
- Y 轴缩放、测量模式、峰值显示不改变主线线宽,避免状态切换造成额外视觉跳动。
|
||||||
- 主线最大线宽不得超过 `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.2`,`200 - 799` 使用 `1.4`,`< 200` 使用 `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.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"
|
||||||
Binary file not shown.
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"
|
||||||
5
build/extraResources/influxdb-1.7.0/start-influxdb.bat
Normal file
5
build/extraResources/influxdb-1.7.0/start-influxdb.bat
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
set "CONFIG_FILE=%~1"
|
||||||
|
if "%CONFIG_FILE%"=="" set "CONFIG_FILE=%~dp0influxdb.conf"
|
||||||
|
influxd.exe -config "%CONFIG_FILE%"
|
||||||
@@ -95,6 +95,13 @@ qr:
|
|||||||
db:
|
db:
|
||||||
type: mysql
|
type: mysql
|
||||||
|
|
||||||
|
steady:
|
||||||
|
influxdb:
|
||||||
|
url: http://127.0.0.1:{{INFLUXDB_PORT}}
|
||||||
|
database: pqsbase
|
||||||
|
username:
|
||||||
|
password:
|
||||||
|
|
||||||
|
|
||||||
# 比对录波需要的配置,晚点再做优化
|
# 比对录波需要的配置,晚点再做优化
|
||||||
# 系统配置
|
# 系统配置
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
"to": "mysql",
|
"to": "mysql",
|
||||||
"filter": ["**/*"]
|
"filter": ["**/*"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "build/extraResources/influxdb-1.7.0",
|
||||||
|
"to": "influxdb-1.7.0",
|
||||||
|
"filter": ["**/*"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "build/extraResources/jre",
|
"from": "build/extraResources/jre",
|
||||||
"to": "jre",
|
"to": "jre",
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
- 手动验证当前页中无波形行复选框禁用,有波形行可选。
|
||||||
|
- 手动验证表头全选只选择当前页有波形的行。
|
||||||
|
- 手动验证查询、重置、翻页后旧选择不会误参与波形导出。
|
||||||
@@ -11,45 +11,40 @@ const { app } = require('electron');
|
|||||||
function getScriptsPath(scriptName) {
|
function getScriptsPath(scriptName) {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// 判断是否是打包后的环境
|
if (process.resourcesPath) {
|
||||||
// 只要 process.resourcesPath 存在,就是打包后的环境(无论在哪个目录)
|
|
||||||
const isProd = !!process.resourcesPath;
|
|
||||||
|
|
||||||
if (isProd) {
|
|
||||||
// 生产环境(打包后):从 resources 目录
|
|
||||||
const prodPath = path.join(process.resourcesPath, 'scripts', scriptName);
|
const prodPath = path.join(process.resourcesPath, 'scripts', scriptName);
|
||||||
|
const prodFile = `${prodPath}.js`;
|
||||||
|
if (fs.existsSync(prodFile)) {
|
||||||
console.log(`[getScriptsPath] Production mode, using: ${prodPath}`);
|
console.log(`[getScriptsPath] Production mode, using: ${prodPath}`);
|
||||||
return prodPath;
|
return prodPath;
|
||||||
} else {
|
|
||||||
// 开发环境:从项目根目录
|
|
||||||
// __dirname 是 electron/preload 或 public/electron/preload
|
|
||||||
// 需要找到项目根目录
|
|
||||||
let currentDir = __dirname;
|
|
||||||
let scriptsPath = null;
|
|
||||||
|
|
||||||
// 向上查找,直到找到 scripts 目录
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
currentDir = path.join(currentDir, '..');
|
|
||||||
const testPath = path.join(currentDir, 'scripts', scriptName + '.js');
|
|
||||||
if (fs.existsSync(testPath)) {
|
|
||||||
scriptsPath = path.join(currentDir, 'scripts', scriptName);
|
|
||||||
console.log(`[getScriptsPath] Development mode, found at: ${scriptsPath}`);
|
|
||||||
return scriptsPath;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果找不到,返回一个默认路径
|
|
||||||
console.warn(`[getScriptsPath] Cannot find ${scriptName}, returning default path`);
|
|
||||||
return path.join(__dirname, '../../../scripts', scriptName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 开发环境:从项目根目录向上查找 scripts 目录,避免误用 Electron 安装目录 resources。
|
||||||
|
let currentDir = __dirname;
|
||||||
|
let scriptsPath = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
currentDir = path.join(currentDir, '..');
|
||||||
|
const testPath = path.join(currentDir, 'scripts', scriptName + '.js');
|
||||||
|
if (fs.existsSync(testPath)) {
|
||||||
|
scriptsPath = path.join(currentDir, 'scripts', scriptName);
|
||||||
|
console.log(`[getScriptsPath] Development mode, found at: ${scriptsPath}`);
|
||||||
|
return scriptsPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[getScriptsPath] Cannot find ${scriptName}, returning default path`);
|
||||||
|
return path.join(__dirname, '../../../scripts', scriptName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延迟加载 scripts
|
// 延迟加载 scripts
|
||||||
let MySQLProcessManager, JavaRunner, ConfigGenerator, PortChecker, StartupManager, LogWindowManager;
|
let MySQLProcessManager, InfluxDBProcessManager, JavaRunner, ConfigGenerator, PortChecker, StartupManager, LogWindowManager;
|
||||||
|
|
||||||
function loadScripts() {
|
function loadScripts() {
|
||||||
if (!MySQLProcessManager) {
|
if (!MySQLProcessManager) {
|
||||||
MySQLProcessManager = require(getScriptsPath('mysql-process-manager'));
|
MySQLProcessManager = require(getScriptsPath('mysql-process-manager'));
|
||||||
|
InfluxDBProcessManager = require(getScriptsPath('influxdb-process-manager'));
|
||||||
JavaRunner = require(getScriptsPath('java-runner'));
|
JavaRunner = require(getScriptsPath('java-runner'));
|
||||||
ConfigGenerator = require(getScriptsPath('config-generator'));
|
ConfigGenerator = require(getScriptsPath('config-generator'));
|
||||||
PortChecker = require(getScriptsPath('port-checker'));
|
PortChecker = require(getScriptsPath('port-checker'));
|
||||||
@@ -61,10 +56,12 @@ function loadScripts() {
|
|||||||
class Lifecycle {
|
class Lifecycle {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.mysqlProcessManager = null;
|
this.mysqlProcessManager = null;
|
||||||
|
this.influxdbProcessManager = null;
|
||||||
this.javaRunner = null;
|
this.javaRunner = null;
|
||||||
this.startupManager = null;
|
this.startupManager = null;
|
||||||
this.logWindowManager = null;
|
this.logWindowManager = null;
|
||||||
this.mysqlPort = null;
|
this.mysqlPort = null;
|
||||||
|
this.influxdbPort = null;
|
||||||
this.javaPort = null;
|
this.javaPort = null;
|
||||||
this.autoRefreshTimer = null;
|
this.autoRefreshTimer = null;
|
||||||
}
|
}
|
||||||
@@ -124,9 +121,38 @@ class Lifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InfluxDB 用于稳态趋势、补数等时序数据能力,必须早于后端服务启动。
|
||||||
|
this.logWindowManager.addLog('system', '▶ 步骤7: 启动 InfluxDB 进程管理器...');
|
||||||
|
this.startupManager.updateProgress('check-influxdb-port', { mysqlPort: this.mysqlPort });
|
||||||
|
|
||||||
|
this.influxdbProcessManager = new InfluxDBProcessManager(this.logWindowManager);
|
||||||
|
this.logWindowManager.addLog('system', '正在检查 InfluxDB 进程状态...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.influxdbPort = await this.influxdbProcessManager.ensureServiceRunning(
|
||||||
|
PortChecker.findAvailablePort.bind(PortChecker),
|
||||||
|
PortChecker.waitForPort.bind(PortChecker)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[lifecycle] InfluxDB process running on port: ${this.influxdbPort}`);
|
||||||
|
this.logWindowManager.addLog('success', `✅ InfluxDB 服务已就绪,端口: ${this.influxdbPort}`);
|
||||||
|
this.startupManager.updateProgress('wait-influxdb', {
|
||||||
|
mysqlPort: this.mysqlPort,
|
||||||
|
influxdbPort: this.influxdbPort
|
||||||
|
});
|
||||||
|
await this.sleep(500);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[lifecycle] InfluxDB error:', error);
|
||||||
|
this.logWindowManager.addLog('error', `InfluxDB 错误: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// 步骤5: 检测 Java 端口
|
// 步骤5: 检测 Java 端口
|
||||||
this.logWindowManager.addLog('system', '▶ 步骤7: 检测可用的 Java 端口(从18093开始)...');
|
this.logWindowManager.addLog('system', '▶ 步骤8: 检测可用的 Java 端口(从18093开始)...');
|
||||||
this.startupManager.updateProgress('check-java-port', { mysqlPort: this.mysqlPort });
|
this.startupManager.updateProgress('check-java-port', {
|
||||||
|
mysqlPort: this.mysqlPort,
|
||||||
|
influxdbPort: this.influxdbPort
|
||||||
|
});
|
||||||
|
|
||||||
this.javaPort = await PortChecker.findAvailablePort(18093, 100);
|
this.javaPort = await PortChecker.findAvailablePort(18093, 100);
|
||||||
|
|
||||||
@@ -136,7 +162,7 @@ class Lifecycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 步骤5.5: 检测 WebSocket 端口
|
// 步骤5.5: 检测 WebSocket 端口
|
||||||
this.logWindowManager.addLog('system', '▶ 步骤8: 检测可用的 WebSocket 端口(从7778开始)...');
|
this.logWindowManager.addLog('system', '▶ 步骤9: 检测可用的 WebSocket 端口(从7778开始)...');
|
||||||
|
|
||||||
this.websocketPort = await PortChecker.findAvailablePort(7778, 100);
|
this.websocketPort = await PortChecker.findAvailablePort(7778, 100);
|
||||||
|
|
||||||
@@ -161,14 +187,16 @@ class Lifecycle {
|
|||||||
logger.info(`[lifecycle] WebSocket will use port: ${this.websocketPort}`);
|
logger.info(`[lifecycle] WebSocket will use port: ${this.websocketPort}`);
|
||||||
this.startupManager.updateProgress('check-java-port', {
|
this.startupManager.updateProgress('check-java-port', {
|
||||||
mysqlPort: this.mysqlPort,
|
mysqlPort: this.mysqlPort,
|
||||||
|
influxdbPort: this.influxdbPort,
|
||||||
javaPort: this.javaPort
|
javaPort: this.javaPort
|
||||||
});
|
});
|
||||||
await this.sleep(500);
|
await this.sleep(500);
|
||||||
|
|
||||||
// 步骤6: 生成配置文件
|
// 步骤6: 生成配置文件
|
||||||
this.logWindowManager.addLog('system', '▶ 步骤9: 生成 Spring Boot 配置文件...');
|
this.logWindowManager.addLog('system', '▶ 步骤10: 生成 Spring Boot 配置文件...');
|
||||||
this.startupManager.updateProgress('generate-config', {
|
this.startupManager.updateProgress('generate-config', {
|
||||||
mysqlPort: this.mysqlPort,
|
mysqlPort: this.mysqlPort,
|
||||||
|
influxdbPort: this.influxdbPort,
|
||||||
javaPort: this.javaPort,
|
javaPort: this.javaPort,
|
||||||
websocketPort: this.websocketPort
|
websocketPort: this.websocketPort
|
||||||
});
|
});
|
||||||
@@ -176,6 +204,7 @@ class Lifecycle {
|
|||||||
const configGenerator = new ConfigGenerator();
|
const configGenerator = new ConfigGenerator();
|
||||||
const { configPath, dataPath } = await configGenerator.generateConfig({
|
const { configPath, dataPath } = await configGenerator.generateConfig({
|
||||||
mysqlPort: this.mysqlPort,
|
mysqlPort: this.mysqlPort,
|
||||||
|
influxdbPort: this.influxdbPort,
|
||||||
javaPort: this.javaPort,
|
javaPort: this.javaPort,
|
||||||
websocketPort: this.websocketPort,
|
websocketPort: this.websocketPort,
|
||||||
mysqlPassword: 'njcnpqs'
|
mysqlPassword: 'njcnpqs'
|
||||||
@@ -194,9 +223,10 @@ class Lifecycle {
|
|||||||
await this.sleep(500);
|
await this.sleep(500);
|
||||||
|
|
||||||
// 步骤7: 启动 Spring Boot
|
// 步骤7: 启动 Spring Boot
|
||||||
this.logWindowManager.addLog('system', '▶ 步骤10: 启动 Spring Boot 应用...');
|
this.logWindowManager.addLog('system', '▶ 步骤11: 启动 Spring Boot 应用...');
|
||||||
this.startupManager.updateProgress('start-java', {
|
this.startupManager.updateProgress('start-java', {
|
||||||
mysqlPort: this.mysqlPort,
|
mysqlPort: this.mysqlPort,
|
||||||
|
influxdbPort: this.influxdbPort,
|
||||||
javaPort: this.javaPort,
|
javaPort: this.javaPort,
|
||||||
dataPath: dataPath
|
dataPath: dataPath
|
||||||
});
|
});
|
||||||
@@ -204,9 +234,10 @@ class Lifecycle {
|
|||||||
await this.startSpringBoot(configPath, dataPath);
|
await this.startSpringBoot(configPath, dataPath);
|
||||||
|
|
||||||
// 步骤8: 等待 Spring Boot 就绪
|
// 步骤8: 等待 Spring Boot 就绪
|
||||||
this.logWindowManager.addLog('system', '▶ 步骤11: 等待 Spring Boot 就绪(最多60秒)...');
|
this.logWindowManager.addLog('system', '▶ 步骤12: 等待 Spring Boot 就绪(最多60秒)...');
|
||||||
this.startupManager.updateProgress('wait-java', {
|
this.startupManager.updateProgress('wait-java', {
|
||||||
mysqlPort: this.mysqlPort,
|
mysqlPort: this.mysqlPort,
|
||||||
|
influxdbPort: this.influxdbPort,
|
||||||
javaPort: this.javaPort,
|
javaPort: this.javaPort,
|
||||||
dataPath: dataPath
|
dataPath: dataPath
|
||||||
});
|
});
|
||||||
@@ -224,9 +255,10 @@ class Lifecycle {
|
|||||||
await this.sleep(1000);
|
await this.sleep(1000);
|
||||||
|
|
||||||
// 步骤9: 完成
|
// 步骤9: 完成
|
||||||
this.logWindowManager.addLog('system', '▶ 步骤12: 启动完成,准备显示主窗口...');
|
this.logWindowManager.addLog('system', '▶ 步骤13: 启动完成,准备显示主窗口...');
|
||||||
this.startupManager.updateProgress('done', {
|
this.startupManager.updateProgress('done', {
|
||||||
mysqlPort: this.mysqlPort,
|
mysqlPort: this.mysqlPort,
|
||||||
|
influxdbPort: this.influxdbPort,
|
||||||
javaPort: this.javaPort,
|
javaPort: this.javaPort,
|
||||||
dataPath: dataPath
|
dataPath: dataPath
|
||||||
});
|
});
|
||||||
@@ -235,6 +267,7 @@ class Lifecycle {
|
|||||||
this.logWindowManager.addLog('system', '='.repeat(60));
|
this.logWindowManager.addLog('system', '='.repeat(60));
|
||||||
this.logWindowManager.addLog('success', '✓ 电能质量运维工具 启动完成!所有服务正常运行');
|
this.logWindowManager.addLog('success', '✓ 电能质量运维工具 启动完成!所有服务正常运行');
|
||||||
this.logWindowManager.addLog('system', `✓ MySQL 端口: ${this.mysqlPort}`);
|
this.logWindowManager.addLog('system', `✓ MySQL 端口: ${this.mysqlPort}`);
|
||||||
|
this.logWindowManager.addLog('system', `✓ InfluxDB 端口: ${this.influxdbPort}`);
|
||||||
this.logWindowManager.addLog('system', `✓ Java 端口: ${this.javaPort}`);
|
this.logWindowManager.addLog('system', `✓ Java 端口: ${this.javaPort}`);
|
||||||
this.logWindowManager.addLog('system', `✓ WebSocket 端口: ${this.websocketPort}`);
|
this.logWindowManager.addLog('system', `✓ WebSocket 端口: ${this.websocketPort}`);
|
||||||
this.logWindowManager.addLog('system', `✓ 数据目录: ${dataPath}`);
|
this.logWindowManager.addLog('system', `✓ 数据目录: ${dataPath}`);
|
||||||
@@ -375,7 +408,26 @@ class Lifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止 MySQL 进程(进程模式)
|
// 停止数据库进程(进程模式)
|
||||||
|
// InfluxDB 只清理当前应用记录的 PID,避免影响用户本机其他 InfluxDB 实例。
|
||||||
|
if (this.influxdbProcessManager) {
|
||||||
|
try {
|
||||||
|
logger.info('[lifecycle] Stopping InfluxDB process...');
|
||||||
|
if (this.logWindowManager && this.logWindowManager.logWindow && !this.logWindowManager.logWindow.isDestroyed()) {
|
||||||
|
this.logWindowManager.addLog('system', '正在停止 InfluxDB...');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.influxdbProcessManager.stopInfluxDBProcess();
|
||||||
|
|
||||||
|
logger.info('[lifecycle] InfluxDB process stopped');
|
||||||
|
if (this.logWindowManager && this.logWindowManager.logWindow && !this.logWindowManager.logWindow.isDestroyed()) {
|
||||||
|
this.logWindowManager.addLog('success', 'InfluxDB 已停止');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[lifecycle] Failed to stop InfluxDB:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.mysqlProcessManager) {
|
if (this.mysqlProcessManager) {
|
||||||
try {
|
try {
|
||||||
logger.info('[lifecycle] Stopping MySQL process...');
|
logger.info('[lifecycle] Stopping MySQL process...');
|
||||||
@@ -427,12 +479,11 @@ class Lifecycle {
|
|||||||
// 启动Java应用
|
// 启动Java应用
|
||||||
this.javaRunner = new JavaRunner();
|
this.javaRunner = new JavaRunner();
|
||||||
|
|
||||||
// 开发环境:build/extraResources/java/entrance.jar
|
const { resolvePackagedRuntime } = require(getScriptsPath('path-utils'));
|
||||||
// 打包后:resources/extraResources/java/entrance.jar
|
const runtime = resolvePackagedRuntime();
|
||||||
const isDev = !process.resourcesPath;
|
const jarPath = runtime.isPackaged
|
||||||
const jarPath = isDev
|
? path.join(runtime.resourcesPath, 'extraResources', 'java', 'entrance.jar')
|
||||||
? path.join(__dirname, '..', 'build', 'extraResources', 'java', 'entrance.jar')
|
: path.join(runtime.baseDir, 'build', 'extraResources', 'java', 'entrance.jar');
|
||||||
: path.join(process.resourcesPath, 'extraResources', 'java', 'entrance.jar');
|
|
||||||
|
|
||||||
const logPath = path.join(dataPath, 'logs');
|
const logPath = path.join(dataPath, 'logs');
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"pinia-plugin-persistedstate": "^3.2.1",
|
"pinia-plugin-persistedstate": "^3.2.1",
|
||||||
"print-js": "^1.6.0",
|
"print-js": "^1.6.0",
|
||||||
"qs": "^6.11.2",
|
"qs": "^6.11.2",
|
||||||
|
"scichart": "^5.2.28",
|
||||||
"screenfull": "^6.0.2",
|
"screenfull": "^6.0.2",
|
||||||
"semver": "^7.3.5",
|
"semver": "^7.3.5",
|
||||||
"sortablejs": "^1.15.0",
|
"sortablejs": "^1.15.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import http from '@/api'
|
import http from '@/api'
|
||||||
import type { EventList } from './interface'
|
import type { EventList } from './interface'
|
||||||
|
import type { Waveform } from '@/api/tools/waveform/interface'
|
||||||
|
|
||||||
export const getTransientEventPage = (params: EventList.TransientPageParams) => {
|
export const getTransientEventPage = (params: EventList.TransientPageParams) => {
|
||||||
return http.post<EventList.PageResult<EventList.TransientEventRecord>>('/event/list/transient/page', params)
|
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}`)
|
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) => {
|
export const exportTransientEvents = (params: EventList.TransientPageParams) => {
|
||||||
return http.downloadWithHeaders('/event/list/transient/export', params)
|
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
|
startTimeEnd?: string
|
||||||
eventType?: string
|
eventType?: string
|
||||||
phase?: string
|
phase?: string
|
||||||
eventDescribe?: string
|
event_describe?: string
|
||||||
durationMin?: number
|
durationMin?: number
|
||||||
durationMax?: number
|
durationMax?: number
|
||||||
featureAmplitudeMin?: number
|
featureAmplitudeMin?: number
|
||||||
featureAmplitudeMax?: number
|
featureAmplitudeMax?: number
|
||||||
|
severityMin?: number
|
||||||
|
severityMax?: number
|
||||||
fileFlag?: number
|
fileFlag?: number
|
||||||
dealFlag?: number
|
dealFlag?: number
|
||||||
lineIds?: string[]
|
lineIds?: string[]
|
||||||
@@ -28,25 +30,25 @@ export namespace EventList {
|
|||||||
lineName?: string
|
lineName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransientWaveformExportParams {
|
||||||
|
eventIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface TransientEventRecord {
|
export interface TransientEventRecord {
|
||||||
eventId: string
|
eventId: string
|
||||||
measurementPointId?: string
|
measurementPointId?: string
|
||||||
eventType?: string
|
eventType?: string
|
||||||
eventTypeName?: string
|
eventTypeName?: string
|
||||||
equipmentName?: string
|
equipmentName?: string
|
||||||
|
mac?: string
|
||||||
engineeringName?: string
|
engineeringName?: string
|
||||||
projectName?: string
|
projectName?: string
|
||||||
startTime?: string
|
startTime?: string
|
||||||
lineName?: string
|
lineName?: string
|
||||||
event_describe?: string
|
event_describe?: string
|
||||||
eventDescribe?: string
|
|
||||||
eventDescription?: string
|
|
||||||
eventDesc?: string
|
|
||||||
description?: string
|
|
||||||
describe?: string
|
|
||||||
remark?: string
|
|
||||||
sagsource?: string
|
sagsource?: string
|
||||||
phase?: string
|
phase?: string
|
||||||
|
severity?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
featureAmplitude?: number
|
featureAmplitude?: number
|
||||||
wavePath?: string
|
wavePath?: string
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill'
|
|||||||
|
|
||||||
export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
silentStatusError?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@@ -109,6 +110,10 @@ class RequestHttp {
|
|||||||
}
|
}
|
||||||
// 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
|
// 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
|
||||||
if (data.code && data.code !== ResultEnum.SUCCESS) {
|
if (data.code && data.code !== ResultEnum.SUCCESS) {
|
||||||
|
if ((response.config as CustomAxiosRequestConfig).silentStatusError) {
|
||||||
|
return Promise.reject(data)
|
||||||
|
}
|
||||||
|
|
||||||
if (data.message.includes('&')) {
|
if (data.message.includes('&')) {
|
||||||
let formattedMessage = data.message.split('&').join('<br>')
|
let formattedMessage = data.message.split('&').join('<br>')
|
||||||
if (data.message.includes(':')) {
|
if (data.message.includes(':')) {
|
||||||
@@ -147,7 +152,9 @@ class RequestHttp {
|
|||||||
if (error.message.indexOf('timeout') !== -1) ElMessage.error('请求超时!请您稍后重试')
|
if (error.message.indexOf('timeout') !== -1) ElMessage.error('请求超时!请您稍后重试')
|
||||||
if (error.message.indexOf('Network Error') !== -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')
|
if (!window.navigator.onLine) router.replace('/500')
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
@@ -10,9 +10,37 @@ export const getSteadyTrendIndicatorTree = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const querySteadyTrend = (params: SteadyDataView.SteadyTrendQueryParams) => {
|
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) => {
|
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 querySteadyChecksquareTasks = (params: SteadyDataView.SteadyChecksquareTaskQueryParams) => {
|
||||||
|
return http.post<SteadyDataView.PageResult<SteadyDataView.SteadyChecksquareTask>>('/steady/checksquare/query', params, {
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSteadyChecksquareTask = (params: SteadyDataView.SteadyChecksquareCreateParams) => {
|
||||||
|
return http.post<SteadyDataView.SteadyChecksquareTask>('/steady/checksquare/create', params, {
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteSteadyChecksquareTasks = (taskIds: SteadyDataView.SteadyChecksquareDeleteParams) => {
|
||||||
|
return http.post<boolean>('/steady/checksquare/delete', taskIds, {
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSteadyChecksquareDetail = (taskId: string) => {
|
||||||
|
return http.get<SteadyDataView.SteadyChecksquareQueryResult>('/steady/checksquare/detail', { taskId }, { loading: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSteadyChecksquareItemDetail = (params: SteadyDataView.SteadyChecksquareItemDetailParams) => {
|
||||||
|
return http.get<SteadyDataView.SteadyChecksquareItemDetail>('/steady/checksquare/item-detail', params, {
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
export namespace SteadyDataView {
|
export namespace SteadyDataView {
|
||||||
|
export interface PageResult<T> {
|
||||||
|
records: T[]
|
||||||
|
current: number
|
||||||
|
size: number
|
||||||
|
total: number
|
||||||
|
pages?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SteadyLedgerNode {
|
export interface SteadyLedgerNode {
|
||||||
id: string
|
id: string
|
||||||
parentId?: string
|
parentId?: string
|
||||||
@@ -40,10 +48,8 @@ export namespace SteadyDataView {
|
|||||||
lineIds: string[]
|
lineIds: string[]
|
||||||
indicatorCodes: string[]
|
indicatorCodes: string[]
|
||||||
statTypes: SteadyTrendStatType[]
|
statTypes: SteadyTrendStatType[]
|
||||||
phases: string[]
|
|
||||||
timeStart: string
|
timeStart: string
|
||||||
timeEnd: string
|
timeEnd: string
|
||||||
bucket?: string
|
|
||||||
qualityFlag?: number
|
qualityFlag?: number
|
||||||
harmonicOrders?: number[]
|
harmonicOrders?: number[]
|
||||||
}
|
}
|
||||||
@@ -61,6 +67,7 @@ export namespace SteadyDataView {
|
|||||||
indicatorName?: string
|
indicatorName?: string
|
||||||
seriesName?: string
|
seriesName?: string
|
||||||
phase?: string
|
phase?: string
|
||||||
|
harmonicOrder?: number
|
||||||
statType?: SteadyTrendStatType
|
statType?: SteadyTrendStatType
|
||||||
unit?: string
|
unit?: string
|
||||||
points: SteadyTrendPoint[]
|
points: SteadyTrendPoint[]
|
||||||
@@ -72,7 +79,151 @@ export namespace SteadyDataView {
|
|||||||
sourcePointCount?: number
|
sourcePointCount?: number
|
||||||
displayPointCount?: number
|
displayPointCount?: number
|
||||||
loadableDays?: string[]
|
loadableDays?: string[]
|
||||||
|
queryTimeStart?: string
|
||||||
|
queryTimeEnd?: string
|
||||||
|
queryCompleted?: boolean
|
||||||
series: SteadyTrendSeries[]
|
series: SteadyTrendSeries[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareTaskQueryParams {
|
||||||
|
pageNum?: number
|
||||||
|
pageSize?: number
|
||||||
|
lineId?: string
|
||||||
|
lineName?: string
|
||||||
|
indicatorCode?: string
|
||||||
|
timeStart?: string
|
||||||
|
timeEnd?: string
|
||||||
|
hasAbnormal?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareCreateParams {
|
||||||
|
lineId: string
|
||||||
|
indicatorCodes: string[]
|
||||||
|
timeStart: string
|
||||||
|
timeEnd: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SteadyChecksquareDeleteParams = string[]
|
||||||
|
|
||||||
|
export interface SteadyChecksquareTask {
|
||||||
|
taskId: string
|
||||||
|
taskNo?: string
|
||||||
|
lineId?: string
|
||||||
|
lineName?: string
|
||||||
|
timeStart?: string
|
||||||
|
timeEnd?: string
|
||||||
|
intervalMinutes?: number
|
||||||
|
taskStatus?: 'RUNNING' | 'SUCCESS' | 'FAIL' | string
|
||||||
|
itemCount?: number
|
||||||
|
abnormalItemCount?: number
|
||||||
|
minDataIntegrity?: number | null
|
||||||
|
createTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareSegment {
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
status: 'NORMAL' | 'MISSING' | string
|
||||||
|
harmonicOrder?: number | null
|
||||||
|
missingPointCount?: number
|
||||||
|
durationMinutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareStatSummary {
|
||||||
|
statType: SteadyTrendStatType
|
||||||
|
supported: boolean
|
||||||
|
hasData?: boolean
|
||||||
|
expectedPointCount?: number
|
||||||
|
actualPointCount?: number
|
||||||
|
missingPointCount?: number
|
||||||
|
dataIntegrity?: number | null
|
||||||
|
dataIntegrityText?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareStatDetail {
|
||||||
|
statType: SteadyTrendStatType
|
||||||
|
supported: boolean
|
||||||
|
segments: SteadyChecksquareSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareItem {
|
||||||
|
itemId?: string
|
||||||
|
itemKey: string
|
||||||
|
indicatorCode: string
|
||||||
|
indicatorName?: string
|
||||||
|
harmonicOrder?: number | null
|
||||||
|
hasData?: boolean
|
||||||
|
expectedPointCount?: number
|
||||||
|
actualPointCount?: number
|
||||||
|
missingPointCount?: number
|
||||||
|
dataIntegrity?: number | null
|
||||||
|
dataIntegrityText?: string | null
|
||||||
|
abnormal?: boolean
|
||||||
|
abnormalPointCount?: number
|
||||||
|
harmonicParityAbnormal?: boolean
|
||||||
|
harmonicParityAbnormalPointCount?: number
|
||||||
|
statSummaries: SteadyChecksquareStatSummary[]
|
||||||
|
statDetails: SteadyChecksquareStatDetail[]
|
||||||
|
children?: SteadyChecksquareItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareQueryResult {
|
||||||
|
taskId?: string
|
||||||
|
taskNo?: string
|
||||||
|
lineId: string
|
||||||
|
lineName?: string
|
||||||
|
timeStart: string
|
||||||
|
timeEnd: string
|
||||||
|
intervalMinutes?: number
|
||||||
|
taskStatus?: 'RUNNING' | 'SUCCESS' | 'FAIL' | string
|
||||||
|
itemCount?: number
|
||||||
|
abnormalItemCount?: number
|
||||||
|
minDataIntegrity?: number | null
|
||||||
|
createTime?: string
|
||||||
|
items: SteadyChecksquareItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SteadyChecksquareDetailType = 'SEGMENT' | 'VALUE_ORDER' | 'HARMONIC_PARITY'
|
||||||
|
|
||||||
|
export interface SteadyChecksquareItemDetailParams {
|
||||||
|
itemId: string
|
||||||
|
detailType: SteadyChecksquareDetailType
|
||||||
|
statType?: SteadyTrendStatType
|
||||||
|
pageNum?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareValueOrderDetail {
|
||||||
|
time: string
|
||||||
|
phase?: string
|
||||||
|
harmonicOrder?: number | null
|
||||||
|
maxValue?: number | null
|
||||||
|
minValue?: number | null
|
||||||
|
avgValue?: number | null
|
||||||
|
cp95Value?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareHarmonicParityDetail {
|
||||||
|
time: string
|
||||||
|
phase?: string
|
||||||
|
statType?: SteadyTrendStatType
|
||||||
|
evenHarmonicOrder?: number
|
||||||
|
evenValue?: number | null
|
||||||
|
oddHarmonicOrders?: number[]
|
||||||
|
oddValues?: number[]
|
||||||
|
oddMedianValue?: number | null
|
||||||
|
thresholdMultiplier?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareItemDetail {
|
||||||
|
itemId: string
|
||||||
|
detailType: SteadyChecksquareDetailType
|
||||||
|
statType?: SteadyTrendStatType | null
|
||||||
|
pageNum?: number | null
|
||||||
|
pageSize?: number | null
|
||||||
|
total?: number | null
|
||||||
|
segments: SteadyChecksquareSegment[]
|
||||||
|
valueOrderDetails: SteadyChecksquareValueOrderDetail[]
|
||||||
|
harmonicParityDetails: SteadyChecksquareHarmonicParityDetail[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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_ROUTE_PATHS = ['/addData', '/api/addData'] as const
|
||||||
const ADD_DATA_BASE_URL = String(import.meta.env.VITE_API_URL || '').trim()
|
const ADD_DATA_BASE_URL = String(import.meta.env.VITE_API_URL || '').trim()
|
||||||
|
const ADD_DATA_INFLUX_STORAGE_TYPE = 'INFLUXDB'
|
||||||
|
|
||||||
const resolveDevProxyTarget = () => {
|
const resolveDevProxyTarget = () => {
|
||||||
const proxyConfig = import.meta.env.VITE_PROXY
|
const proxyConfig = import.meta.env.VITE_PROXY
|
||||||
@@ -81,16 +82,25 @@ const requestAddData = async <T>(
|
|||||||
throw lastError
|
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) => {
|
export const getAddDataPreview = (params: AddData.TaskRequestParams) => {
|
||||||
return requestAddData<AddData.PreviewResponse>('post', '/task/preview', params)
|
return requestAddData<AddData.PreviewResponse>('post', '/task/preview', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createAddDataTask = (params: AddData.TaskRequestParams) => {
|
export const createAddDataTask = (params: AddData.TaskRequestParams, storageType?: AddData.StorageType) => {
|
||||||
return requestAddData<AddData.CreateTaskResponse>('post', '/task/create', params)
|
return requestAddData<AddData.CreateTaskResponse>('post', `${resolveTaskPathPrefix(storageType)}/create`, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAddDataTaskStatus = (taskId: string | number) => {
|
export const getAddDataTaskStatus = (taskId: string | number, storageType?: AddData.StorageType) => {
|
||||||
return requestAddData<AddData.TaskStatusResponse>('get', `/task/status/${taskId}`)
|
return requestAddData<AddData.TaskStatusResponse>('get', `${resolveTaskPathPrefix(storageType)}/status/${taskId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAddDataTemplateList = () => {
|
export const getAddDataTemplateList = () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export namespace AddData {
|
export namespace AddData {
|
||||||
export type LineMode = 'single' | 'multiple'
|
export type LineMode = 'single' | 'multiple'
|
||||||
export type IntervalMinutes = 1 | 3 | 5 | 10
|
export type IntervalMinutes = 1 | 3 | 5 | 10
|
||||||
|
export type StorageType = 'MYSQL' | 'INFLUXDB' | (string & {})
|
||||||
export type TaskStatus = 'WAITING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | (string & {})
|
export type TaskStatus = 'WAITING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | (string & {})
|
||||||
|
|
||||||
export interface TaskRequestParams {
|
export interface TaskRequestParams {
|
||||||
@@ -12,12 +13,18 @@ export namespace AddData {
|
|||||||
|
|
||||||
export interface TaskFormModel {
|
export interface TaskFormModel {
|
||||||
lineMode: LineMode
|
lineMode: LineMode
|
||||||
|
storageType: StorageType
|
||||||
lineIds: string[]
|
lineIds: string[]
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
intervalMinutes: IntervalMinutes
|
intervalMinutes: IntervalMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorageTypeItem {
|
||||||
|
code?: StorageType
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PreviewTableStat {
|
export interface PreviewTableStat {
|
||||||
tableName?: string
|
tableName?: string
|
||||||
timePointCount?: number | string
|
timePointCount?: number | string
|
||||||
@@ -106,4 +113,9 @@ export namespace AddData {
|
|||||||
cp95ValueRule: string
|
cp95ValueRule: string
|
||||||
decimalScaleText: 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 interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||||
|
|
||||||
const expectations = [
|
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 devType', /devType:\s*params\.dev_type/],
|
||||||
['equipment payload maps devModel', /devModel:\s*params\.dev_model/],
|
['equipment payload maps devModel', /devModel:\s*params\.dev_model/],
|
||||||
['equipment payload maps devAccessMethod', /devAccessMethod:\s*params\.dev_access_method/],
|
['equipment payload maps devAccessMethod', /devAccessMethod:\s*params\.dev_access_method/],
|
||||||
['equipment payload maps nodeId', /nodeId:\s*params\.node_id/],
|
['equipment payload maps nodeId', /nodeId:\s*params\.node_id/],
|
||||||
['equipment payload maps nodeProcess', /nodeProcess:\s*resolveOptionalNumber\(params\.node_process\)/],
|
['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 lineNo', /lineNo:\s*params\.line_no/],
|
||||||
['line payload maps volGrade', /volGrade:\s*params\.vol_grade/],
|
['line payload maps volGrade', /volGrade:\s*params\.vol_grade/],
|
||||||
['line payload maps ctRatio', /ctRatio:\s*params\.ct_ratio/],
|
['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'/]
|
['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) {
|
if (failures.length) {
|
||||||
console.error('addLedger API_DEBUG contract check failed:')
|
console.error('addLedger API_DEBUG contract check failed:')
|
||||||
|
|||||||
@@ -27,41 +27,55 @@ const toAddLedgerProjectPayload = (params: AddLedger.ProjectForm) => ({
|
|||||||
description: params.description
|
description: params.description
|
||||||
})
|
})
|
||||||
|
|
||||||
const toAddLedgerEquipmentPayload = (params: AddLedger.EquipmentForm) => ({
|
const toAddLedgerEquipmentPayload = (params: AddLedger.EquipmentForm) => {
|
||||||
id: resolveOptionalText(params.id),
|
// 后端仍接收 mac/ndid 两个字段,前端统一用“装置网络参数”作为唯一来源。
|
||||||
projectId: resolveOptionalText(params.projectId || params.parentId),
|
const networkParam = resolveOptionalText(params.mac)
|
||||||
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 toAddLedgerLinePayload = (params: AddLedger.LineForm) => ({
|
return {
|
||||||
lineId: resolveOptionalText(params.line_id || params.id),
|
id: resolveOptionalText(params.id),
|
||||||
deviceId: resolveOptionalText(params.deviceId || params.parentId),
|
projectId: resolveOptionalText(params.projectId || params.parentId),
|
||||||
name: params.name,
|
name: params.name,
|
||||||
lineNo: params.line_no,
|
ndid: networkParam,
|
||||||
conType: params.conType,
|
mac: networkParam,
|
||||||
volGrade: params.vol_grade,
|
devType: params.dev_type,
|
||||||
position: params.position,
|
devModel: params.dev_model,
|
||||||
ctRatio: params.ct_ratio,
|
devAccessMethod: params.dev_access_method,
|
||||||
ct2Ratio: params.ct2_ratio,
|
nodeId: params.node_id,
|
||||||
ptRatio: params.pt_ratio,
|
nodeProcess: resolveOptionalNumber(params.node_process),
|
||||||
pt2Ratio: params.pt2_ratio,
|
upgrade: params.upgrade
|
||||||
shortCircuitCapacity: params.short_circuit_capacity,
|
}
|
||||||
basicCapacity: params.basic_capacity,
|
}
|
||||||
protocolCapacity: params.protocol_capacity,
|
|
||||||
devCapacity: params.dev_capacity,
|
const toAddLedgerLinePayload = (params: AddLedger.LineForm) => {
|
||||||
monitorObj: params.monitor_obj,
|
const lineId = resolveOptionalText(params.id || params.line_id)
|
||||||
isGovern: params.is_govern,
|
const payload = {
|
||||||
monitorUser: params.monitor_user,
|
deviceId: resolveOptionalText(params.deviceId || params.parentId),
|
||||||
isImportant: params.is_important
|
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 resolveDevProxyTarget = () => {
|
||||||
const proxyConfig = import.meta.env.VITE_PROXY
|
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))
|
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) => {
|
export const saveAddLedgerLine = (params: AddLedger.LineForm) => {
|
||||||
return requestAddLedger<AddLedger.LineForm>('post', '/line/save', toAddLedgerLinePayload(params))
|
return requestAddLedger<AddLedger.LineForm>('post', '/line/save', toAddLedgerLinePayload(params))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,48 @@ export namespace AddLedger {
|
|||||||
upgrade?: number
|
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 {
|
export interface LineForm {
|
||||||
id?: string
|
id?: string
|
||||||
line_id?: string
|
line_id?: string
|
||||||
@@ -85,10 +127,12 @@ export namespace AddLedger {
|
|||||||
basic_capacity?: number
|
basic_capacity?: number
|
||||||
protocol_capacity?: number
|
protocol_capacity?: number
|
||||||
dev_capacity?: number
|
dev_capacity?: number
|
||||||
|
lineType?: number
|
||||||
monitor_obj?: string
|
monitor_obj?: string
|
||||||
is_govern?: number
|
is_govern?: number
|
||||||
monitor_user?: string
|
monitor_user?: string
|
||||||
is_important?: number
|
is_important?: number
|
||||||
|
overlimit?: OverlimitDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NodeDetail = EngineeringForm | ProjectForm | EquipmentForm | LineForm
|
export type NodeDetail = EngineeringForm | ProjectForm | EquipmentForm | LineForm
|
||||||
|
|||||||
@@ -9,6 +9,74 @@ const buildIcdFormData = (icdFile: File) => {
|
|||||||
return formData
|
return formData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildIcdPathFormData = (icdFile: File, request: MmsMapping.CreateIcdPathRequest | MmsMapping.UpdateIcdPathRequest) => {
|
||||||
|
const formData = buildIcdFormData(icdFile)
|
||||||
|
|
||||||
|
formData.append('request', new Blob([JSON.stringify(request)], { type: 'application/json' }))
|
||||||
|
|
||||||
|
return formData
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listDeviceTypesApi = () => {
|
||||||
|
return http.get<MmsMapping.DeviceType[]>('/api/device-types')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDeviceTypeApi = (params: MmsMapping.CreateDeviceTypeRequest) => {
|
||||||
|
return http.post<boolean>('/api/device-types/add', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateDeviceTypeApi = (params: MmsMapping.UpdateDeviceTypeRequest) => {
|
||||||
|
return http.post<boolean>('/api/device-types/update', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteDeviceTypesApi = (ids: string[]) => {
|
||||||
|
return http.post<boolean>('/api/device-types/delete', ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveIcdCheckResultApi = (id: string, params: MmsMapping.SaveIcdCheckResultRequest) => {
|
||||||
|
return http.post<boolean>(`/api/device-types/${id}/icd-check-result`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pqdifCheckApi = (id: string) => {
|
||||||
|
return http.post<MmsMapping.PqdifCheckPlaceholder>(`/api/device-types/${id}/pqdif-check`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listIcdPathsApi = (params: MmsMapping.IcdPathListRequest) => {
|
||||||
|
return http.post<MmsMapping.IcdPathRecord[]>('/api/mms-mapping/icd-paths/list', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createIcdPathApi = (params: MmsMapping.CreateIcdPathRequest) => {
|
||||||
|
return http.post<boolean>('/api/mms-mapping/icd-paths/add', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createIcdPathWithFileApi = (params: MmsMapping.CreateIcdPathWithFileRequest) => {
|
||||||
|
return http.post<boolean>('/api/mms-mapping/icd-paths/add', buildIcdPathFormData(params.icdFile, params.request), {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateIcdPathApi = (params: MmsMapping.UpdateIcdPathRequest) => {
|
||||||
|
return http.post<boolean>('/api/mms-mapping/icd-paths/update', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateIcdPathWithFileApi = (params: MmsMapping.UpdateIcdPathWithFileRequest) => {
|
||||||
|
return http.post<boolean>('/api/mms-mapping/icd-paths/update', buildIcdPathFormData(params.icdFile, params.request), {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteIcdPathsApi = (ids: string[]) => {
|
||||||
|
return http.post<boolean>('/api/mms-mapping/icd-paths/delete', ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveIcdPathCheckResultApi = (id: string, params: MmsMapping.SaveIcdPathCheckResultRequest) => {
|
||||||
|
return http.post<boolean>(`/api/mms-mapping/icd-paths/${id}/icd-check-result`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkIcdJsonConsistencyApi = (params: MmsMapping.IcdJsonConsistencyCheckRequest) => {
|
||||||
|
return http.post<MmsMapping.IcdJsonConsistencyCheckResponse>('/api/mms-mapping/check-icd-json-consistency', params)
|
||||||
|
}
|
||||||
|
|
||||||
export const getIcdApi = (params: MmsMapping.GetIcdParams) => {
|
export const getIcdApi = (params: MmsMapping.GetIcdParams) => {
|
||||||
const formData = buildIcdFormData(params.icdFile)
|
const formData = buildIcdFormData(params.icdFile)
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export namespace MmsMapping {
|
|||||||
|
|
||||||
export interface GetXmlFromJsonRequestPayload {
|
export interface GetXmlFromJsonRequestPayload {
|
||||||
mappingJson: string
|
mappingJson: string
|
||||||
|
configType?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetXmlFromJsonParams {
|
export interface GetXmlFromJsonParams {
|
||||||
@@ -128,4 +129,118 @@ export namespace MmsMapping {
|
|||||||
version: string
|
version: string
|
||||||
author: string
|
author: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceType {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
icdId?: string
|
||||||
|
icdName?: string
|
||||||
|
icdPath?: string
|
||||||
|
icdResult?: number
|
||||||
|
icdMsg?: string
|
||||||
|
power?: string
|
||||||
|
devVolt?: number
|
||||||
|
devCurr?: number
|
||||||
|
devChns?: number
|
||||||
|
waveCmd?: string
|
||||||
|
reportName?: string
|
||||||
|
canCheckIcd?: boolean
|
||||||
|
canCheckPqdif?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDeviceTypeRequest {
|
||||||
|
name: string
|
||||||
|
icd?: string
|
||||||
|
power?: string
|
||||||
|
devVolt?: number
|
||||||
|
devCurr?: number
|
||||||
|
devChns?: number
|
||||||
|
waveCmd?: string
|
||||||
|
reportName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDeviceTypeRequest extends CreateDeviceTypeRequest {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveIcdCheckResultRequest {
|
||||||
|
mappingJson?: string
|
||||||
|
xml?: string
|
||||||
|
result: number
|
||||||
|
msg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IcdPathRecord {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
path?: string
|
||||||
|
angle?: number
|
||||||
|
usePhaseIndex?: number
|
||||||
|
state?: number
|
||||||
|
jsonStr?: string
|
||||||
|
xmlStr?: string
|
||||||
|
result?: number
|
||||||
|
msg?: string
|
||||||
|
type?: number
|
||||||
|
referenceIcdId?: string
|
||||||
|
createBy?: string
|
||||||
|
createTime?: string
|
||||||
|
updateBy?: string
|
||||||
|
updateTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IcdPathListRequest {
|
||||||
|
keyword?: string
|
||||||
|
type?: number
|
||||||
|
result?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIcdPathRequest {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
angle?: number
|
||||||
|
usePhaseIndex?: number
|
||||||
|
type?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIcdPathWithFileRequest {
|
||||||
|
icdFile: File
|
||||||
|
request: CreateIcdPathRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateIcdPathRequest extends CreateIcdPathRequest {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateIcdPathWithFileRequest {
|
||||||
|
icdFile: File
|
||||||
|
request: UpdateIcdPathRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveIcdPathCheckResultRequest {
|
||||||
|
mappingJson?: string
|
||||||
|
xml?: string
|
||||||
|
result?: number
|
||||||
|
msg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IcdJsonConsistencyCheckRequest {
|
||||||
|
checkedJson: string
|
||||||
|
standardJson: string
|
||||||
|
saveToDisk?: boolean
|
||||||
|
outputDir?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IcdJsonConsistencyCheckResponse {
|
||||||
|
result?: number
|
||||||
|
message?: string
|
||||||
|
issues?: string[]
|
||||||
|
issuesJson?: string
|
||||||
|
correctedJson?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PqdifCheckPlaceholder {
|
||||||
|
status?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang='ts' name='Grid'>
|
<script setup lang='ts' name='Grid'>
|
||||||
|
import type { VNode, VNodeArrayChildren } from 'vue'
|
||||||
import type { BreakPoint } from './interface/index'
|
import type { BreakPoint } from './interface/index'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -99,11 +100,12 @@ const findIndex = () => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let find = false
|
let find = false
|
||||||
fields.reduce((prev = 0, current, index) => {
|
fields.reduce((prev: number, current: unknown, index: number) => {
|
||||||
prev +=
|
prev +=
|
||||||
((current as VNode)!.props![breakPoint.value]?.span ?? (current as VNode)!.props?.span ?? 1) +
|
((current as VNode)!.props![breakPoint.value]?.span ?? (current as VNode)!.props?.span ?? 1) +
|
||||||
((current as VNode)!.props![breakPoint.value]?.offset ?? (current as VNode)!.props?.offset ?? 0)
|
((current as VNode)!.props![breakPoint.value]?.offset ?? (current as VNode)!.props?.offset ?? 0)
|
||||||
if (Number(prev) >= props.collapsedRows * gridCols.value - suffixCols) {
|
// 刚好填满首行时仍应显示当前项,只有超过可用列数才进入折叠。
|
||||||
|
if (Number(prev) > props.collapsedRows * gridCols.value - suffixCols) {
|
||||||
hiddenIndex.value = index
|
hiddenIndex.value = index
|
||||||
find = true
|
find = true
|
||||||
throw 'find it'
|
throw 'find it'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
|
v-if="!column.search?.render"
|
||||||
:is="column.search?.render ?? `el-${column.search?.el}`"
|
:is="column.search?.render ?? `el-${column.search?.el}`"
|
||||||
v-bind="{ ...handleSearchProps, ...placeholder, searchParam: _searchParam, clearable }"
|
v-bind="{ ...handleSearchProps, ...placeholder, searchParam: _searchParam, clearable }"
|
||||||
v-model.trim="_searchParam[column.search?.key ?? handleProp(column.prop!)]"
|
v-model.trim="_searchParam[column.search?.key ?? handleProp(column.prop!)]"
|
||||||
@@ -20,12 +21,19 @@
|
|||||||
</template>
|
</template>
|
||||||
<slot v-else></slot>
|
<slot v-else></slot>
|
||||||
</component>
|
</component>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="column.search.render"
|
||||||
|
v-bind="{ ...handleSearchProps, ...placeholder, searchParam: _searchParam, clearable }"
|
||||||
|
:data="column.search?.el === 'tree-select' ? columnEnum : []"
|
||||||
|
:options="['cascader', 'select-v2'].includes(column.search?.el!) ? columnEnum : []"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="SearchFormItem">
|
<script setup lang="ts" name="SearchFormItem">
|
||||||
import { computed, inject, ref } from "vue";
|
import { computed, inject, ref } from "vue";
|
||||||
import { handleProp } from "@/utils";
|
import { handleProp } from "@/utils";
|
||||||
import { ColumnProps } from "@/components/ProTable/interface";
|
import type { ColumnProps } from "@/components/ProTable/interface";
|
||||||
|
|
||||||
interface SearchFormItem {
|
interface SearchFormItem {
|
||||||
column: ColumnProps;
|
column: ColumnProps;
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang='ts' name='SearchForm'>
|
<script setup lang='ts' name='SearchForm'>
|
||||||
import { ColumnProps } from '@/components/ProTable/interface'
|
import type { ColumnProps } from '@/components/ProTable/interface'
|
||||||
import { BreakPoint } from '@/components/Grid/interface'
|
import type { BreakPoint } from '@/components/Grid/interface'
|
||||||
import { Delete, Search, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
import { Delete, Search, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
||||||
import SearchFormItem from './components/SearchFormItem.vue'
|
import SearchFormItem from './components/SearchFormItem.vue'
|
||||||
import Grid from '@/components/Grid/index.vue'
|
import Grid from '@/components/Grid/index.vue'
|
||||||
@@ -80,15 +80,15 @@ const breakPoint = computed<BreakPoint>(() => gridRef.value?.breakPoint)
|
|||||||
// 判断是否显示 展开/合并 按钮
|
// 判断是否显示 展开/合并 按钮
|
||||||
const showCollapse = computed(() => {
|
const showCollapse = computed(() => {
|
||||||
let show = false
|
let show = false
|
||||||
|
const searchColCount =
|
||||||
|
typeof props.searchCol !== 'number' ? props.searchCol[breakPoint.value] : props.searchCol
|
||||||
|
const firstRowSearchCols = Math.max(searchColCount - 1, 1)
|
||||||
|
|
||||||
props.columns.reduce((prev, current) => {
|
props.columns.reduce((prev, current) => {
|
||||||
prev +=
|
prev +=
|
||||||
(current.search![breakPoint.value]?.span ?? current.search?.span ?? 1) +
|
(current.search![breakPoint.value]?.span ?? current.search?.span ?? 1) +
|
||||||
(current.search![breakPoint.value]?.offset ?? current.search?.offset ?? 0)
|
(current.search![breakPoint.value]?.offset ?? current.search?.offset ?? 0)
|
||||||
if (typeof props.searchCol !== 'number') {
|
if (prev > firstRowSearchCols) show = true
|
||||||
if (prev >= props.searchCol[breakPoint.value]) show = true
|
|
||||||
} else {
|
|
||||||
if (prev >= props.searchCol) show = true
|
|
||||||
}
|
|
||||||
return prev
|
return prev
|
||||||
}, 0)
|
}, 0)
|
||||||
return show
|
return show
|
||||||
|
|||||||
@@ -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 resolveCurrentDataZoomRange = (zoomPayload: ChartDataZoomPayload) => {
|
||||||
const dataZoomOptions = chart?.getOption?.()?.dataZoom
|
const dataZoomOptions = chart?.getOption?.()?.dataZoom
|
||||||
const dataZoomList = Array.isArray(dataZoomOptions) ? dataZoomOptions : dataZoomOptions ? [dataZoomOptions] : []
|
const dataZoomList = Array.isArray(dataZoomOptions) ? dataZoomOptions : dataZoomOptions ? [dataZoomOptions] : []
|
||||||
@@ -140,6 +191,9 @@ const resolveChartDataZoomRange = (zoomPayload: ChartDataZoomPayload) => {
|
|||||||
const valueRange = resolveZoomRangeFromAxisValues(zoomPayload?.startValue, zoomPayload?.endValue)
|
const valueRange = resolveZoomRangeFromAxisValues(zoomPayload?.startValue, zoomPayload?.endValue)
|
||||||
if (valueRange) return valueRange
|
if (valueRange) return valueRange
|
||||||
|
|
||||||
|
const timeValueRange = resolveZoomRangeFromTimeAxisValues(zoomPayload?.startValue, zoomPayload?.endValue)
|
||||||
|
if (timeValueRange) return timeValueRange
|
||||||
|
|
||||||
return resolveCurrentDataZoomRange(zoomPayload)
|
return resolveCurrentDataZoomRange(zoomPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,9 +253,18 @@ const resetChartCursor = () => {
|
|||||||
isPanPointerDown = false
|
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 updatePanCursor = (event: { offsetX: number; offsetY: number }) => {
|
||||||
const viewportRoot = getChartViewportRoot()
|
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')) {
|
if (!viewportRoot || (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark')) {
|
||||||
resetChartCursor()
|
resetChartCursor()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ export const DICT_CODES = {
|
|||||||
USER_STATE: 'state',
|
USER_STATE: 'state',
|
||||||
EVENT_TYPE: 'event_type',
|
EVENT_TYPE: 'event_type',
|
||||||
LEDGER_DEVICE_TYPE: 'ledger_device_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',
|
||||||
|
DEVICE_TYPE_WORK_POWER: 'Dev_Power',
|
||||||
|
DEVICE_TYPE_CHANNEL_COUNT: 'Dev_Chns',
|
||||||
|
DEVICE_TYPE_RATED_VOLTAGE: 'Dev_Volt',
|
||||||
|
DEVICE_TYPE_RATED_CURRENT: 'Dev_Curr'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type DictCode = (typeof DICT_CODES)[keyof typeof DICT_CODES]
|
export type DictCode = (typeof DICT_CODES)[keyof typeof DICT_CODES]
|
||||||
|
|||||||
@@ -89,12 +89,6 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncHomeStateWithMenus()
|
|
||||||
logRouterPerf('before-each-menu-sync', guardStart, {
|
|
||||||
path: to.path,
|
|
||||||
from: from.path,
|
|
||||||
hasActivateInfo: authStore.activateInfoLoadedGet
|
|
||||||
})
|
|
||||||
await authStore.setRouteName(to.name as string)
|
await authStore.setRouteName(to.name as string)
|
||||||
|
|
||||||
if (!authStore.activateInfoLoadedGet) {
|
if (!authStore.activateInfoLoadedGet) {
|
||||||
|
|||||||
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')
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const srcRoot = path.resolve(currentDir, '../..')
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
router: path.resolve(srcRoot, 'routers/index.ts'),
|
||||||
|
authStore: path.resolve(srcRoot, 'stores/modules/auth.ts'),
|
||||||
|
utils: path.resolve(srcRoot, 'utils/index.ts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
|
||||||
|
const routerSource = read(files.router)
|
||||||
|
const authStoreSource = read(files.authStore)
|
||||||
|
const utilsSource = read(files.utils)
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
{
|
||||||
|
name: 'auth store owns cached flat/show/breadcrumb menu state',
|
||||||
|
pass:
|
||||||
|
/flatMenuList:\s*\[\]/.test(authStoreSource) &&
|
||||||
|
/showMenuList:\s*\[\]/.test(authStoreSource) &&
|
||||||
|
/breadcrumbList:\s*\{\}/.test(authStoreSource)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'auth store refreshes derived menus when auth menu list changes',
|
||||||
|
pass: /refreshDerivedMenus\(\)/.test(authStoreSource) && /this\.refreshDerivedMenus\(\)/.test(authStoreSource)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'menu derivation helpers avoid JSON stringify deep clone',
|
||||||
|
pass:
|
||||||
|
!/getFlatMenuList[\s\S]*JSON\.parse\(JSON\.stringify/.test(utilsSource) &&
|
||||||
|
!/getShowMenuList[\s\S]*JSON\.parse\(JSON\.stringify/.test(utilsSource)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'router beforeEach does not sync home state on every navigation',
|
||||||
|
pass: !/before-each-menu-sync/.test(routerSource)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'router syncs home state after dynamic menu initialization',
|
||||||
|
pass: /syncHomeStateWithMenus\(\)/.test(routerSource) && /first-sync-home-state/.test(routerSource)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(item => !item.pass)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('Menu navigation performance contract failed:')
|
||||||
|
failures.forEach(item => console.error(`- ${item.name}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Menu navigation performance contract passed')
|
||||||
@@ -21,7 +21,20 @@ const COMPONENT_PATH_ALIASES: Record<string, string> = {
|
|||||||
'/event/event-list/index': '/event/eventList/index',
|
'/event/event-list/index': '/event/eventList/index',
|
||||||
// 后端菜单可能使用短横线模块名,前端页面目录统一为 steadyDataView。
|
// 后端菜单可能使用短横线模块名,前端页面目录统一为 steadyDataView。
|
||||||
'/steady/steady-data-view': '/steady/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([
|
const STATIC_ROUTE_NAMES = new Set([
|
||||||
'layout',
|
'layout',
|
||||||
@@ -30,12 +43,16 @@ const STATIC_ROUTE_NAMES = new Set([
|
|||||||
'tools',
|
'tools',
|
||||||
'toolWaveform',
|
'toolWaveform',
|
||||||
'toolMmsMapping',
|
'toolMmsMapping',
|
||||||
|
'deviceTypes',
|
||||||
'toolAddData',
|
'toolAddData',
|
||||||
'toolAddLedger',
|
'toolAddLedger',
|
||||||
'eventList',
|
'eventList',
|
||||||
'steadyDataView',
|
'steadyDataView',
|
||||||
|
'steadyTrend',
|
||||||
|
'checksquare',
|
||||||
'systemMonitor',
|
'systemMonitor',
|
||||||
'diskMonitor',
|
'diskMonitor',
|
||||||
|
'systemOpsDbms',
|
||||||
'403',
|
'403',
|
||||||
'404',
|
'404',
|
||||||
'500'
|
'500'
|
||||||
|
|||||||
@@ -60,7 +60,16 @@ export const staticRouter: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/tools/mmsMapping/index.vue'),
|
component: () => import('@/views/tools/mmsMapping/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
cacheName: 'MmsMappingView',
|
cacheName: 'MmsMappingView',
|
||||||
title: 'MMS 映射'
|
title: '模型映射管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tools/deviceTypes',
|
||||||
|
name: 'deviceTypes',
|
||||||
|
component: () => import('@/views/tools/deviceTypes/index.vue'),
|
||||||
|
meta: {
|
||||||
|
cacheName: 'MmsDeviceTypesView',
|
||||||
|
title: '设备类型管理'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,6 +126,41 @@ export const staticRouter: RouteRecordRaw[] = [
|
|||||||
title: '稳态数据'
|
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',
|
path: '/403',
|
||||||
name: '403',
|
name: '403',
|
||||||
@@ -163,6 +207,26 @@ export const staticRouter: RouteRecordRaw[] = [
|
|||||||
title: '磁盘监控'
|
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(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
component: () => import('@/components/ErrorMessage/404.vue')
|
component: () => import('@/components/ErrorMessage/404.vue')
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export interface AuthState {
|
|||||||
[key: string]: string[]
|
[key: string]: string[]
|
||||||
}
|
}
|
||||||
authMenuList: Menu.MenuOptions[]
|
authMenuList: Menu.MenuOptions[]
|
||||||
|
showMenuList: Menu.MenuOptions[]
|
||||||
|
flatMenuList: Menu.MenuOptions[]
|
||||||
|
breadcrumbList: { [key: string]: Menu.MenuOptions[] }
|
||||||
showMenuFlag: boolean
|
showMenuFlag: boolean
|
||||||
activateInfo: Activate.ActivationCodePlaintext
|
activateInfo: Activate.ActivationCodePlaintext
|
||||||
activateInfoLoaded: boolean
|
activateInfoLoaded: boolean
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const useAuthStore = defineStore(AUTH_STORE_KEY, {
|
|||||||
state: (): AuthState => ({
|
state: (): AuthState => ({
|
||||||
authButtonList: {},
|
authButtonList: {},
|
||||||
authMenuList: [],
|
authMenuList: [],
|
||||||
|
showMenuList: [],
|
||||||
|
flatMenuList: [],
|
||||||
|
breadcrumbList: {},
|
||||||
routeName: '',
|
routeName: '',
|
||||||
showMenuFlag: localStorage.getItem('showMenuFlag') === 'true',
|
showMenuFlag: localStorage.getItem('showMenuFlag') === 'true',
|
||||||
activateInfo: {} as Activate.ActivationCodePlaintext,
|
activateInfo: {} as Activate.ActivationCodePlaintext,
|
||||||
@@ -18,9 +21,9 @@ export const useAuthStore = defineStore(AUTH_STORE_KEY, {
|
|||||||
getters: {
|
getters: {
|
||||||
authButtonListGet: state => state.authButtonList,
|
authButtonListGet: state => state.authButtonList,
|
||||||
authMenuListGet: state => state.authMenuList,
|
authMenuListGet: state => state.authMenuList,
|
||||||
showMenuListGet: state => getShowMenuList(state.authMenuList),
|
showMenuListGet: state => state.showMenuList,
|
||||||
flatMenuListGet: state => getFlatMenuList(state.authMenuList),
|
flatMenuListGet: state => state.flatMenuList,
|
||||||
breadcrumbListGet: state => getAllBreadcrumbList(state.authMenuList),
|
breadcrumbListGet: state => state.breadcrumbList,
|
||||||
showMenuFlagGet: state => state.showMenuFlag,
|
showMenuFlagGet: state => state.showMenuFlag,
|
||||||
activateInfoGet: state => state.activateInfo,
|
activateInfoGet: state => state.activateInfo,
|
||||||
activateInfoLoadedGet: state => state.activateInfoLoaded
|
activateInfoLoadedGet: state => state.activateInfoLoaded
|
||||||
@@ -33,6 +36,13 @@ export const useAuthStore = defineStore(AUTH_STORE_KEY, {
|
|||||||
async getAuthMenuList() {
|
async getAuthMenuList() {
|
||||||
const { data: menuData } = await getAuthMenuListApi()
|
const { data: menuData } = await getAuthMenuListApi()
|
||||||
this.authMenuList = normalizeBusinessMenus(filterBusinessMenus(menuData))
|
this.authMenuList = normalizeBusinessMenus(filterBusinessMenus(menuData))
|
||||||
|
// 菜单派生数据只在菜单源数据变化时重算,避免每次路由跳转都深拷贝整棵菜单。
|
||||||
|
this.refreshDerivedMenus()
|
||||||
|
},
|
||||||
|
refreshDerivedMenus() {
|
||||||
|
this.showMenuList = getShowMenuList(this.authMenuList)
|
||||||
|
this.flatMenuList = getFlatMenuList(this.authMenuList)
|
||||||
|
this.breadcrumbList = getAllBreadcrumbList(this.authMenuList)
|
||||||
},
|
},
|
||||||
async setRouteName(name: string) {
|
async setRouteName(name: string) {
|
||||||
this.routeName = name
|
this.routeName = name
|
||||||
@@ -40,6 +50,9 @@ export const useAuthStore = defineStore(AUTH_STORE_KEY, {
|
|||||||
async resetAuthStore() {
|
async resetAuthStore() {
|
||||||
this.authButtonList = {}
|
this.authButtonList = {}
|
||||||
this.authMenuList = []
|
this.authMenuList = []
|
||||||
|
this.showMenuList = []
|
||||||
|
this.flatMenuList = []
|
||||||
|
this.breadcrumbList = {}
|
||||||
this.routeName = ''
|
this.routeName = ''
|
||||||
this.showMenuFlag = false
|
this.showMenuFlag = false
|
||||||
this.activateInfo = {}
|
this.activateInfo = {}
|
||||||
@@ -144,6 +157,25 @@ function normalizeBusinessMenu(menu: any): any {
|
|||||||
menu.component = '@/views/steady/steadyDataView/index.vue'
|
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
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +205,51 @@ function isSteadyDataViewMenu(menu: any): boolean {
|
|||||||
return title.includes('稳态数据')
|
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 {
|
export function resolveBusinessMenuPath(menu: Menu.MenuOptions): string {
|
||||||
if (isEventListMenu(menu)) return '/eventList/index'
|
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
|
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-a: #daa520;
|
||||||
--cn-color-phase-b: #2e8b57;
|
--cn-color-phase-b: #2e8b57;
|
||||||
--cn-color-phase-c: #a52a2a;
|
--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;
|
--cn-color-run: #20b2aa;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isArray, isNumber } from '@/utils/is'
|
import { isArray, isNumber } from '@/utils/is'
|
||||||
import { FieldNamesProps } from '@/components/ProTable/interface'
|
import type { FieldNamesProps } from '@/components/ProTable/interface'
|
||||||
|
|
||||||
const mode = import.meta.env.VITE_ROUTER_MODE
|
const mode = import.meta.env.VITE_ROUTER_MODE
|
||||||
|
|
||||||
@@ -152,8 +152,7 @@ export function getUrlWithParams() {
|
|||||||
* @returns {Array}
|
* @returns {Array}
|
||||||
*/
|
*/
|
||||||
export function getFlatMenuList(menuList: Menu.MenuOptions[]): Menu.MenuOptions[] {
|
export function getFlatMenuList(menuList: Menu.MenuOptions[]): Menu.MenuOptions[] {
|
||||||
const newMenuList: Menu.MenuOptions[] = JSON.parse(JSON.stringify(menuList))
|
return menuList.flatMap(item => [item, ...(item.children?.length ? getFlatMenuList(item.children) : [])])
|
||||||
return newMenuList.flatMap(item => [item, ...(item.children ? getFlatMenuList(item.children) : [])])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,12 +160,13 @@ export function getFlatMenuList(menuList: Menu.MenuOptions[]): Menu.MenuOptions[
|
|||||||
* @param {Array} menuList 菜单列表
|
* @param {Array} menuList 菜单列表
|
||||||
* @returns {Array}
|
* @returns {Array}
|
||||||
* */
|
* */
|
||||||
export function getShowMenuList(menuList: Menu.MenuOptions[]) {
|
export function getShowMenuList(menuList: Menu.MenuOptions[]): Menu.MenuOptions[] {
|
||||||
const newMenuList: Menu.MenuOptions[] = JSON.parse(JSON.stringify(menuList))
|
return menuList
|
||||||
return newMenuList.filter(item => {
|
.filter(item => !item.meta?.isHide)
|
||||||
item.children?.length && (item.children = getShowMenuList(item.children))
|
.map(item => ({
|
||||||
return !item.meta?.isHide
|
...item,
|
||||||
})
|
children: item.children?.length ? getShowMenuList(item.children) : item.children
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFirstMenuPath(menuList: Menu.MenuOptions[]): string {
|
export function getFirstMenuPath(menuList: Menu.MenuOptions[]): string {
|
||||||
|
|||||||
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'
|
'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)), [
|
assert.deepEqual(buildTimePeriodRange('month', new Date(2026, 4, 13)), [
|
||||||
'2026-05-01 00:00:00.000',
|
'2026-05-01 00:00:00.000',
|
||||||
'2026-05-31 23:59:59.999'
|
'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'
|
'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(formatTimePeriodDateTime(new Date(2026, 4, 13, 8, 9, 10, 11)), '2026-05-13 08:09:10.011')
|
||||||
assert.equal(getTimePeriodPickerType('day'), 'date')
|
assert.equal(getTimePeriodPickerType('day'), 'date')
|
||||||
assert.equal(getTimePeriodPickerFormat('month'), 'YYYY-MM')
|
assert.equal(getTimePeriodPickerFormat('month'), 'YYYY-MM')
|
||||||
assert.equal(resolveTimePeriodUnitLabel('year'), '年')
|
assert.equal(resolveTimePeriodUnitLabel('year'), '年')
|
||||||
|
assert.equal(resolveTimePeriodUnitLabel('custom'), '自定义')
|
||||||
|
|
||||||
const componentExpectations = [
|
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 previous period button', /ArrowLeft[\s\S]*上一个/],
|
||||||
['component renders current period button', /Clock[\s\S]*当前/],
|
['component renders current period button', /Clock[\s\S]*当前/],
|
||||||
['component renders next period button', /ArrowRight[\s\S]*下一个/],
|
['component renders next period button', /ArrowRight[\s\S]*下一个/],
|
||||||
['component renders date picker by selected unit', /getTimePeriodPickerType\(props\.unit\)/],
|
['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;/]
|
['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>
|
<template>
|
||||||
<div class="time-period-search">
|
<div class="time-period-search">
|
||||||
<el-select class="time-period-search__unit" :model-value="unit" @update:model-value="handleUnitChange">
|
<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-select>
|
||||||
|
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="!isCustomUnit"
|
||||||
class="time-period-search__button"
|
class="time-period-search__button"
|
||||||
:icon="ArrowLeft"
|
:icon="ArrowLeft"
|
||||||
:title="`上一个${unitLabel}`"
|
:title="`上一个${unitLabel}`"
|
||||||
@@ -12,6 +18,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
|
v-if="!isCustomUnit"
|
||||||
class="time-period-search__picker"
|
class="time-period-search__picker"
|
||||||
:model-value="baseDate"
|
:model-value="baseDate"
|
||||||
:type="getTimePeriodPickerType(props.unit)"
|
:type="getTimePeriodPickerType(props.unit)"
|
||||||
@@ -22,7 +29,23 @@
|
|||||||
@update:model-value="handleDateChange"
|
@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
|
<el-button
|
||||||
|
v-if="!isCustomUnit"
|
||||||
class="time-period-search__button"
|
class="time-period-search__button"
|
||||||
:icon="ArrowRight"
|
:icon="ArrowRight"
|
||||||
:title="`下一个${unitLabel}`"
|
:title="`下一个${unitLabel}`"
|
||||||
@@ -30,6 +53,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="!isCustomUnit"
|
||||||
class="time-period-search__button"
|
class="time-period-search__button"
|
||||||
:icon="Clock"
|
:icon="Clock"
|
||||||
:title="`当前${unitLabel}`"
|
:title="`当前${unitLabel}`"
|
||||||
@@ -57,15 +81,25 @@ defineOptions({
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
unit: TimePeriodUnit
|
unit: TimePeriodUnit
|
||||||
modelValue: Date | string | number
|
modelValue: Date | string | number
|
||||||
|
rangeValue?: string[]
|
||||||
|
visibleUnits?: TimePeriodUnit[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:unit': [value: TimePeriodUnit]
|
'update:unit': [value: TimePeriodUnit]
|
||||||
'update:modelValue': [value: Date]
|
'update:modelValue': [value: Date]
|
||||||
|
'update:rangeValue': [value: string[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const baseDate = computed(() => new Date(props.modelValue))
|
const baseDate = computed(() => new Date(props.modelValue))
|
||||||
const unitLabel = computed(() => resolveTimePeriodUnitLabel(props.unit))
|
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) => {
|
const handleUnitChange = (value: TimePeriodUnit) => {
|
||||||
emit('update:unit', value)
|
emit('update:unit', value)
|
||||||
@@ -76,6 +110,11 @@ const handleDateChange = (value: Date | string | number | null) => {
|
|||||||
emit('update:modelValue', new Date(value))
|
emit('update:modelValue', new Date(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRangeChange = (value: string[] | null) => {
|
||||||
|
if (!value?.length) return
|
||||||
|
emit('update:rangeValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
const shiftPeriod = (offset: number) => {
|
const shiftPeriod = (offset: number) => {
|
||||||
emit('update:modelValue', shiftTimePeriod(props.unit, baseDate.value, offset))
|
emit('update:modelValue', shiftTimePeriod(props.unit, baseDate.value, offset))
|
||||||
}
|
}
|
||||||
@@ -103,6 +142,12 @@ const setCurrentPeriod = () => {
|
|||||||
flex: 0 0 112px;
|
flex: 0 0 112px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-period-search__range-picker {
|
||||||
|
width: 360px;
|
||||||
|
flex: 1 1 360px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
.time-period-search__button {
|
.time-period-search__button {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
flex: 0 0 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 }[] = [
|
export const timePeriodUnitOptions: { label: string; value: TimePeriodUnit }[] = [
|
||||||
{ label: '日', value: 'day' },
|
{ label: '日', value: 'day' },
|
||||||
|
{ label: '周', value: 'week' },
|
||||||
{ label: '月', value: 'month' },
|
{ label: '月', value: 'month' },
|
||||||
{ label: '年', value: 'year' }
|
{ label: '年', value: 'year' },
|
||||||
|
{ label: '自定义', value: 'custom' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const datePickerTypeMap: Record<TimePeriodUnit, 'date' | 'month' | 'year'> = {
|
const datePickerTypeMap: Record<TimePeriodUnit, 'date' | 'month' | 'year'> = {
|
||||||
day: 'date',
|
day: 'date',
|
||||||
|
week: 'date',
|
||||||
month: 'month',
|
month: 'month',
|
||||||
year: 'year'
|
year: 'year',
|
||||||
|
custom: 'date'
|
||||||
}
|
}
|
||||||
|
|
||||||
const datePickerFormatMap: Record<TimePeriodUnit, string> = {
|
const datePickerFormatMap: Record<TimePeriodUnit, string> = {
|
||||||
day: 'YYYY-MM-DD',
|
day: 'YYYY-MM-DD',
|
||||||
|
week: 'YYYY-MM-DD',
|
||||||
month: 'YYYY-MM',
|
month: 'YYYY-MM',
|
||||||
year: 'YYYY'
|
year: 'YYYY',
|
||||||
|
custom: 'YYYY-MM-DD'
|
||||||
}
|
}
|
||||||
|
|
||||||
const padTimeValue = (value: number, length = 2) => String(value).padStart(length, '0')
|
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') {
|
if (unit === 'year') {
|
||||||
return [
|
return [
|
||||||
formatTimePeriodDateTime(new Date(year, 0, 1, 0, 0, 0, 0)),
|
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
|
return nextDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (unit === 'week') {
|
||||||
|
nextDate.setDate(nextDate.getDate() + offset * 7)
|
||||||
|
return nextDate
|
||||||
|
}
|
||||||
|
|
||||||
// 月份切换以 1 日为锚点,避免 31 日切到短月份时发生日期溢出。
|
// 月份切换以 1 日为锚点,避免 31 日切到短月份时发生日期溢出。
|
||||||
nextDate.setDate(1)
|
nextDate.setDate(1)
|
||||||
nextDate.setMonth(nextDate.getMonth() + offset)
|
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')
|
const tempModulePath = path.join(tempDir, 'display.mjs')
|
||||||
fs.writeFileSync(tempModulePath, transpiled, 'utf8')
|
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({ event_describe: '' }), '--')
|
assert.equal(resolveEventDescription({ event_describe: '' }), '--')
|
||||||
assert.equal(resolveEventDescription({ eventDescribe: '驼峰描述' }), '--')
|
|
||||||
assert.equal(resolveEventDescription({ eventDescription: '描述字段' }), '--')
|
|
||||||
assert.equal(resolveEventDescription({ eventType: 'VOLTAGE_SAG' }), '--')
|
assert.equal(resolveEventDescription({ eventType: 'VOLTAGE_SAG' }), '--')
|
||||||
assert.equal(resolveEventDescription({}), '--')
|
assert.equal(resolveEventDescription({}), '--')
|
||||||
assert.equal(resolveEventDescription(null), '--')
|
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 = [
|
const eventTypeOptions = [
|
||||||
{ id: 'c5ce588cb76fba90c4510000000000000', name: '电压暂降', code: 'VOLTAGE_SAG' },
|
{ id: 'c5ce588cb76fba90c4510000000000000', name: '电压暂降', code: 'VOLTAGE_SAG' },
|
||||||
{ id: 'a26e588cb76fba90c4510000000000000', name: '电压暂升', code: 'VOLTAGE_SWELL', value: 'SWELL' }
|
{ 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({
|
const params = buildEventQueryParams({
|
||||||
pageNum: 2,
|
pageNum: 2,
|
||||||
pageSize: 20,
|
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.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(Object.hasOwn(params, 'eventTypeCode'), false)
|
||||||
|
assert.equal(source.includes(['event', 'Describe'].join('')), false)
|
||||||
|
|
||||||
console.log('eventList query params contract passed')
|
console.log('eventList query params contract passed')
|
||||||
@@ -4,11 +4,11 @@ import path from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const currentDir = path.dirname(fileURLToPath(import.meta.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 staticRouterFile = path.join(routersDir, 'staticRouter.ts')
|
||||||
const dynamicRouterFile = path.join(routersDir, 'dynamicRouter.ts')
|
const dynamicRouterFile = path.join(routersDir, 'dynamicRouter.ts')
|
||||||
const authStoreFile = path.join(currentDir, '..', '..', '..', 'stores', 'modules', 'auth.ts')
|
const authStoreFile = path.join(currentDir, '..', '..', '..', '..', 'stores', 'modules', 'auth.ts')
|
||||||
const subMenuFile = path.join(currentDir, '..', '..', '..', 'layouts', 'components', 'Menu', 'SubMenu.vue')
|
const subMenuFile = path.join(currentDir, '..', '..', '..', '..', 'layouts', 'components', 'Menu', 'SubMenu.vue')
|
||||||
|
|
||||||
const staticRouterSource = fs.readFileSync(staticRouterFile, 'utf8')
|
const staticRouterSource = fs.readFileSync(staticRouterFile, 'utf8')
|
||||||
const dynamicRouterSource = fs.readFileSync(dynamicRouterFile, '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'
|
import ts from 'typescript'
|
||||||
|
|
||||||
const sharedModulePath = path.resolve('src/views/components/TimePeriodSearch/timePeriod.ts')
|
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)) {
|
if (!fs.existsSync(sharedModulePath)) {
|
||||||
throw new Error('TimePeriodSearch/timePeriod.ts must provide shared time range helpers')
|
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>
|
<template>
|
||||||
<div class="table-box event-list-page">
|
<div class="table-box event-list-page">
|
||||||
<ProTable
|
<EventListTable
|
||||||
ref="proTable"
|
:event-type-options="eventTypeOptions"
|
||||||
row-key="eventId"
|
|
||||||
:columns="columns"
|
|
||||||
:request-api="getTableList"
|
:request-api="getTableList"
|
||||||
:search-col="{ xs: 1, sm: 2, md: 2, lg: 5, xl: 5 }"
|
@event-export="handleEventExport"
|
||||||
@reset="handleSearchReset"
|
@waveform-export="handleWaveformExport"
|
||||||
>
|
@view-measurement-point="handleViewMeasurementPoint"
|
||||||
<template #tableHeader>
|
@view-voltage-tolerance="handleViewVoltageTolerance"
|
||||||
<el-button type="primary" plain :icon="Download" @click="handleExport">导出</el-button>
|
@view-waveform="handleViewWaveform"
|
||||||
</template>
|
@supplement-waveform="handleSupplementWaveform"
|
||||||
|
/>
|
||||||
|
|
||||||
<template #fileFlag="{ row }">
|
<MeasurementPointDialog
|
||||||
<el-tag :type="Number(row.fileFlag) === 1 ? 'success' : 'info'" effect="light">
|
v-model:visible="measurementPointDialogVisible"
|
||||||
{{ resolveFileFlagText(row.fileFlag) }}
|
:loading="measurementPointLoading"
|
||||||
</el-tag>
|
:data="measurementPointData"
|
||||||
</template>
|
/>
|
||||||
|
|
||||||
<template #operation="{ row }">
|
<VoltageToleranceDialog
|
||||||
<el-button v-if="Number(row.fileFlag) === 1" type="primary" link :icon="View" @click="handleViewWaveform(row)">
|
v-model:visible="voltageToleranceDialogVisible"
|
||||||
查看波形
|
:data="voltageToleranceEventData"
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElButton, ElRadioButton, ElRadioGroup } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Download, RefreshRight, View } from '@element-plus/icons-vue'
|
import {
|
||||||
import ProTable from '@/components/ProTable/index.vue'
|
exportTransientEvents,
|
||||||
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
exportTransientWaveforms,
|
||||||
import { exportTransientEvents, getTransientEventDetail, getTransientEventPage } from '@/api/event/eventList'
|
getTransientEventDetail,
|
||||||
|
getTransientEventPage,
|
||||||
|
getTransientEventWave
|
||||||
|
} from '@/api/event/eventList'
|
||||||
import type { EventList } from '@/api/event/eventList/interface'
|
import type { EventList } from '@/api/event/eventList/interface'
|
||||||
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
|
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
|
||||||
import { useDictStore } from '@/stores/modules/dict'
|
import { useDictStore } from '@/stores/modules/dict'
|
||||||
import { DICT_CODES } from '@/constants/dictCodes'
|
import { DICT_CODES } from '@/constants/dictCodes'
|
||||||
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
import EventListTable from './components/EventListTable.vue'
|
||||||
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
import MeasurementPointDialog from './components/MeasurementPointDialog.vue'
|
||||||
import { formatEventOccurrenceTime } from './eventTimeRange'
|
import VoltageToleranceDialog from './components/VoltageToleranceDialog.vue'
|
||||||
import { buildEventQueryParams, type EventSearchParams } from './utils/queryParams'
|
import { buildEventQueryParams, type EventSearchParams } from './utils/queryParams'
|
||||||
import { resolveEventDescription, resolveEventTypeName } from './utils/display'
|
|
||||||
import {
|
|
||||||
fileFlagOptions,
|
|
||||||
phaseOptions,
|
|
||||||
resolveFileFlagText
|
|
||||||
} from './utils/status'
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'EventListView'
|
name: 'EventListView'
|
||||||
})
|
})
|
||||||
|
|
||||||
const proTable = ref<ProTableInstance>()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dictStore = useDictStore()
|
const dictStore = useDictStore()
|
||||||
|
const EVENT_LIST_WAVEFORM_SESSION_KEY = 'eventList:waveformParseResult'
|
||||||
const measurementPointDialogVisible = ref(false)
|
const measurementPointDialogVisible = ref(false)
|
||||||
const measurementPointLoading = ref(false)
|
const measurementPointLoading = ref(false)
|
||||||
const measurementPointData = ref<EventList.TransientEventRecord | null>(null)
|
const measurementPointData = ref<EventList.TransientEventRecord | null>(null)
|
||||||
const eventTimeUnit = ref<TimePeriodUnit>('month')
|
const voltageToleranceDialogVisible = ref(false)
|
||||||
const eventTimeBaseDate = ref(new Date())
|
const voltageToleranceEventData = ref<EventList.TransientEventRecord | null>(null)
|
||||||
const defaultStartTimeRange = buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
|
|
||||||
const eventTypeOptions = computed(() => dictStore.getDictData(DICT_CODES.EVENT_TYPE))
|
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) => {
|
const getTableList = (params: EventSearchParams) => {
|
||||||
// 分页查询按 API_DEBUG.md 转换时间范围与枚举筛选参数。
|
// 分页查询按 API_DEBUG.md 转换时间范围与枚举筛选参数。
|
||||||
@@ -244,7 +64,7 @@ const getTableList = (params: EventSearchParams) => {
|
|||||||
|
|
||||||
const handleViewMeasurementPoint = async (row: EventList.TransientEventRecord) => {
|
const handleViewMeasurementPoint = async (row: EventList.TransientEventRecord) => {
|
||||||
if (!row.eventId) {
|
if (!row.eventId) {
|
||||||
ElMessage.warning('缺少事件 ID,无法查询监测点信息')
|
ElMessage.warning('\u7f3a\u5c11\u4e8b\u4ef6 ID\uff0c\u65e0\u6cd5\u67e5\u8be2\u76d1\u6d4b\u70b9\u4fe1\u606f')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,29 +80,48 @@ const handleViewMeasurementPoint = async (row: EventList.TransientEventRecord) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewWaveform = (row: EventList.TransientEventRecord) => {
|
const handleViewWaveform = async (row: EventList.TransientEventRecord) => {
|
||||||
if (!row.wavePath) {
|
if (!row.eventId) {
|
||||||
ElMessage.warning('缺少波形文件路径,无法查看波形')
|
ElMessage.warning('\u7f3a\u5c11\u4e8b\u4ef6 ID\uff0c\u65e0\u6cd5\u67e5\u770b\u6ce2\u5f62')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查看波形由后端按事件定位并解析 COMTRADE 文件,前端只传递解析结果。
|
||||||
|
const response = await getTransientEventWave(row.eventId)
|
||||||
|
sessionStorage.setItem(EVENT_LIST_WAVEFORM_SESSION_KEY, JSON.stringify(response.data))
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
path: '/tools/waveform',
|
path: '/tools/waveform',
|
||||||
query: {
|
query: {
|
||||||
eventId: row.eventId,
|
source: 'eventList',
|
||||||
wavePath: row.wavePath
|
eventId: row.eventId
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSupplementWaveform = (_row: EventList.TransientEventRecord) => {
|
const handleViewVoltageTolerance = (row: EventList.TransientEventRecord) => {
|
||||||
// 波形补招需要后端补招接口,当前先保留操作入口避免误触发未知流程。
|
voltageToleranceEventData.value = row
|
||||||
ElMessage.warning('暂无波形补招接口,无法发起补招')
|
voltageToleranceDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleSupplementWaveform = () => {
|
||||||
const searchParam = (proTable.value?.searchParam || {}) as EventSearchParams
|
// 波形补招需要后端补招接口,当前保留操作入口避免误触发未知流程。
|
||||||
useDownloadWithServerFileName(exportTransientEvents, '暂态事件列表', buildEventQueryParams(searchParam), false)
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -296,8 +135,4 @@ const handleExport = () => {
|
|||||||
.event-list-page :deep(.el-descriptions__cell) {
|
.event-list-page :deep(.el-descriptions__cell) {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-list-page :deep(.event-file-flag-search) {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { EventList } from '@/api/event/eventList/interface'
|
import type { EventList } from '@/api/event/eventList/interface'
|
||||||
import { formatEventOccurrenceTime } from '../eventTimeRange'
|
import { formatEventOccurrenceTime } from './eventTimeRange'
|
||||||
import { resolveEventDescription } from './display'
|
import { resolveEventDescription } from './display'
|
||||||
import { resolveDealFlagText, resolveFileFlagText } from './status'
|
import { resolveDealFlagText, resolveFileFlagText } from './status'
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ export const resolveEventDescription = (row: EventRecordLike) => {
|
|||||||
return description || '--'
|
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[] = []) => {
|
export const resolveEventTypeName = (row: EventRecordLike, eventTypeOptions: EventTypeOptionLike[] = []) => {
|
||||||
if (!row) return '/'
|
if (!row) return '/'
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const formatEventOccurrenceTime = (value: unknown) => {
|
|||||||
const text = String(value).trim()
|
const text = String(value).trim()
|
||||||
if (!text) return '--'
|
if (!text) return '--'
|
||||||
|
|
||||||
// 发生时刻直接承载事件定位精度:小数秒按接口原始值展示,不补零、不裁剪。
|
// 发生时刻直接承载接口返回精度:有毫秒就展示,没有毫秒不在前端合成。
|
||||||
const matched = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/)
|
const matched = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/)
|
||||||
if (!matched) return text
|
if (!matched) return text
|
||||||
|
|
||||||
@@ -11,6 +11,13 @@ const resolveOptionalText = (value: unknown) => {
|
|||||||
return text || undefined
|
return text || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveEventTimeText = (value: unknown) => {
|
||||||
|
const text = resolveOptionalText(value)
|
||||||
|
if (!text) return undefined
|
||||||
|
|
||||||
|
return text.replace(/\.\d+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
const resolveOptionalNumber = (value: unknown) => {
|
const resolveOptionalNumber = (value: unknown) => {
|
||||||
if (value === null || value === undefined || value === '') return undefined
|
if (value === null || value === undefined || value === '') return undefined
|
||||||
const parsed = Number(value)
|
const parsed = Number(value)
|
||||||
@@ -30,10 +37,16 @@ export const buildEventQueryParams = (params: EventSearchParams = {}) => {
|
|||||||
return pruneEmptyParams({
|
return pruneEmptyParams({
|
||||||
pageNum: params.pageNum,
|
pageNum: params.pageNum,
|
||||||
pageSize: params.pageSize,
|
pageSize: params.pageSize,
|
||||||
startTimeStart: resolveOptionalText(timeRange[0]),
|
startTimeStart: resolveEventTimeText(timeRange[0]),
|
||||||
startTimeEnd: resolveOptionalText(timeRange[1]),
|
startTimeEnd: resolveEventTimeText(timeRange[1]),
|
||||||
eventType: resolveOptionalText(params.eventType),
|
eventType: resolveOptionalText(params.eventType),
|
||||||
phase: resolveOptionalText(params.phase),
|
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),
|
fileFlag: resolveOptionalNumber(params.fileFlag),
|
||||||
dealFlag: resolveOptionalNumber(params.dealFlag),
|
dealFlag: resolveOptionalNumber(params.dealFlag),
|
||||||
engineeringName: ledgerKeyword,
|
engineeringName: ledgerKeyword,
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ export const phaseOptions = [
|
|||||||
{ label: '三相', value: 'ABC' }
|
{ 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 = [
|
export const fileFlagOptions = [
|
||||||
{ label: '未招', value: 0 },
|
{ label: '未招', value: 0 },
|
||||||
{ label: '已招', value: 1 }
|
{ 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,417 @@
|
|||||||
|
# check-square API 调试文档
|
||||||
|
|
||||||
|
## 1. 模块说明
|
||||||
|
|
||||||
|
- 模块路径:`steady/check-square`
|
||||||
|
- 接口基础路径:`/steady/checksquare`
|
||||||
|
- 返回包装:接口统一返回 `HttpResult<T>`,调试时重点查看响应体中的业务数据字段 `data`。
|
||||||
|
- 时间格式:`yyyy-MM-dd HH:mm:ss`
|
||||||
|
|
||||||
|
本模块用于按监测点、时间范围和指标执行稳态数据校验,并提供任务列表、任务详情、检测项明细查询和任务删除能力。
|
||||||
|
|
||||||
|
## 2. 通用约定
|
||||||
|
|
||||||
|
### 2.1 请求头
|
||||||
|
|
||||||
|
| 名称 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Content-Type` | `application/json` | `POST` 请求使用 JSON 请求体 |
|
||||||
|
| `Authorization` | 登录态 Token | 如当前环境开启认证,需携带现有登录接口返回的认证信息 |
|
||||||
|
|
||||||
|
### 2.2 任务状态
|
||||||
|
|
||||||
|
| 值 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `RUNNING` | 执行中 |
|
||||||
|
| `SUCCESS` | 执行成功 |
|
||||||
|
| `FAIL` | 执行失败 |
|
||||||
|
|
||||||
|
### 2.3 明细类型
|
||||||
|
|
||||||
|
| 值 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `SEGMENT` | 缺失区间 |
|
||||||
|
| `VALUE_ORDER` | 指标值大小关系异常明细 |
|
||||||
|
| `HARMONIC_PARITY` | 谐波奇偶关系异常明细 |
|
||||||
|
|
||||||
|
## 3. 调试顺序建议
|
||||||
|
|
||||||
|
1. 调用 `POST /steady/checksquare/create`:按监测点和时间范围创建或获取任务。
|
||||||
|
2. 读取 `/create` 返回的 `data.taskId`:该返回值是任务列表中的行信息。
|
||||||
|
3. 调用 `GET /steady/checksquare/detail`:用 `taskId` 查询任务下的检测项。
|
||||||
|
4. 读取 `/detail` 返回的 `items[].itemId`:按检测项继续查询缺失区间或异常明细。
|
||||||
|
5. 调用 `GET /steady/checksquare/item-detail`:按 `itemId + detailType` 查询具体明细。
|
||||||
|
6. 调用 `POST /steady/checksquare/query`:按条件分页查询历史任务列表。
|
||||||
|
7. 需要清理任务时调用 `POST /steady/checksquare/delete`。
|
||||||
|
|
||||||
|
注意:旧的获取或创建兼容接口已移除。创建或复用任务的逻辑已经合并到 `/create` 中。
|
||||||
|
|
||||||
|
## 4. 创建或获取任务
|
||||||
|
|
||||||
|
### 4.1 接口
|
||||||
|
|
||||||
|
`POST /steady/checksquare/create`
|
||||||
|
|
||||||
|
### 4.2 行为说明
|
||||||
|
|
||||||
|
接口开始执行前,会先按 `lineId + timeStart + timeEnd` 查询是否存在未删除的任务:
|
||||||
|
|
||||||
|
- 已存在:直接返回该任务的任务列表行信息。
|
||||||
|
- 不存在:创建任务,执行校验,任务执行完成后返回任务列表行信息。
|
||||||
|
|
||||||
|
返回对象为 `SteadyChecksquareTaskVO`,用于页面任务列表展示。若要查看检测项明细,需要再调用 `GET /steady/checksquare/detail`。
|
||||||
|
|
||||||
|
### 4.3 请求字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `lineId` | `String` | 是 | 监测点 ID |
|
||||||
|
| `indicatorCodes` | `Array<String>` | 是 | 指标编码列表 |
|
||||||
|
| `timeStart` | `String` | 是 | 开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
|
||||||
|
| `timeEnd` | `String` | 是 | 结束时间,格式 `yyyy-MM-dd HH:mm:ss` |
|
||||||
|
|
||||||
|
`indicatorCodes` 是数组;不要传成单个字符串。
|
||||||
|
|
||||||
|
### 4.4 请求示例
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /steady/checksquare/create
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lineId": "LINE_001",
|
||||||
|
"indicatorCodes": ["VOLTAGE_A", "CURRENT_A"],
|
||||||
|
"timeStart": "2026-06-13 00:00:00",
|
||||||
|
"timeEnd": "2026-06-13 01:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 响应字段 `data`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `taskId` | `String` | 任务 ID |
|
||||||
|
| `taskNo` | `String` | 任务编号 |
|
||||||
|
| `lineId` | `String` | 监测点 ID |
|
||||||
|
| `lineName` | `String` | 监测点名称 |
|
||||||
|
| `timeStart` | `String` | 开始时间 |
|
||||||
|
| `timeEnd` | `String` | 结束时间 |
|
||||||
|
| `intervalMinutes` | `Integer` | 统计间隔,单位分钟 |
|
||||||
|
| `taskStatus` | `String` | 任务状态:`RUNNING`、`SUCCESS`、`FAIL` |
|
||||||
|
| `itemCount` | `Integer` | 检测项数量 |
|
||||||
|
| `abnormalItemCount` | `Integer` | 异常检测项数量 |
|
||||||
|
| `minDataIntegrity` | `BigDecimal` | 最低数据完整率 |
|
||||||
|
| `createTime` | `String` | 创建时间 |
|
||||||
|
|
||||||
|
### 4.6 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"taskId": "1812345678901234567",
|
||||||
|
"taskNo": "CS202606130001",
|
||||||
|
"lineId": "LINE_001",
|
||||||
|
"lineName": "1号监测点",
|
||||||
|
"timeStart": "2026-06-13 00:00:00",
|
||||||
|
"timeEnd": "2026-06-13 01:00:00",
|
||||||
|
"intervalMinutes": 1,
|
||||||
|
"taskStatus": "SUCCESS",
|
||||||
|
"itemCount": 2,
|
||||||
|
"abnormalItemCount": 1,
|
||||||
|
"minDataIntegrity": 98.50,
|
||||||
|
"createTime": "2026-06-13 09:30:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 查询任务列表
|
||||||
|
|
||||||
|
### 5.1 接口
|
||||||
|
|
||||||
|
`POST /steady/checksquare/query`
|
||||||
|
|
||||||
|
### 5.2 请求字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `lineId` | `String` | 否 | 监测点 ID |
|
||||||
|
| `indicatorCode` | `String` | 否 | 指标编码 |
|
||||||
|
| `timeStart` | `String` | 否 | 检测开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
|
||||||
|
| `timeEnd` | `String` | 否 | 检测结束时间,格式 `yyyy-MM-dd HH:mm:ss` |
|
||||||
|
| `hasAbnormal` | `Boolean` | 否 | 是否存在异常 |
|
||||||
|
| `pageNum` | `Integer` | 否 | 页码 |
|
||||||
|
| `pageSize` | `Integer` | 否 | 每页条数 |
|
||||||
|
|
||||||
|
`indicatorCode` 是单个字符串,用于历史任务筛选;和 `/create` 的 `indicatorCodes` 数组不同。
|
||||||
|
|
||||||
|
### 5.3 请求示例
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /steady/checksquare/query
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lineId": "LINE_001",
|
||||||
|
"indicatorCode": "VOLTAGE_A",
|
||||||
|
"timeStart": "2026-06-13 00:00:00",
|
||||||
|
"timeEnd": "2026-06-13 23:59:59",
|
||||||
|
"hasAbnormal": true,
|
||||||
|
"pageNum": 1,
|
||||||
|
"pageSize": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 响应说明
|
||||||
|
|
||||||
|
`data` 为 MyBatis-Plus `Page<SteadyChecksquareTaskVO>` 分页对象,常用字段如下:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `records` | `Array<Object>` | 任务列表,每项字段同 `/create` 返回的 `SteadyChecksquareTaskVO` |
|
||||||
|
| `total` | `Long` | 总记录数 |
|
||||||
|
| `size` | `Long` | 每页条数 |
|
||||||
|
| `current` | `Long` | 当前页码 |
|
||||||
|
| `pages` | `Long` | 总页数 |
|
||||||
|
|
||||||
|
### 5.5 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"taskId": "1812345678901234567",
|
||||||
|
"taskNo": "CS202606130001",
|
||||||
|
"lineId": "LINE_001",
|
||||||
|
"lineName": "1号监测点",
|
||||||
|
"timeStart": "2026-06-13 00:00:00",
|
||||||
|
"timeEnd": "2026-06-13 01:00:00",
|
||||||
|
"intervalMinutes": 1,
|
||||||
|
"taskStatus": "SUCCESS",
|
||||||
|
"itemCount": 2,
|
||||||
|
"abnormalItemCount": 1,
|
||||||
|
"minDataIntegrity": 98.50,
|
||||||
|
"createTime": "2026-06-13 09:30:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"size": 10,
|
||||||
|
"current": 1,
|
||||||
|
"pages": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 查询任务详情
|
||||||
|
|
||||||
|
### 6.1 接口
|
||||||
|
|
||||||
|
`GET /steady/checksquare/detail?taskId={taskId}`
|
||||||
|
|
||||||
|
### 6.2 请求参数
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `taskId` | `String` | 是 | 任务 ID |
|
||||||
|
|
||||||
|
### 6.3 请求示例
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /steady/checksquare/detail?taskId=1812345678901234567
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 响应字段 `data`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `taskId` | `String` | 任务 ID |
|
||||||
|
| `taskNo` | `String` | 任务编号 |
|
||||||
|
| `lineId` | `String` | 监测点 ID |
|
||||||
|
| `lineName` | `String` | 监测点名称 |
|
||||||
|
| `timeStart` | `String` | 开始时间 |
|
||||||
|
| `timeEnd` | `String` | 结束时间 |
|
||||||
|
| `intervalMinutes` | `Integer` | 统计间隔,单位分钟 |
|
||||||
|
| `items` | `Array<Object>` | 检测项列表 |
|
||||||
|
|
||||||
|
### 6.5 检测项字段 `items[]`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `itemId` | `String` | 检测项 ID,用于查询明细 |
|
||||||
|
| `itemKey` | `String` | 检测项唯一键 |
|
||||||
|
| `indicatorCode` | `String` | 指标编码 |
|
||||||
|
| `indicatorName` | `String` | 指标名称 |
|
||||||
|
| `harmonicOrder` | `Integer` | 谐波次数 |
|
||||||
|
| `intervalMinutes` | `Integer` | 当前检测项统计间隔,单位分钟 |
|
||||||
|
| `hasData` | `Boolean` | 时间范围内是否存在任意数据 |
|
||||||
|
| `expectedPointCount` | `Integer` | 期望点数 |
|
||||||
|
| `actualPointCount` | `Integer` | 实际点数 |
|
||||||
|
| `missingPointCount` | `Integer` | 缺失点数 |
|
||||||
|
| `dataIntegrity` | `BigDecimal` | 数据完整率 |
|
||||||
|
| `dataIntegrityText` | `String` | 数据完整率文本 |
|
||||||
|
| `abnormal` | `Boolean` | 指标值大小关系是否异常 |
|
||||||
|
| `abnormalPointCount` | `Integer` | 指标值大小关系异常点数 |
|
||||||
|
| `abnormalDetails` | `Array<Object>` | 指标值大小关系异常明细摘要 |
|
||||||
|
| `harmonicParityAbnormal` | `Boolean` | 谐波奇偶关系是否异常 |
|
||||||
|
| `harmonicParityAbnormalPointCount` | `Integer` | 谐波奇偶关系异常点数 |
|
||||||
|
| `harmonicParityAbnormalDetails` | `Array<Object>` | 谐波奇偶关系异常明细摘要 |
|
||||||
|
| `statSummaries` | `Array<Object>` | 统计类型摘要 |
|
||||||
|
| `statDetails` | `Array<Object>` | 统计类型明细 |
|
||||||
|
|
||||||
|
### 6.6 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"taskId": "1812345678901234567",
|
||||||
|
"taskNo": "CS202606130001",
|
||||||
|
"lineId": "LINE_001",
|
||||||
|
"lineName": "1号监测点",
|
||||||
|
"timeStart": "2026-06-13 00:00:00",
|
||||||
|
"timeEnd": "2026-06-13 01:00:00",
|
||||||
|
"intervalMinutes": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"itemId": "1812345678901234568",
|
||||||
|
"itemKey": "LINE_001:VOLTAGE_A",
|
||||||
|
"indicatorCode": "VOLTAGE_A",
|
||||||
|
"indicatorName": "A相电压",
|
||||||
|
"harmonicOrder": null,
|
||||||
|
"intervalMinutes": 1,
|
||||||
|
"hasData": true,
|
||||||
|
"expectedPointCount": 60,
|
||||||
|
"actualPointCount": 59,
|
||||||
|
"missingPointCount": 1,
|
||||||
|
"dataIntegrity": 98.33,
|
||||||
|
"dataIntegrityText": "98.33%",
|
||||||
|
"abnormal": true,
|
||||||
|
"abnormalPointCount": 1,
|
||||||
|
"abnormalDetails": [],
|
||||||
|
"harmonicParityAbnormal": false,
|
||||||
|
"harmonicParityAbnormalPointCount": 0,
|
||||||
|
"harmonicParityAbnormalDetails": [],
|
||||||
|
"statSummaries": [],
|
||||||
|
"statDetails": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 查询检测项明细
|
||||||
|
|
||||||
|
### 7.1 接口
|
||||||
|
|
||||||
|
`GET /steady/checksquare/item-detail`
|
||||||
|
|
||||||
|
### 7.2 请求参数
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `itemId` | `String` | 是 | 检测项 ID |
|
||||||
|
| `detailType` | `String` | 是 | 明细类型:`SEGMENT`、`VALUE_ORDER`、`HARMONIC_PARITY` |
|
||||||
|
| `statType` | `String` | 否 | 统计类型;查询统计明细时使用 |
|
||||||
|
| `pageNum` | `Integer` | 否 | 页码 |
|
||||||
|
| `pageSize` | `Integer` | 否 | 每页条数 |
|
||||||
|
|
||||||
|
### 7.3 请求示例
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /steady/checksquare/item-detail?itemId=1812345678901234568&detailType=SEGMENT&pageNum=1&pageSize=10
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 响应字段 `data`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `itemId` | `String` | 检测项 ID |
|
||||||
|
| `detailType` | `String` | 明细类型 |
|
||||||
|
| `statType` | `String` | 统计类型 |
|
||||||
|
| `pageNum` | `Integer` | 当前页码;未分页查询时为空 |
|
||||||
|
| `pageSize` | `Integer` | 每页条数;未分页查询时为空 |
|
||||||
|
| `total` | `Long` | 总记录数;未分页查询时为空 |
|
||||||
|
| `segments` | `Array<Object>` | 缺失区间,`detailType=SEGMENT` 时查看 |
|
||||||
|
| `valueOrderDetails` | `Array<Object>` | 指标值大小关系异常明细,`detailType=VALUE_ORDER` 时查看 |
|
||||||
|
| `harmonicParityDetails` | `Array<Object>` | 谐波奇偶关系异常明细,`detailType=HARMONIC_PARITY` 时查看 |
|
||||||
|
|
||||||
|
### 7.5 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"itemId": "1812345678901234568",
|
||||||
|
"detailType": "SEGMENT",
|
||||||
|
"statType": null,
|
||||||
|
"pageNum": 1,
|
||||||
|
"pageSize": 10,
|
||||||
|
"total": 1,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"segmentStart": "2026-06-13 00:10:00",
|
||||||
|
"segmentEnd": "2026-06-13 00:10:00",
|
||||||
|
"pointCount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"valueOrderDetails": [],
|
||||||
|
"harmonicParityDetails": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 删除任务
|
||||||
|
|
||||||
|
### 8.1 接口
|
||||||
|
|
||||||
|
`POST /steady/checksquare/delete`
|
||||||
|
|
||||||
|
### 8.2 请求字段
|
||||||
|
|
||||||
|
请求体直接传任务 ID 数组。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `taskIds` | `Array<String>` | 是 | 任务 ID 数组 |
|
||||||
|
|
||||||
|
### 8.3 请求示例
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /steady/checksquare/delete
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"1812345678901234567"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 常见调试注意事项
|
||||||
|
|
||||||
|
- `/create` 返回的是任务列表行信息,不是详情页完整数据。
|
||||||
|
- `/create` 如果命中已存在任务,会直接返回该任务;不会生成重复任务。
|
||||||
|
- `/detail` 需要使用 `/create` 或 `/query` 返回的 `taskId`。
|
||||||
|
- `/item-detail` 需要使用 `/detail` 返回的 `items[].itemId`。
|
||||||
|
- `/query` 使用 `indicatorCode` 单值筛选;`/create` 使用 `indicatorCodes` 数组创建检测项。
|
||||||
|
- 当前文档只覆盖现有有效接口,不包含旧的任务获取或创建兼容接口。
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="table-main card checksquare-result-panel">
|
||||||
|
<div class="table-header">
|
||||||
|
<div class="header-button-lf">
|
||||||
|
<span class="section-title">校验任务摘要</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="!task" class="empty-result" description="新增后在此查看任务摘要" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="result-body">
|
||||||
|
<div class="result-overview">
|
||||||
|
<div class="task-card">
|
||||||
|
<div class="task-title">
|
||||||
|
<span>{{ task.taskNo || task.taskId || '-' }}</span>
|
||||||
|
<el-tag :type="resolveChecksquareTaskStatusType(task.taskStatus)" effect="plain">
|
||||||
|
{{ formatChecksquareTaskStatus(task.taskStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="task-meta">
|
||||||
|
<span>{{ task.lineName || task.lineId || '-' }}</span>
|
||||||
|
<span>{{ task.timeStart || '-' }} 至 {{ task.timeEnd || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-metrics">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">进线/监测点</span>
|
||||||
|
<span class="metric-value">{{ task.lineName || task.lineId || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">检测项</span>
|
||||||
|
<span class="metric-value">{{ task.itemCount ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">异常项</span>
|
||||||
|
<span class="metric-value is-danger">{{ task.abnormalItemCount ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">最低完整率</span>
|
||||||
|
<span class="metric-value">{{ formatChecksquareIntegrity(task.minDataIntegrity) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">统计间隔</span>
|
||||||
|
<span class="metric-value">{{ task.intervalMinutes ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-detail-grid">
|
||||||
|
<div class="detail-block">
|
||||||
|
<span class="detail-label">校验时间</span>
|
||||||
|
<span class="detail-value">{{ task.timeStart || '-' }} 至 {{ task.timeEnd || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<span class="detail-label">创建时间</span>
|
||||||
|
<span class="detail-value">{{ task.createTime || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
import {
|
||||||
|
formatChecksquareIntegrity,
|
||||||
|
formatChecksquareTaskStatus,
|
||||||
|
resolveChecksquareTaskStatusType
|
||||||
|
} from '../utils/checksquareTaskTable'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareCreateResultPanel'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
task: SteadyDataView.SteadyChecksquareTask | null
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.checksquare-result-panel {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-result {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-overview {
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title span:first-child,
|
||||||
|
.task-meta span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label,
|
||||||
|
.detail-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value,
|
||||||
|
.detail-value {
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value.is-danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-block {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.result-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<section class="table-main card checksquare-detail">
|
||||||
|
<el-empty v-if="!selectedItem" description="请选择指标查看明细" />
|
||||||
|
|
||||||
|
<el-tabs v-else v-model="detailType" class="detail-tabs" @tab-change="handleDetailTypeChange">
|
||||||
|
<el-tab-pane label="缺数区间" name="SEGMENT">
|
||||||
|
<div class="item-overview">
|
||||||
|
<div class="overview-item">
|
||||||
|
<span class="overview-label">数据完整性</span>
|
||||||
|
<span class="overview-value">
|
||||||
|
{{ formatDataIntegrity(selectedItem.dataIntegrity, selectedItem.dataIntegrityText) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="overview-item">
|
||||||
|
<span class="overview-label">期望点数</span>
|
||||||
|
<span class="overview-value">{{ selectedItem.expectedPointCount ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="overview-item">
|
||||||
|
<span class="overview-label">实际点数</span>
|
||||||
|
<span class="overview-value">{{ selectedItem.actualPointCount ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="overview-item">
|
||||||
|
<span class="overview-label">缺失点数</span>
|
||||||
|
<span class="overview-value">{{ selectedItem.missingPointCount ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="segment-toolbar">
|
||||||
|
<el-select v-model="segmentStatType" class="stat-select" @change="handleSegmentStatTypeChange">
|
||||||
|
<el-option v-for="statType in CHECKSQUARE_STAT_TYPES" :key="statType" :label="formatChecksquareStatType(statType)" :value="statType" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" class="detail-table" :data="segments" height="100%" empty-text="暂无缺数区间">
|
||||||
|
<el-table-column prop="statType" label="统计类型" width="100">
|
||||||
|
<template #default="{ row }">{{ formatChecksquareStatType(row.statType) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="90">
|
||||||
|
<template #default="{ row }">{{ formatSegmentStatus(row.status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="startTime" label="开始时间" min-width="170" />
|
||||||
|
<el-table-column prop="endTime" label="结束时间" min-width="170" />
|
||||||
|
<el-table-column prop="harmonicOrder" label="谐波次数" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ row.harmonicOrder ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="missingPointCount" label="缺失点数" width="110" align="right">
|
||||||
|
<template #default="{ row }">{{ row.missingPointCount ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="durationMinutes" label="持续分钟" width="110" align="right">
|
||||||
|
<template #default="{ row }">{{ row.durationMinutes ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="值关系异常" name="VALUE_ORDER">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
class="detail-table"
|
||||||
|
:data="itemDetail?.valueOrderDetails || []"
|
||||||
|
height="100%"
|
||||||
|
empty-text="暂无值关系异常"
|
||||||
|
>
|
||||||
|
<el-table-column prop="time" label="时间" min-width="160" />
|
||||||
|
<el-table-column prop="phase" label="相别" width="80" />
|
||||||
|
<el-table-column prop="harmonicOrder" label="谐波次数" width="96" align="right">
|
||||||
|
<template #default="{ row }">{{ row.harmonicOrder ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="maxValue" label="最大值" min-width="100" align="right" />
|
||||||
|
<el-table-column prop="cp95Value" label="CP95" min-width="100" align="right" />
|
||||||
|
<el-table-column prop="avgValue" label="平均值" min-width="100" align="right" />
|
||||||
|
<el-table-column prop="minValue" label="最小值" min-width="100" align="right" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div v-if="showDetailPagination" class="detail-pagination">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:current-page="detailPageNum"
|
||||||
|
:page-size="DETAIL_PAGE_SIZE"
|
||||||
|
:total="detailTotal"
|
||||||
|
@current-change="handleDetailPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="谐波奇偶异常" name="HARMONIC_PARITY">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
class="detail-table"
|
||||||
|
:data="itemDetail?.harmonicParityDetails || []"
|
||||||
|
height="100%"
|
||||||
|
empty-text="暂无谐波奇偶异常"
|
||||||
|
>
|
||||||
|
<el-table-column prop="time" label="时间" min-width="160" />
|
||||||
|
<el-table-column prop="phase" label="相别" width="80" />
|
||||||
|
<el-table-column prop="statType" label="统计类型" width="100">
|
||||||
|
<template #default="{ row }">{{ row.statType ? formatChecksquareStatType(row.statType) : '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="evenHarmonicOrder" label="偶次" width="80" align="right" />
|
||||||
|
<el-table-column prop="evenValue" label="偶次值" min-width="100" align="right" />
|
||||||
|
<el-table-column prop="oddHarmonicOrders" label="奇次" min-width="110">
|
||||||
|
<template #default="{ row }">{{ formatDetailArray(row.oddHarmonicOrders) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="oddValues" label="奇次值" min-width="130">
|
||||||
|
<template #default="{ row }">{{ formatDetailArray(row.oddValues) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="oddMedianValue" label="奇次中位值" min-width="120" align="right" />
|
||||||
|
<el-table-column prop="thresholdMultiplier" label="阈值倍数" min-width="100" align="right" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div v-if="showDetailPagination" class="detail-pagination">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:current-page="detailPageNum"
|
||||||
|
:page-size="DETAIL_PAGE_SIZE"
|
||||||
|
:total="detailTotal"
|
||||||
|
@current-change="handleDetailPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { getSteadyChecksquareItemDetail } from '@/api/steady/steadyDataView'
|
||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
import {
|
||||||
|
CHECKSQUARE_STAT_TYPES,
|
||||||
|
collectMissingSegments,
|
||||||
|
formatChecksquareStatType,
|
||||||
|
formatDataIntegrity,
|
||||||
|
formatStatMissingRate,
|
||||||
|
resolveChecksquareRowName
|
||||||
|
} from '../utils/checksquareTable'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareDetailPanel'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedItem: SteadyDataView.SteadyChecksquareItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const DETAIL_PAGE_SIZE = 20
|
||||||
|
const detailType = ref<SteadyDataView.SteadyChecksquareDetailType>('SEGMENT')
|
||||||
|
const segmentStatType = ref<SteadyDataView.SteadyTrendStatType>('AVG')
|
||||||
|
const itemDetail = ref<SteadyDataView.SteadyChecksquareItemDetail | null>(null)
|
||||||
|
const detailPageNum = ref(1)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
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 segments = computed(() => {
|
||||||
|
if (itemDetail.value?.segments?.length) {
|
||||||
|
return itemDetail.value.segments.map(segment => ({
|
||||||
|
...segment,
|
||||||
|
statType: itemDetail.value?.statType || segmentStatType.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectMissingSegments(props.selectedItem)
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailTotal = computed(() => {
|
||||||
|
if (typeof itemDetail.value?.total === 'number') return itemDetail.value.total
|
||||||
|
if (detailType.value === 'VALUE_ORDER') return itemDetail.value?.valueOrderDetails?.length || 0
|
||||||
|
if (detailType.value === 'HARMONIC_PARITY') return itemDetail.value?.harmonicParityDetails?.length || 0
|
||||||
|
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const showDetailPagination = computed(() => {
|
||||||
|
return detailType.value !== 'SEGMENT' && detailTotal.value > DETAIL_PAGE_SIZE
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatSegmentStatus = (status?: string) => {
|
||||||
|
if (status === 'NORMAL') return '正常'
|
||||||
|
if (status === 'MISSING') return '缺失'
|
||||||
|
|
||||||
|
return status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDetailArray = (value?: Array<string | number> | null) => {
|
||||||
|
return value?.length ? value.join('、') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCurrentDetail = async () => {
|
||||||
|
if (!props.selectedItem?.itemId) {
|
||||||
|
itemDetail.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await getSteadyChecksquareItemDetail({
|
||||||
|
itemId: props.selectedItem.itemId,
|
||||||
|
detailType: detailType.value,
|
||||||
|
statType: detailType.value === 'SEGMENT' ? segmentStatType.value : undefined,
|
||||||
|
pageNum: detailType.value === 'SEGMENT' ? undefined : detailPageNum.value,
|
||||||
|
pageSize: detailType.value === 'SEGMENT' ? undefined : DETAIL_PAGE_SIZE
|
||||||
|
})
|
||||||
|
itemDetail.value = unwrapData(response)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetailTypeChange = () => {
|
||||||
|
detailPageNum.value = 1
|
||||||
|
itemDetail.value = null
|
||||||
|
loadCurrentDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSegmentStatTypeChange = () => {
|
||||||
|
detailPageNum.value = 1
|
||||||
|
itemDetail.value = null
|
||||||
|
loadCurrentDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetailPageChange = (pageNum: number) => {
|
||||||
|
detailPageNum.value = pageNum
|
||||||
|
loadCurrentDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selectedItem?.itemId,
|
||||||
|
() => {
|
||||||
|
detailType.value = 'SEGMENT'
|
||||||
|
segmentStatType.value = 'AVG'
|
||||||
|
detailPageNum.value = 1
|
||||||
|
itemDetail.value = null
|
||||||
|
loadCurrentDetail()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.checksquare-detail {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tabs :deep(.el-tabs__content) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tabs :deep(.el-tab-pane) {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-item {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-value {
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-select {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table :deep(.el-table__header .cell),
|
||||||
|
.detail-table :deep(.el-table__body .cell) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-pagination {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.item-overview,
|
||||||
|
.stat-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog :model-value="visible" :title="dialogTitle" width="640px" @update:model-value="emit('update:visible', $event)">
|
||||||
|
<el-descriptions :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 { ChecksquareMeasurementPointDetail } from '../utils/checksquareLedger'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareMeasurementPointDialog'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
data: ChecksquareMeasurementPointDetail | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dialogTitle = '监测点信息'
|
||||||
|
const measurementPointItems: { label: string; prop: keyof ChecksquareMeasurementPointDetail }[] = [
|
||||||
|
{ label: '工程名称', prop: 'engineeringName' },
|
||||||
|
{ label: '项目名称', prop: 'projectName' },
|
||||||
|
{ label: '设备名称', prop: 'equipmentName' },
|
||||||
|
{ label: '网络参数', prop: 'networkParam' },
|
||||||
|
{ label: '监测点名称', prop: 'lineName' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const resolveText = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '--'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<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 v-if="result.taskNo" size="small" effect="plain">任务编号:{{ result.taskNo }}</el-tag>
|
||||||
|
<el-tag size="small" effect="plain">{{ result.lineName || result.lineId || '未返回监测点' }}</el-tag>
|
||||||
|
<el-tag size="small" effect="plain">
|
||||||
|
检测时间:{{ result.timeStart || '-' }} 至 {{ result.timeEnd || '-' }}
|
||||||
|
</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="指标名称" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="indicator-name" :title="resolveChecksquareRowName(row)">
|
||||||
|
{{ resolveChecksquareRowName(row) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="abnormalPointCount" label="值关系异常点" width="88" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="{ 'is-abnormal-count': hasAbnormalCount(row.abnormalPointCount) }">
|
||||||
|
{{ row.abnormalPointCount ?? '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="harmonicParityAbnormalPointCount" label="谐波奇偶异常点" width="88" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="{ 'is-abnormal-count': hasAbnormalCount(row.harmonicParityAbnormalPointCount) }">
|
||||||
|
{{ row.harmonicParityAbnormalPointCount ?? '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数据完整性" align="center">
|
||||||
|
<el-table-column prop="hasData" label="是否有数据" width="88" 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="dataIntegrity" label="总体(%)" width="88" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatSummaryDataIntegrity(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="平均值(%)" width="88" align="center">
|
||||||
|
<template #default="{ row }">{{ formatSummaryStatIntegrity(row, 'AVG') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最大值(%)" width="88" align="center">
|
||||||
|
<template #default="{ row }">{{ formatSummaryStatIntegrity(row, 'MAX') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最小值(%)" width="88" align="center">
|
||||||
|
<template #default="{ row }">{{ formatSummaryStatIntegrity(row, 'MIN') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="CP95值(%)" width="88" align="center">
|
||||||
|
<template #default="{ row }">{{ formatSummaryStatIntegrity(row, 'CP95') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="130" 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,
|
||||||
|
formatDataIntegrity,
|
||||||
|
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]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const hasAbnormalCount = (value?: number | null) => Number(value || 0) > 0
|
||||||
|
|
||||||
|
const stripPercentUnit = (value: string) => value.replace(/%$/, '')
|
||||||
|
|
||||||
|
const formatSummaryDataIntegrity = (row: SteadyDataView.SteadyChecksquareItem) => {
|
||||||
|
return stripPercentUnit(formatDataIntegrity(row.dataIntegrity, row.dataIntegrityText))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSummaryStatIntegrity = (
|
||||||
|
row: SteadyDataView.SteadyChecksquareItem,
|
||||||
|
statType: SteadyDataView.SteadyTrendStatType
|
||||||
|
) => {
|
||||||
|
return stripPercentUnit(formatStatMissingRate(row, statType))
|
||||||
|
}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-abnormal-count {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<ProTable
|
||||||
|
ref="proTable"
|
||||||
|
row-key="taskId"
|
||||||
|
:columns="columns"
|
||||||
|
:request-api="getTableList"
|
||||||
|
:search-col="{ xs: 1, sm: 2, md: 2, lg: 5, xl: 5 }"
|
||||||
|
>
|
||||||
|
<template #tableHeader>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="emit('createTask')">新增</el-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #operation="{ row }">
|
||||||
|
<el-button type="danger" link :icon="Delete" @click="emit('delete', row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</ProTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, reactive, ref } from 'vue'
|
||||||
|
import { ElButton, ElDatePicker, ElTag, ElTreeSelect } from 'element-plus'
|
||||||
|
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||||
|
import ProTable from '@/components/ProTable/index.vue'
|
||||||
|
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
import {
|
||||||
|
buildChecksquareTaskQueryParams,
|
||||||
|
formatChecksquareIntegrity,
|
||||||
|
formatChecksquareTaskStatus,
|
||||||
|
resolveChecksquareTaskStatusType,
|
||||||
|
resolveChecksquareText,
|
||||||
|
type ChecksquareTaskSearchParams
|
||||||
|
} from '../utils/checksquareTaskTable'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareTaskTable'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
ledgerTree: SteadyDataView.SteadyLedgerNode[]
|
||||||
|
indicatorTree: SteadyDataView.SteadyIndicatorNode[]
|
||||||
|
requestApi: (params: SteadyDataView.SteadyChecksquareTaskQueryParams) => Promise<any>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
createTask: []
|
||||||
|
detail: [row: SteadyDataView.SteadyChecksquareTask]
|
||||||
|
delete: [row: SteadyDataView.SteadyChecksquareTask]
|
||||||
|
viewMeasurementPoint: [row: SteadyDataView.SteadyChecksquareTask]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const proTable = ref<ProTableInstance>()
|
||||||
|
|
||||||
|
interface ChecksquareFilterTreeNode {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
children?: ChecksquareFilterTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeLineFilterTree = (nodes: SteadyDataView.SteadyLedgerNode[]): ChecksquareFilterTreeNode[] => {
|
||||||
|
return nodes.map(node => ({
|
||||||
|
label: node.name,
|
||||||
|
value: node.id,
|
||||||
|
disabled: node.level !== 3 || node.selectable === false,
|
||||||
|
children: node.children?.length ? normalizeLineFilterTree(node.children) : undefined
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeIndicatorFilterTree = (
|
||||||
|
nodes: SteadyDataView.SteadyIndicatorNode[],
|
||||||
|
parentKey = ''
|
||||||
|
): ChecksquareFilterTreeNode[] => {
|
||||||
|
return nodes.map((node, index) => {
|
||||||
|
const isLeaf = !node.children?.length
|
||||||
|
const value =
|
||||||
|
isLeaf && node.indicatorCode
|
||||||
|
? node.indicatorCode
|
||||||
|
: node.id || `${parentKey}${node.groupCode || node.name || 'node'}-${index}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: node.unit ? `${node.name}(${node.unit})` : node.name,
|
||||||
|
value,
|
||||||
|
disabled: !isLeaf || !node.indicatorCode,
|
||||||
|
children: node.children?.length ? normalizeIndicatorFilterTree(node.children, `${value}-`) : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineFilterTree = computed(() => normalizeLineFilterTree(props.ledgerTree))
|
||||||
|
const indicatorFilterTree = computed(() => normalizeIndicatorFilterTree(props.indicatorTree))
|
||||||
|
|
||||||
|
const splitTreeSelectValues = (value?: string) => {
|
||||||
|
return (value || '')
|
||||||
|
.split(',')
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeTreeSelectValues = (value: unknown) => {
|
||||||
|
const rawValues = Array.isArray(value) ? value : value === undefined || value === null || value === '' ? [] : [value]
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
rawValues
|
||||||
|
.filter((item): item is string | number => typeof item === 'string' || typeof item === 'number')
|
||||||
|
.map(item => String(item).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTimeRangeSearch = ({ searchParam }: { searchParam: ChecksquareTaskSearchParams }) =>
|
||||||
|
h(ElDatePicker, {
|
||||||
|
modelValue: searchParam.taskTimeRange,
|
||||||
|
type: 'datetimerange',
|
||||||
|
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
startPlaceholder: '开始时间',
|
||||||
|
endPlaceholder: '结束时间',
|
||||||
|
clearable: true,
|
||||||
|
'onUpdate:modelValue': (value: string[] | null) => {
|
||||||
|
searchParam.taskTimeRange = value || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderLineSearch = ({ searchParam }: { searchParam: ChecksquareTaskSearchParams }) =>
|
||||||
|
h(ElTreeSelect, {
|
||||||
|
class: 'checksquare-search-tree-select',
|
||||||
|
style: { width: '100%' },
|
||||||
|
modelValue: splitTreeSelectValues(searchParam.lineId),
|
||||||
|
data: lineFilterTree.value,
|
||||||
|
nodeKey: 'value',
|
||||||
|
multiple: true,
|
||||||
|
showCheckbox: true,
|
||||||
|
collapseTags: true,
|
||||||
|
collapseTagsTooltip: true,
|
||||||
|
maxCollapseTags: 1,
|
||||||
|
filterable: true,
|
||||||
|
clearable: true,
|
||||||
|
defaultExpandAll: true,
|
||||||
|
checkStrictly: true,
|
||||||
|
props: { label: 'label', children: 'children', disabled: 'disabled' },
|
||||||
|
placeholder: '请选择监测点',
|
||||||
|
'onUpdate:modelValue': (value: unknown) => {
|
||||||
|
const selectedValues = normalizeTreeSelectValues(value)
|
||||||
|
searchParam.lineId = selectedValues.length ? selectedValues.join(',') : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderIndicatorSearch = ({ searchParam }: { searchParam: ChecksquareTaskSearchParams }) =>
|
||||||
|
h(ElTreeSelect, {
|
||||||
|
class: 'checksquare-search-tree-select',
|
||||||
|
style: { width: '100%' },
|
||||||
|
modelValue: splitTreeSelectValues(searchParam.indicatorCode),
|
||||||
|
data: indicatorFilterTree.value,
|
||||||
|
nodeKey: 'value',
|
||||||
|
multiple: true,
|
||||||
|
showCheckbox: true,
|
||||||
|
collapseTags: true,
|
||||||
|
collapseTagsTooltip: true,
|
||||||
|
maxCollapseTags: 1,
|
||||||
|
filterable: true,
|
||||||
|
clearable: true,
|
||||||
|
defaultExpandAll: true,
|
||||||
|
checkStrictly: true,
|
||||||
|
props: { label: 'label', children: 'children', disabled: 'disabled' },
|
||||||
|
placeholder: '请选择稳态指标',
|
||||||
|
'onUpdate:modelValue': (value: unknown) => {
|
||||||
|
const selectedValues = normalizeTreeSelectValues(value)
|
||||||
|
searchParam.indicatorCode = selectedValues.length ? selectedValues.join(',') : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = reactive<ColumnProps<SteadyDataView.SteadyChecksquareTask>[]>([
|
||||||
|
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||||
|
{
|
||||||
|
prop: 'taskNo',
|
||||||
|
label: '任务编号',
|
||||||
|
minWidth: 180,
|
||||||
|
render: ({ row }) => resolveChecksquareText(row.taskNo)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'lineId',
|
||||||
|
label: '监测点ID',
|
||||||
|
isShow: false,
|
||||||
|
isSetting: false,
|
||||||
|
search: {
|
||||||
|
label: '监测点',
|
||||||
|
order: 2,
|
||||||
|
render: renderLineSearch
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'lineName',
|
||||||
|
label: '监测点名称',
|
||||||
|
minWidth: 160,
|
||||||
|
render: ({ row }) =>
|
||||||
|
h(
|
||||||
|
ElButton,
|
||||||
|
{
|
||||||
|
type: 'primary',
|
||||||
|
link: true,
|
||||||
|
onClick: () => emit('viewMeasurementPoint', row)
|
||||||
|
},
|
||||||
|
() => resolveChecksquareText(row.lineName || row.lineId)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'indicatorCode',
|
||||||
|
label: '稳态指标',
|
||||||
|
isShow: false,
|
||||||
|
isSetting: false,
|
||||||
|
search: {
|
||||||
|
label: '稳态指标',
|
||||||
|
order: 3,
|
||||||
|
render: renderIndicatorSearch
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'timeStart',
|
||||||
|
label: '开始时间',
|
||||||
|
minWidth: 170,
|
||||||
|
render: ({ row }) => resolveChecksquareText(row.timeStart),
|
||||||
|
search: {
|
||||||
|
label: '检测时间',
|
||||||
|
key: 'taskTimeRange',
|
||||||
|
order: 1,
|
||||||
|
render: renderTimeRangeSearch
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'timeEnd',
|
||||||
|
label: '结束时间',
|
||||||
|
minWidth: 170,
|
||||||
|
render: ({ row }) => resolveChecksquareText(row.timeEnd)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'taskStatus',
|
||||||
|
label: '任务状态',
|
||||||
|
minWidth: 110,
|
||||||
|
render: ({ row }) =>
|
||||||
|
h(
|
||||||
|
ElTag,
|
||||||
|
{ type: resolveChecksquareTaskStatusType(row.taskStatus), effect: 'plain' },
|
||||||
|
() => formatChecksquareTaskStatus(row.taskStatus)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'itemCount',
|
||||||
|
label: '检测项数',
|
||||||
|
minWidth: 100,
|
||||||
|
align: 'center',
|
||||||
|
render: ({ row }) => resolveChecksquareText(row.itemCount)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'abnormalItemCount',
|
||||||
|
label: '异常项数',
|
||||||
|
minWidth: 100,
|
||||||
|
align: 'center',
|
||||||
|
render: ({ row }) =>
|
||||||
|
h(
|
||||||
|
ElButton,
|
||||||
|
{
|
||||||
|
type: 'primary',
|
||||||
|
link: true,
|
||||||
|
onClick: () => emit('detail', row)
|
||||||
|
},
|
||||||
|
() => resolveChecksquareText(row.abnormalItemCount)
|
||||||
|
),
|
||||||
|
search: {
|
||||||
|
label: '异常状态',
|
||||||
|
key: 'hasAbnormal',
|
||||||
|
order: 4,
|
||||||
|
el: 'select'
|
||||||
|
},
|
||||||
|
enum: [
|
||||||
|
{ label: '存在异常', value: true },
|
||||||
|
{ label: '全部', value: false }
|
||||||
|
],
|
||||||
|
isFilterEnum: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'minDataIntegrity',
|
||||||
|
label: '最低完整性',
|
||||||
|
minWidth: 120,
|
||||||
|
align: 'center',
|
||||||
|
render: ({ row }) => formatChecksquareIntegrity(row.minDataIntegrity)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'createTime',
|
||||||
|
label: '创建时间',
|
||||||
|
minWidth: 170,
|
||||||
|
render: ({ row }) => resolveChecksquareText(row.createTime)
|
||||||
|
},
|
||||||
|
{ prop: 'operation', label: '操作', fixed: 'right', width: 150 }
|
||||||
|
])
|
||||||
|
|
||||||
|
const getTableList = (params: ChecksquareTaskSearchParams) => {
|
||||||
|
return props.requestApi(buildChecksquareTaskQueryParams(params))
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
proTable.value?.getTableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
refresh
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.checksquare-search-tree-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.checksquare-search-tree-select .el-select__wrapper) {
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.checksquare-search-tree-select .el-select__selection) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.checksquare-search-tree-select .el-select__tags-text) {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: bottom;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<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="query-actions">
|
||||||
|
<el-button type="primary" :icon="Plus" :loading="loading.query" @click="emit('create')">
|
||||||
|
新增
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain :icon="RefreshLeft" @click="emit('reset')">重置</el-button>
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="checksquare-result-slot">
|
||||||
|
<slot name="result" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { Plus, RefreshLeft } 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'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareWorkbench'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
form: ChecksquareFormState
|
||||||
|
ledgerTree: SteadyDataView.SteadyLedgerNode[]
|
||||||
|
indicatorTree: SteadyDataView.SteadyIndicatorNode[]
|
||||||
|
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[]]
|
||||||
|
create: []
|
||||||
|
reset: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedIndicatorKeys = ref<string[]>([])
|
||||||
|
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 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: 240px 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: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-result-slot {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-result-slot :deep(.checksquare-result-panel) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-card {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-field--time {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
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 {
|
||||||
|
order: 2;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-select-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
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;
|
||||||
|
order: 3;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.checksquare-layout:not(.is-ledger-collapsed) {
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.toolbar-field--time,
|
||||||
|
.indicator-form-item {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-result-slot {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
/* 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'),
|
||||||
|
taskTable: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareTaskTable.vue'),
|
||||||
|
summaryTable: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareSummaryTable.vue'),
|
||||||
|
detailPanel: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareDetailPanel.vue'),
|
||||||
|
createResultPanel: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareCreateResultPanel.vue'),
|
||||||
|
measurementPointDialog: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareMeasurementPointDialog.vue'),
|
||||||
|
payload: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquarePayload.ts'),
|
||||||
|
ledgerUtils: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquareLedger.ts'),
|
||||||
|
taskTableUtils: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquareTaskTable.ts'),
|
||||||
|
table: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquareTable.ts'),
|
||||||
|
grid: path.resolve(rootDir, 'components/Grid/index.vue'),
|
||||||
|
searchForm: path.resolve(rootDir, 'components/SearchForm/index.vue'),
|
||||||
|
searchFormItem: path.resolve(rootDir, 'components/SearchForm/components/SearchFormItem.vue')
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = file => (exists(file) ? fs.readFileSync(file, 'utf8') : '')
|
||||||
|
const exists = file => fs.existsSync(file)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['checksquare task query api exists', () => /querySteadyChecksquareTasks/.test(read(files.api))],
|
||||||
|
[
|
||||||
|
'checksquare api exposes all documented endpoints',
|
||||||
|
() => {
|
||||||
|
const api = read(files.api)
|
||||||
|
return (
|
||||||
|
/\/steady\/checksquare\/query/.test(api) &&
|
||||||
|
/\/steady\/checksquare\/create/.test(api) &&
|
||||||
|
!/\/steady\/checksquare\/get-or-create/.test(api) &&
|
||||||
|
/\/steady\/checksquare\/delete/.test(api) &&
|
||||||
|
/\/steady\/checksquare\/detail/.test(api) &&
|
||||||
|
/\/steady\/checksquare\/item-detail/.test(api)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare delete api accepts documented task id array body',
|
||||||
|
() =>
|
||||||
|
/export const deleteSteadyChecksquareTasks = \(taskIds: SteadyDataView\.SteadyChecksquareDeleteParams\)/.test(
|
||||||
|
read(files.api)
|
||||||
|
) &&
|
||||||
|
/http\.post<boolean>\('\/steady\/checksquare\/delete', taskIds/.test(read(files.api)) &&
|
||||||
|
/export type SteadyChecksquareDeleteParams = string\[\]/.test(read(files.apiTypes))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare task query params support page and filters',
|
||||||
|
() =>
|
||||||
|
/interface SteadyChecksquareTaskQueryParams[\s\S]*pageNum\?: number[\s\S]*pageSize\?: number[\s\S]*lineId\?: string[\s\S]*indicatorCode\?: string[\s\S]*hasAbnormal\?: boolean/.test(
|
||||||
|
read(files.apiTypes)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare create params match backend create body',
|
||||||
|
() => {
|
||||||
|
const typeBlock =
|
||||||
|
read(files.apiTypes).match(/interface SteadyChecksquareCreateParams\s*\{[\s\S]*?\n {4}\}/)?.[0] || ''
|
||||||
|
return (
|
||||||
|
/lineId: string/.test(typeBlock) &&
|
||||||
|
/indicatorCodes: string\[\]/.test(typeBlock) &&
|
||||||
|
/timeStart: string/.test(typeBlock) &&
|
||||||
|
/timeEnd: string/.test(typeBlock) &&
|
||||||
|
!/harmonicOrders/.test(typeBlock)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
['task table component exists', () => exists(files.taskTable)],
|
||||||
|
['task table uses ProTable like event list', () => /<ProTable[\s\S]*row-key="taskId"[\s\S]*:columns="columns"/.test(read(files.taskTable))],
|
||||||
|
[
|
||||||
|
'task table exposes create task header action',
|
||||||
|
() =>
|
||||||
|
/<template #tableHeader>/.test(read(files.taskTable)) &&
|
||||||
|
/>新增<\/el-button>/.test(read(files.taskTable)) &&
|
||||||
|
/emit\('createTask'\)/.test(read(files.taskTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task table has documented task columns',
|
||||||
|
() => {
|
||||||
|
const source = read(files.taskTable)
|
||||||
|
return ['taskNo', 'lineName', 'timeStart', 'timeEnd', 'taskStatus', 'itemCount', 'abnormalItemCount', 'minDataIntegrity', 'createTime'].every(
|
||||||
|
prop => new RegExp(`prop:\\s*'${prop}'`).test(source)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task table exposes row delete action without duplicate detail operation',
|
||||||
|
() => {
|
||||||
|
const taskTable = read(files.taskTable)
|
||||||
|
const operationSlot = taskTable.match(/<template #operation="\{ row \}">[\s\S]*?<\/template>/)?.[0] || ''
|
||||||
|
return (
|
||||||
|
/emit\('delete', row\)/.test(operationSlot) &&
|
||||||
|
!/emit\('detail', row\)/.test(operationSlot) &&
|
||||||
|
/delete: \[row: SteadyDataView\.SteadyChecksquareTask\]/.test(taskTable) &&
|
||||||
|
/Delete/.test(taskTable)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task detail opens from abnormal item count value',
|
||||||
|
() => {
|
||||||
|
const taskTable = read(files.taskTable)
|
||||||
|
return (
|
||||||
|
/prop:\s*'abnormalItemCount'[\s\S]*ElButton[\s\S]*type:\s*'primary'[\s\S]*link:\s*true[\s\S]*emit\('detail', row\)/.test(
|
||||||
|
taskTable
|
||||||
|
) &&
|
||||||
|
/resolveChecksquareText\(row\.abnormalItemCount\)/.test(taskTable)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task table query params convert time range and abnormal filter',
|
||||||
|
() =>
|
||||||
|
/buildChecksquareTaskQueryParams/.test(read(files.taskTable)) &&
|
||||||
|
/taskTimeRange/.test(read(files.taskTableUtils)) &&
|
||||||
|
/hasAbnormal/.test(read(files.taskTable))
|
||||||
|
],
|
||||||
|
['workbench remains create dialog selector body', () => /SteadyLedgerTree/.test(read(files.workbench)) && /TimePeriodSearch/.test(read(files.workbench))],
|
||||||
|
['workbench emits create instead of old query action', () => /create: \[\]/.test(read(files.workbench)) && !/query: \[\]/.test(read(files.workbench))],
|
||||||
|
['workbench no longer renders result table', () => !/ChecksquareSummaryTable/.test(read(files.workbench))],
|
||||||
|
[
|
||||||
|
'workbench create action uses short add label',
|
||||||
|
() => /@click="emit\('create'\)"[\s\S]*>\s*新增\s*<\/el-button>/.test(read(files.workbench))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'create dialog workbench places search controls in two rows with actions after indicator',
|
||||||
|
() => {
|
||||||
|
const workbench = read(files.workbench)
|
||||||
|
return (
|
||||||
|
/\.query-card\s*\{[\s\S]*display:\s*flex[\s\S]*flex-wrap:\s*wrap/.test(workbench) &&
|
||||||
|
/\.toolbar-field--time\s*\{[\s\S]*flex:\s*1\s+1\s+100%/.test(workbench) &&
|
||||||
|
/\.indicator-form-item\s*\{[\s\S]*order:\s*2[\s\S]*flex:\s*1\s+1\s+auto/.test(workbench) &&
|
||||||
|
/\.query-actions\s*\{[\s\S]*order:\s*3[\s\S]*flex:\s*0\s+0\s+auto/.test(workbench) &&
|
||||||
|
/<div class="query-actions">[\s\S]*emit\('create'\)[\s\S]*emit\('reset'\)[\s\S]*<\/div>\s*<div class="toolbar-field indicator-form-item">/.test(
|
||||||
|
workbench
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'payload builds create params without harmonic orders',
|
||||||
|
() => /buildSteadyChecksquareCreatePayload/.test(read(files.payload)) && !/harmonicOrder/.test(read(files.payload))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page renders task table as first screen',
|
||||||
|
() =>
|
||||||
|
/<ChecksquareTaskTable[\s\S]*@create-task="openCreateDialog"[\s\S]*@detail="openTaskDetail"[\s\S]*@delete="handleDeleteTask"/.test(
|
||||||
|
read(files.page)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page passes steady ledger and indicator trees to task table filters',
|
||||||
|
() =>
|
||||||
|
/<ChecksquareTaskTable[\s\S]*:ledger-tree="ledgerTree"[\s\S]*:indicator-tree="indicatorTree"/.test(
|
||||||
|
read(files.page)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task table receives steady ledger and indicator tree filter data',
|
||||||
|
() =>
|
||||||
|
/ledgerTree:\s*SteadyDataView\.SteadyLedgerNode\[\]/.test(read(files.taskTable)) &&
|
||||||
|
/indicatorTree:\s*SteadyDataView\.SteadyIndicatorNode\[\]/.test(read(files.taskTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task table monitor point filter uses dropdown tree instead of lineId input',
|
||||||
|
() =>
|
||||||
|
/renderLineSearch/.test(read(files.taskTable)) &&
|
||||||
|
/ElTreeSelect/.test(read(files.taskTable)) &&
|
||||||
|
/multiple:\s*true/.test(read(files.taskTable)) &&
|
||||||
|
/normalizeTreeSelectValues/.test(read(files.taskTable)) &&
|
||||||
|
/checkStrictly:\s*true/.test(read(files.taskTable)) &&
|
||||||
|
!/lineId[\s\S]*?el:\s*'input'/.test(read(files.taskTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task table indicator code filter uses steady indicator tree selection',
|
||||||
|
() =>
|
||||||
|
/renderIndicatorSearch/.test(read(files.taskTable)) &&
|
||||||
|
/indicatorFilterTree/.test(read(files.taskTable)) &&
|
||||||
|
/multiple:\s*true/.test(read(files.taskTable)) &&
|
||||||
|
/normalizeTreeSelectValues/.test(read(files.taskTable)) &&
|
||||||
|
/indicatorCode/.test(read(files.taskTable)) &&
|
||||||
|
/style:\s*\{\s*width:\s*'100%'\s*\}/.test(read(files.taskTable)) &&
|
||||||
|
!/indicatorCode[\s\S]*?el:\s*'input'/.test(read(files.taskTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task table displays indicator code filter as steady indicator',
|
||||||
|
() =>
|
||||||
|
/label:\s*'稳态指标'/.test(read(files.taskTable)) &&
|
||||||
|
/placeholder:\s*'请选择稳态指标'/.test(read(files.taskTable)) &&
|
||||||
|
!/label:\s*'指标编码'/.test(read(files.taskTable)) &&
|
||||||
|
!/placeholder:\s*'请选择指标编码'/.test(read(files.taskTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task table tree select filters keep selected tags visible',
|
||||||
|
() => {
|
||||||
|
const taskTable = read(files.taskTable)
|
||||||
|
return (
|
||||||
|
/class:\s*'checksquare-search-tree-select'/.test(taskTable) &&
|
||||||
|
/maxCollapseTags:\s*1/.test(taskTable) &&
|
||||||
|
/\.checksquare-search-tree-select\s*\{[\s\S]*width:\s*100%/.test(taskTable) &&
|
||||||
|
/:deep\(\.checksquare-search-tree-select \.el-select__tags-text\)[\s\S]*max-width/.test(taskTable)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task table monitor point name opens measurement point dialog like event list',
|
||||||
|
() => {
|
||||||
|
const taskTable = read(files.taskTable)
|
||||||
|
return (
|
||||||
|
/viewMeasurementPoint:\s*\[row: SteadyDataView\.SteadyChecksquareTask\]/.test(taskTable) &&
|
||||||
|
/prop:\s*'lineName'[\s\S]*ElButton[\s\S]*type:\s*'primary'[\s\S]*link:\s*true[\s\S]*emit\('viewMeasurementPoint', row\)/.test(
|
||||||
|
taskTable
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare measurement point dialog matches event list fields',
|
||||||
|
() => {
|
||||||
|
const dialog = read(files.measurementPointDialog)
|
||||||
|
return (
|
||||||
|
exists(files.measurementPointDialog) &&
|
||||||
|
/name:\s*'ChecksquareMeasurementPointDialog'/.test(dialog) &&
|
||||||
|
/dialogTitle\s*=\s*'监测点信息'/.test(dialog) &&
|
||||||
|
/工程名称/.test(dialog) &&
|
||||||
|
/项目名称/.test(dialog) &&
|
||||||
|
/设备名称/.test(dialog) &&
|
||||||
|
/网络参数/.test(dialog) &&
|
||||||
|
/监测点名称/.test(dialog) &&
|
||||||
|
/resolveText/.test(dialog)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare ledger utils resolve monitor point detail from loaded ledger tree',
|
||||||
|
() => {
|
||||||
|
const source = read(files.ledgerUtils)
|
||||||
|
return (
|
||||||
|
exists(files.ledgerUtils) &&
|
||||||
|
/resolveChecksquareMeasurementPointDetail/.test(source) &&
|
||||||
|
/engineeringName/.test(source) &&
|
||||||
|
/projectName/.test(source) &&
|
||||||
|
/equipmentName/.test(source) &&
|
||||||
|
/lineName/.test(source) &&
|
||||||
|
/networkParam/.test(source)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page wires checksquare measurement point dialog to task table',
|
||||||
|
() => {
|
||||||
|
const page = read(files.page)
|
||||||
|
return (
|
||||||
|
/@view-measurement-point="openMeasurementPointDialog"/.test(page) &&
|
||||||
|
/<ChecksquareMeasurementPointDialog[\s\S]*v-model:visible="measurementPointDialogVisible"[\s\S]*:data="measurementPointData"/.test(
|
||||||
|
page
|
||||||
|
) &&
|
||||||
|
/resolveChecksquareMeasurementPointDetail/.test(page)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'search grid keeps third filter visible when operation column exactly fills first row',
|
||||||
|
() => /Number\(prev\)\s*>\s*props\.collapsedRows \* gridCols\.value - suffixCols/.test(read(files.grid))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'search collapse toggle only appears when filters exceed available first row columns',
|
||||||
|
() =>
|
||||||
|
/const searchColCount[\s\S]*typeof props\.searchCol !== 'number'[\s\S]*props\.searchCol\[breakPoint\.value\][\s\S]*const firstRowSearchCols[\s\S]*Math\.max\(searchColCount - 1,\s*1\)[\s\S]*prev\s*>\s*firstRowSearchCols/.test(
|
||||||
|
read(files.searchForm)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare task search grid follows event list five-column layout',
|
||||||
|
() => /:search-col="\{\s*xs:\s*1,\s*sm:\s*2,\s*md:\s*2,\s*lg:\s*5,\s*xl:\s*5\s*\}"/.test(read(files.taskTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'custom search render does not receive generic form item v-model',
|
||||||
|
() =>
|
||||||
|
/v-if=['"]!column\.search\?\.render['"]/.test(read(files.searchFormItem)) &&
|
||||||
|
/v-else[\s\S]*:is="column\.search\.render"/.test(read(files.searchFormItem))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page wraps old workbench in create dialog',
|
||||||
|
() =>
|
||||||
|
/<el-dialog[\s\S]*新增校验任务[\s\S]*width="960px"[\s\S]*<ChecksquareWorkbench/.test(
|
||||||
|
read(files.page)
|
||||||
|
) &&
|
||||||
|
/\.checksquare-create-dialog\s*\{[\s\S]*height:\s*560px/.test(read(files.page))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page create flow calls create api, keeps summary only and refreshes task table',
|
||||||
|
() =>
|
||||||
|
/createSteadyChecksquareTask/.test(read(files.page)) &&
|
||||||
|
!/getOrCreateSteadyChecksquareTask/.test(read(files.page)) &&
|
||||||
|
!/refreshCreateTaskDetail/.test(read(files.page)) &&
|
||||||
|
!/startCreateTaskPolling/.test(read(files.page)) &&
|
||||||
|
/taskTableRef\.value\?\.refresh\(\)/.test(read(files.page)) &&
|
||||||
|
/activeCreateTask\.value/.test(read(files.page))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'create dialog shows create task summary without detail table',
|
||||||
|
() => {
|
||||||
|
const page = read(files.page)
|
||||||
|
const workbench = read(files.workbench)
|
||||||
|
const panel = read(files.createResultPanel)
|
||||||
|
return (
|
||||||
|
exists(files.createResultPanel) &&
|
||||||
|
/<template #result>[\s\S]*<ChecksquareCreateResultPanel[\s\S]*:task="activeCreateTask"[\s\S]*<\/template>/.test(
|
||||||
|
page
|
||||||
|
) &&
|
||||||
|
/\.checksquare-create-dialog\s*\{[\s\S]*display:\s*block/.test(page) &&
|
||||||
|
/<slot name="result" \/>/.test(workbench) &&
|
||||||
|
/\.checksquare-main\s*\{[\s\S]*display:\s*flex[\s\S]*flex-direction:\s*column/.test(workbench) &&
|
||||||
|
/\.checksquare-layout\s*\{[\s\S]*grid-template-columns:\s*240px\s+minmax\(0,\s*1fr\)/.test(
|
||||||
|
workbench
|
||||||
|
) &&
|
||||||
|
/\.checksquare-result-slot\s*\{[\s\S]*width:\s*100%/.test(workbench) &&
|
||||||
|
/\.checksquare-result-slot\s*\{[\s\S]*min-width:\s*0/.test(workbench) &&
|
||||||
|
/\.checksquare-result-slot\s*:deep\(\.checksquare-result-panel\)\s*\{[\s\S]*height:\s*100%/.test(
|
||||||
|
workbench
|
||||||
|
) &&
|
||||||
|
/name:\s*'ChecksquareCreateResultPanel'/.test(panel) &&
|
||||||
|
/class="result-overview"/.test(panel) &&
|
||||||
|
/class="result-body"/.test(panel) &&
|
||||||
|
/class="result-detail-grid"/.test(panel) &&
|
||||||
|
!/detail-block--wide/.test(panel) &&
|
||||||
|
/\.result-overview\s*\{[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\)/.test(panel) &&
|
||||||
|
/\.result-metrics\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/.test(panel) &&
|
||||||
|
/\.result-detail-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/.test(panel) &&
|
||||||
|
!/class="result-tips"/.test(panel) &&
|
||||||
|
!/class="tips-title"/.test(panel) &&
|
||||||
|
!/class="tips-list"/.test(panel) &&
|
||||||
|
/class="result-metrics"[\s\S]*lineName[\s\S]*task\.itemCount/.test(panel) &&
|
||||||
|
/校验任务摘要/.test(panel) &&
|
||||||
|
/task\.itemCount/.test(panel) &&
|
||||||
|
/task\.abnormalItemCount/.test(panel) &&
|
||||||
|
/task\.minDataIntegrity/.test(panel) &&
|
||||||
|
!/class="detail-label">任务编号/.test(panel) &&
|
||||||
|
/\.result-body\s*\{[\s\S]*flex:\s*1/.test(panel) &&
|
||||||
|
!/class="result-detail-table"/.test(panel) &&
|
||||||
|
!/resultItems/.test(panel) &&
|
||||||
|
!/emit\('detail', row\)/.test(panel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'create dialog does not poll create task detail',
|
||||||
|
() => {
|
||||||
|
const page = read(files.page)
|
||||||
|
return (
|
||||||
|
!/createTaskPollingTimer/.test(page) &&
|
||||||
|
!/startCreateTaskPolling/.test(page) &&
|
||||||
|
!/stopCreateTaskPolling/.test(page) &&
|
||||||
|
!/setInterval/.test(page) &&
|
||||||
|
!/onBeforeUnmount/.test(page)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page delete flow confirms, calls delete api and refreshes task table',
|
||||||
|
() =>
|
||||||
|
/deleteSteadyChecksquareTasks/.test(read(files.page)) &&
|
||||||
|
/ElMessageBox\.confirm/.test(read(files.page)) &&
|
||||||
|
/deleteSteadyChecksquareTasks\(\[row\.taskId\]\)/.test(read(files.page)) &&
|
||||||
|
/taskTableRef\.value\?\.refresh\(\)/.test(read(files.page))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page detail flow calls detail api',
|
||||||
|
() => /getSteadyChecksquareDetail/.test(read(files.page)) && /detailDialogVisible\.value = true/.test(read(files.page))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task detail and item detail dialogs use the same size',
|
||||||
|
() => {
|
||||||
|
const page = read(files.page)
|
||||||
|
return (
|
||||||
|
/v-model="detailDialogVisible"[\s\S]*?width="1080px"/.test(page) &&
|
||||||
|
/v-model="itemDetailDialogVisible"[\s\S]*?width="1080px"/.test(page) &&
|
||||||
|
/v-model="detailDialogVisible"[\s\S]*?class="checksquare-detail-dialog"/.test(page) &&
|
||||||
|
/v-model="itemDetailDialogVisible"[\s\S]*?class="checksquare-detail-dialog"/.test(page) &&
|
||||||
|
/\.checksquare-detail-dialog\s*\{[\s\S]*height:\s*560px/.test(page)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table supports persisted abnormal fields',
|
||||||
|
() => /abnormalPointCount/.test(read(files.summaryTable)) && /harmonicParityAbnormalPointCount/.test(read(files.summaryTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table renders positive abnormal counts in danger color',
|
||||||
|
() => {
|
||||||
|
const summaryTable = read(files.summaryTable)
|
||||||
|
return (
|
||||||
|
/hasAbnormalCount/.test(summaryTable) &&
|
||||||
|
/:class="\{\s*'is-abnormal-count': hasAbnormalCount\(row\.abnormalPointCount\)\s*\}"/.test(summaryTable) &&
|
||||||
|
/:class="\{\s*'is-abnormal-count': hasAbnormalCount\(row\.harmonicParityAbnormalPointCount\)\s*\}"/.test(
|
||||||
|
summaryTable
|
||||||
|
) &&
|
||||||
|
/\.is-abnormal-count\s*\{[\s\S]*color:\s*var\(--el-color-danger\)/.test(summaryTable)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table groups data integrity columns under compact double header',
|
||||||
|
() => {
|
||||||
|
const summaryTable = read(files.summaryTable)
|
||||||
|
const dataIntegrityGroup =
|
||||||
|
summaryTable.match(/<el-table-column label="数据完整性"[\s\S]*?<\/el-table-column>\s*<el-table-column label="操作"/)?.[0] ||
|
||||||
|
''
|
||||||
|
|
||||||
|
return (
|
||||||
|
/label="数据完整性"/.test(dataIntegrityGroup) &&
|
||||||
|
/prop="hasData" label="是否有数据" width="88"/.test(dataIntegrityGroup) &&
|
||||||
|
/prop="dataIntegrity" label="总体\(%\)" width="88"/.test(dataIntegrityGroup) &&
|
||||||
|
/label="平均值\(%\)" width="88"/.test(dataIntegrityGroup) &&
|
||||||
|
/label="最大值\(%\)" width="88"/.test(dataIntegrityGroup) &&
|
||||||
|
/label="最小值\(%\)" width="88"/.test(dataIntegrityGroup) &&
|
||||||
|
/label="CP95值\(%\)" width="88"/.test(dataIntegrityGroup)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table removes percent unit from data integrity cell values',
|
||||||
|
() => {
|
||||||
|
const summaryTable = read(files.summaryTable)
|
||||||
|
return (
|
||||||
|
/stripPercentUnit/.test(summaryTable) &&
|
||||||
|
/formatSummaryDataIntegrity/.test(summaryTable) &&
|
||||||
|
/formatSummaryStatIntegrity/.test(summaryTable) &&
|
||||||
|
/formatSummaryDataIntegrity\(row\)/.test(summaryTable) &&
|
||||||
|
/formatSummaryStatIntegrity\(row,\s*'AVG'\)/.test(summaryTable) &&
|
||||||
|
/formatSummaryStatIntegrity\(row,\s*'MAX'\)/.test(summaryTable) &&
|
||||||
|
/formatSummaryStatIntegrity\(row,\s*'MIN'\)/.test(summaryTable) &&
|
||||||
|
/formatSummaryStatIntegrity\(row,\s*'CP95'\)/.test(summaryTable)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table keeps abnormal and operation columns compact',
|
||||||
|
() => {
|
||||||
|
const summaryTable = read(files.summaryTable)
|
||||||
|
return (
|
||||||
|
/prop="abnormalPointCount" label="值关系异常点" width="88"/.test(summaryTable) &&
|
||||||
|
/prop="harmonicParityAbnormalPointCount" label="谐波奇偶异常点" width="88"/.test(summaryTable) &&
|
||||||
|
/<el-table-column label="操作" width="130"/.test(summaryTable)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table keeps indicator name column at configured width',
|
||||||
|
() => /prop="indicatorName" label="指标名称" width="160"/.test(read(files.summaryTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table places abnormal fields after indicator name and hides max continuous missing column',
|
||||||
|
() => {
|
||||||
|
const summaryTable = read(files.summaryTable)
|
||||||
|
const indicatorIndex = summaryTable.indexOf('prop="indicatorName"')
|
||||||
|
const valueOrderIndex = summaryTable.indexOf('prop="abnormalPointCount"')
|
||||||
|
const harmonicParityIndex = summaryTable.indexOf('prop="harmonicParityAbnormalPointCount"')
|
||||||
|
const hasDataIndex = summaryTable.indexOf('prop="hasData"')
|
||||||
|
|
||||||
|
return (
|
||||||
|
!/prop="maxContinuousMissingMinutes"/.test(summaryTable) &&
|
||||||
|
indicatorIndex >= 0 &&
|
||||||
|
valueOrderIndex > indicatorIndex &&
|
||||||
|
harmonicParityIndex > valueOrderIndex &&
|
||||||
|
hasDataIndex > harmonicParityIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare display uses documented data integrity fields instead of missing rate fields',
|
||||||
|
() => {
|
||||||
|
const types = read(files.apiTypes)
|
||||||
|
const taskTable = read(files.taskTable)
|
||||||
|
const summaryTable = read(files.summaryTable)
|
||||||
|
const tableUtils = read(files.table)
|
||||||
|
const taskTableUtils = read(files.taskTableUtils)
|
||||||
|
|
||||||
|
return (
|
||||||
|
/minDataIntegrity\?: number \| null/.test(types) &&
|
||||||
|
/dataIntegrity\?: number \| null/.test(types) &&
|
||||||
|
/dataIntegrityText\?: string \| null/.test(types) &&
|
||||||
|
/prop:\s*'minDataIntegrity'/.test(taskTable) &&
|
||||||
|
/formatChecksquareIntegrity/.test(taskTableUtils) &&
|
||||||
|
/prop="dataIntegrity"/.test(summaryTable) &&
|
||||||
|
/formatDataIntegrity/.test(tableUtils) &&
|
||||||
|
!/maxMissingRate/.test(types) &&
|
||||||
|
!/missingRate/.test(types) &&
|
||||||
|
!/maxContinuousMissingMinutes/.test(types) &&
|
||||||
|
!/maxMissingRate/.test(taskTable) &&
|
||||||
|
!/missingRate/.test(summaryTable) &&
|
||||||
|
!/missingRate/.test(tableUtils) &&
|
||||||
|
!/missingRate/.test(taskTableUtils)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare task status follows documented FAIL value',
|
||||||
|
() => {
|
||||||
|
const source = read(files.taskTableUtils)
|
||||||
|
const types = read(files.apiTypes)
|
||||||
|
|
||||||
|
return (
|
||||||
|
/taskStatus\?: 'RUNNING' \| 'SUCCESS' \| 'FAIL' \| string/.test(types) &&
|
||||||
|
/status === 'FAIL'/.test(source) &&
|
||||||
|
!/FAILED/.test(source)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'detail dialog shows integrity overview and documented point counts',
|
||||||
|
() => {
|
||||||
|
const detailPanel = read(files.detailPanel)
|
||||||
|
|
||||||
|
return (
|
||||||
|
/formatDataIntegrity/.test(detailPanel) &&
|
||||||
|
/selectedItem\.dataIntegrity/.test(detailPanel) &&
|
||||||
|
/selectedItem\.dataIntegrityText/.test(detailPanel) &&
|
||||||
|
/selectedItem\.expectedPointCount/.test(detailPanel) &&
|
||||||
|
/selectedItem\.actualPointCount/.test(detailPanel) &&
|
||||||
|
/selectedItem\.missingPointCount/.test(detailPanel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'detail panel loads item details on demand',
|
||||||
|
() => /getSteadyChecksquareItemDetail/.test(read(files.detailPanel)) && /detailType/.test(read(files.detailPanel))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'item detail api types support documented pagination fields',
|
||||||
|
() => {
|
||||||
|
const types = read(files.apiTypes)
|
||||||
|
return (
|
||||||
|
/interface SteadyChecksquareItemDetailParams[\s\S]*pageNum\?: number[\s\S]*pageSize\?: number/.test(types) &&
|
||||||
|
/interface SteadyChecksquareItemDetail[\s\S]*pageNum\?: number \| null[\s\S]*pageSize\?: number \| null[\s\S]*total\?: number \| null/.test(
|
||||||
|
types
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table displays documented task base fields',
|
||||||
|
() => {
|
||||||
|
const summaryTable = read(files.summaryTable)
|
||||||
|
return /任务编号/.test(summaryTable) && /检测时间/.test(summaryTable) && /result\.taskNo/.test(summaryTable)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'detail panel paginates abnormal item details with documented page size',
|
||||||
|
() => {
|
||||||
|
const detailPanel = read(files.detailPanel)
|
||||||
|
return (
|
||||||
|
/DETAIL_PAGE_SIZE\s*=\s*20/.test(detailPanel) &&
|
||||||
|
/detailPageNum/.test(detailPanel) &&
|
||||||
|
/pageNum:[\s\S]*detailPageNum\.value/.test(detailPanel) &&
|
||||||
|
/pageSize:[\s\S]*DETAIL_PAGE_SIZE/.test(detailPanel) &&
|
||||||
|
/<el-pagination/.test(detailPanel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'detail panel renders documented detail fields',
|
||||||
|
() => {
|
||||||
|
const detailPanel = read(files.detailPanel)
|
||||||
|
return (
|
||||||
|
/prop="status"[\s\S]*状态/.test(detailPanel) &&
|
||||||
|
/prop="startTime" label="开始时间"/.test(detailPanel) &&
|
||||||
|
/prop="endTime" label="结束时间"/.test(detailPanel) &&
|
||||||
|
/oddHarmonicOrders/.test(detailPanel) &&
|
||||||
|
/oddValues/.test(detailPanel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'detail panel table follows task detail table container style',
|
||||||
|
() => {
|
||||||
|
const detailPanel = read(files.detailPanel)
|
||||||
|
return (
|
||||||
|
/<section class="table-main card checksquare-detail">/.test(detailPanel) &&
|
||||||
|
/<el-tabs/.test(detailPanel) &&
|
||||||
|
/<el-tab-pane label="缺数区间" name="SEGMENT">/.test(detailPanel) &&
|
||||||
|
/<el-tab-pane label="值关系异常" name="VALUE_ORDER">/.test(detailPanel) &&
|
||||||
|
/<el-tab-pane label="谐波奇偶异常" name="HARMONIC_PARITY">/.test(detailPanel) &&
|
||||||
|
/class="detail-table"/.test(detailPanel) &&
|
||||||
|
/height="100%"/.test(detailPanel) &&
|
||||||
|
!/max-height="300"/.test(detailPanel) &&
|
||||||
|
!/section-title/.test(detailPanel) &&
|
||||||
|
!/section-description/.test(detailPanel) &&
|
||||||
|
!/Refresh/.test(detailPanel) &&
|
||||||
|
/\.detail-table :deep\(\.el-table__header \.cell\),[\s\S]*\.detail-table :deep\(\.el-table__body \.cell\)[\s\S]*text-align:\s*center/.test(
|
||||||
|
detailPanel
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
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')
|
||||||
325
frontend/src/views/steady/checksquare/index.vue
Normal file
325
frontend/src/views/steady/checksquare/index.vue
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<template>
|
||||||
|
<div class="table-box checksquare-page">
|
||||||
|
<ChecksquareTaskTable
|
||||||
|
ref="taskTableRef"
|
||||||
|
:ledger-tree="ledgerTree"
|
||||||
|
:indicator-tree="indicatorTree"
|
||||||
|
:request-api="querySteadyChecksquareTasks"
|
||||||
|
@create-task="openCreateDialog"
|
||||||
|
@detail="openTaskDetail"
|
||||||
|
@delete="handleDeleteTask"
|
||||||
|
@view-measurement-point="openMeasurementPointDialog"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChecksquareMeasurementPointDialog
|
||||||
|
v-model:visible="measurementPointDialogVisible"
|
||||||
|
:data="measurementPointData"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="createDialogVisible"
|
||||||
|
title="新增校验任务"
|
||||||
|
width="960px"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div class="checksquare-create-dialog">
|
||||||
|
<ChecksquareWorkbench
|
||||||
|
v-model:form="formState"
|
||||||
|
v-model:ledger-panel-collapsed="ledgerPanelCollapsed"
|
||||||
|
:ledger-tree="ledgerTree"
|
||||||
|
:indicator-tree="indicatorTree"
|
||||||
|
: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"
|
||||||
|
@create="handleCreateTask"
|
||||||
|
@reset="handleReset"
|
||||||
|
>
|
||||||
|
<template #result>
|
||||||
|
<ChecksquareCreateResultPanel
|
||||||
|
:task="activeCreateTask"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ChecksquareWorkbench>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="detailDialogVisible" title="校验任务详情" width="1080px" append-to-body destroy-on-close>
|
||||||
|
<div v-loading="loading.detail" class="checksquare-detail-dialog">
|
||||||
|
<ChecksquareSummaryTable
|
||||||
|
:result="taskDetail"
|
||||||
|
:items="taskDetail?.items || []"
|
||||||
|
:loading="loading.detail"
|
||||||
|
@refresh="refreshTaskDetail"
|
||||||
|
@detail="openItemDetail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="itemDetailDialogVisible" title="检测项明细" width="1080px" append-to-body destroy-on-close>
|
||||||
|
<div class="checksquare-detail-dialog">
|
||||||
|
<ChecksquareDetailPanel :selected-item="selectedItem" />
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
createSteadyChecksquareTask,
|
||||||
|
deleteSteadyChecksquareTasks,
|
||||||
|
getSteadyChecksquareDetail,
|
||||||
|
getSteadyTrendIndicatorTree,
|
||||||
|
getSteadyTrendLedgerTree,
|
||||||
|
querySteadyChecksquareTasks
|
||||||
|
} 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 ChecksquareCreateResultPanel from './components/ChecksquareCreateResultPanel.vue'
|
||||||
|
import ChecksquareDetailPanel from './components/ChecksquareDetailPanel.vue'
|
||||||
|
import ChecksquareMeasurementPointDialog from './components/ChecksquareMeasurementPointDialog.vue'
|
||||||
|
import ChecksquareSummaryTable from './components/ChecksquareSummaryTable.vue'
|
||||||
|
import ChecksquareTaskTable from './components/ChecksquareTaskTable.vue'
|
||||||
|
import ChecksquareWorkbench from './components/ChecksquareWorkbench.vue'
|
||||||
|
import {
|
||||||
|
resolveChecksquareMeasurementPointDetail,
|
||||||
|
type ChecksquareMeasurementPointDetail
|
||||||
|
} from './utils/checksquareLedger'
|
||||||
|
import {
|
||||||
|
buildSteadyChecksquareCreatePayload,
|
||||||
|
defaultChecksquareFormState,
|
||||||
|
validateChecksquareSelection
|
||||||
|
} from './utils/checksquarePayload'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareView'
|
||||||
|
})
|
||||||
|
|
||||||
|
const ledgerTree = ref<SteadyDataView.SteadyLedgerNode[]>([])
|
||||||
|
const indicatorTree = ref<SteadyDataView.SteadyIndicatorNode[]>([])
|
||||||
|
const selectedLedgerNodes = ref<SteadyDataView.SteadyLedgerNode[]>([])
|
||||||
|
const selectedIndicators = ref<SteadyDataView.SteadyIndicatorNode[]>([])
|
||||||
|
const taskDetail = ref<SteadyDataView.SteadyChecksquareQueryResult | null>(null)
|
||||||
|
const selectedTask = ref<SteadyDataView.SteadyChecksquareTask | null>(null)
|
||||||
|
const selectedItem = ref<SteadyDataView.SteadyChecksquareItem | null>(null)
|
||||||
|
const activeCreateTask = ref<SteadyDataView.SteadyChecksquareTask | null>(null)
|
||||||
|
const measurementPointData = ref<ChecksquareMeasurementPointDetail | 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 createDialogVisible = ref(false)
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const itemDetailDialogVisible = ref(false)
|
||||||
|
const measurementPointDialogVisible = ref(false)
|
||||||
|
const taskTableRef = ref<InstanceType<typeof ChecksquareTaskTable>>()
|
||||||
|
const loading = reactive({
|
||||||
|
ledger: false,
|
||||||
|
indicator: false,
|
||||||
|
query: false,
|
||||||
|
detail: false
|
||||||
|
})
|
||||||
|
let ledgerSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
|
||||||
|
|
||||||
|
const unwrapData = <T,>(response: { data: T } | T): T => {
|
||||||
|
if (response && typeof response === 'object' && 'data' in response) {
|
||||||
|
return (response as { data: T }).data
|
||||||
|
}
|
||||||
|
|
||||||
|
return response as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
|
||||||
|
loading.ledger = true
|
||||||
|
try {
|
||||||
|
const response = await getSteadyTrendLedgerTree(keyword ? { keyword } : undefined)
|
||||||
|
// 数据校验沿用稳态趋势台账树,搜索返回扁平节点时仍需恢复固定层级。
|
||||||
|
ledgerTree.value = 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 openCreateDialog = () => {
|
||||||
|
activeCreateTask.value = null
|
||||||
|
createDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLedgerSearch = (value: string) => {
|
||||||
|
ledgerKeyword.value = value
|
||||||
|
if (ledgerSearchTimer) clearTimeout(ledgerSearchTimer)
|
||||||
|
ledgerSearchTimer = setTimeout(() => loadLedgerTree(value), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
|
||||||
|
selectedLedgerNodes.value = nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||||
|
selectedIndicators.value = collectLeafIndicators(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
formState.value = defaultChecksquareFormState()
|
||||||
|
selectedLedgerNodes.value = []
|
||||||
|
selectedIndicators.value = []
|
||||||
|
defaultLedgerCheckedKeys.value = []
|
||||||
|
defaultIndicatorCheckedKeys.value = []
|
||||||
|
activeCreateTask.value = null
|
||||||
|
selectorResetKey.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeCreateTask = (result: SteadyDataView.SteadyChecksquareTask): SteadyDataView.SteadyChecksquareTask | null => {
|
||||||
|
const taskId = result.taskId || result.taskNo
|
||||||
|
if (!taskId) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
taskNo: result.taskNo,
|
||||||
|
lineId: result.lineId,
|
||||||
|
lineName: result.lineName,
|
||||||
|
timeStart: result.timeStart,
|
||||||
|
timeEnd: result.timeEnd,
|
||||||
|
intervalMinutes: result.intervalMinutes,
|
||||||
|
taskStatus: result.taskStatus,
|
||||||
|
itemCount: result.itemCount,
|
||||||
|
abnormalItemCount: result.abnormalItemCount,
|
||||||
|
minDataIntegrity: result.minDataIntegrity,
|
||||||
|
createTime: result.createTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateTask = async () => {
|
||||||
|
const selectionError = validateChecksquareSelection({
|
||||||
|
lineIds: lineIds.value,
|
||||||
|
indicators: selectedIndicators.value,
|
||||||
|
timeRange: formState.value.timeRange
|
||||||
|
})
|
||||||
|
if (selectionError) {
|
||||||
|
ElMessage.warning(selectionError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.query = true
|
||||||
|
try {
|
||||||
|
// /create 只返回任务行信息,检测项明细统一通过 /detail 拉取,避免把列表行误当成详情数据。
|
||||||
|
const response = await createSteadyChecksquareTask(
|
||||||
|
buildSteadyChecksquareCreatePayload(lineIds.value[0], selectedIndicators.value, formState.value)
|
||||||
|
)
|
||||||
|
const result = unwrapData(response)
|
||||||
|
activeCreateTask.value = normalizeCreateTask(result)
|
||||||
|
ElMessage.success('校验任务已获取')
|
||||||
|
taskTableRef.value?.refresh()
|
||||||
|
} finally {
|
||||||
|
loading.query = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTaskDetail = async () => {
|
||||||
|
if (!selectedTask.value?.taskId) return
|
||||||
|
|
||||||
|
loading.detail = true
|
||||||
|
try {
|
||||||
|
const response = await getSteadyChecksquareDetail(selectedTask.value.taskId)
|
||||||
|
taskDetail.value = unwrapData(response)
|
||||||
|
} finally {
|
||||||
|
loading.detail = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTaskDetail = async (row: SteadyDataView.SteadyChecksquareTask) => {
|
||||||
|
selectedTask.value = row
|
||||||
|
taskDetail.value = null
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
await refreshTaskDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTask = async (row: SteadyDataView.SteadyChecksquareTask) => {
|
||||||
|
if (!row.taskId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认删除该校验任务吗?删除后历史列表将不再显示该任务。', '删除确认', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除接口按任务 ID 数组批量处理;列表行操作只传当前行任务 ID。
|
||||||
|
await deleteSteadyChecksquareTasks([row.taskId])
|
||||||
|
ElMessage.success('删除校验任务成功')
|
||||||
|
taskTableRef.value?.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openItemDetail = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||||
|
selectedItem.value = item
|
||||||
|
itemDetailDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMeasurementPointDialog = (row: SteadyDataView.SteadyChecksquareTask) => {
|
||||||
|
measurementPointData.value = resolveChecksquareMeasurementPointDetail(ledgerTree.value, row)
|
||||||
|
measurementPointDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadLedgerTree()
|
||||||
|
loadIndicatorTree()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.checksquare-page {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-create-dialog {
|
||||||
|
display: block;
|
||||||
|
height: 560px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-detail-dialog {
|
||||||
|
height: 560px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
|
||||||
|
export interface ChecksquareMeasurementPointDetail {
|
||||||
|
engineeringName?: string
|
||||||
|
projectName?: string
|
||||||
|
equipmentName?: string
|
||||||
|
networkParam?: string
|
||||||
|
lineName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveText = (data: Record<string, unknown>, ...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 collectLedgerPath = (
|
||||||
|
nodes: SteadyDataView.SteadyLedgerNode[],
|
||||||
|
lineId: string,
|
||||||
|
parents: SteadyDataView.SteadyLedgerNode[] = []
|
||||||
|
): SteadyDataView.SteadyLedgerNode[] => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const nextPath = [...parents, node]
|
||||||
|
|
||||||
|
if (node.id === lineId) {
|
||||||
|
return nextPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
const matchedPath = collectLedgerPath(node.children, lineId, nextPath)
|
||||||
|
if (matchedPath.length) return matchedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveChecksquareMeasurementPointDetail = (
|
||||||
|
ledgerTree: SteadyDataView.SteadyLedgerNode[],
|
||||||
|
row: Pick<SteadyDataView.SteadyChecksquareTask, 'lineId' | 'lineName'>
|
||||||
|
): ChecksquareMeasurementPointDetail => {
|
||||||
|
const lineId = row.lineId || ''
|
||||||
|
const ledgerPath = lineId ? collectLedgerPath(ledgerTree, lineId) : []
|
||||||
|
const engineeringNode = ledgerPath.find(item => item.level === 0)
|
||||||
|
const projectNode = ledgerPath.find(item => item.level === 1)
|
||||||
|
const equipmentNode = ledgerPath.find(item => item.level === 2)
|
||||||
|
const lineNode = ledgerPath.find(item => item.level === 3)
|
||||||
|
const rawEquipmentNode = (equipmentNode || {}) as SteadyDataView.SteadyLedgerNode & Record<string, unknown>
|
||||||
|
const rawLineNode = (lineNode || {}) as SteadyDataView.SteadyLedgerNode & Record<string, unknown>
|
||||||
|
|
||||||
|
// 数据校验任务只返回监测点 ID/名称,弹窗所需层级信息从已加载的台账树回溯补齐。
|
||||||
|
return {
|
||||||
|
engineeringName: engineeringNode?.name,
|
||||||
|
projectName: projectNode?.name,
|
||||||
|
equipmentName: equipmentNode?.name,
|
||||||
|
networkParam: resolveText(rawEquipmentNode, 'mac', 'ndid', 'unnid') || resolveText(rawLineNode, 'mac', 'ndid', 'unnid'),
|
||||||
|
lineName: lineNode?.name || row.lineName || row.lineId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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 buildSteadyChecksquareCreatePayload = (
|
||||||
|
lineId: string,
|
||||||
|
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||||
|
formState: ChecksquareFormState
|
||||||
|
): SteadyDataView.SteadyChecksquareCreateParams => {
|
||||||
|
return {
|
||||||
|
lineId,
|
||||||
|
indicatorCodes: collectChecksquareIndicatorCodes(indicators),
|
||||||
|
timeStart: (formState.timeRange[0] || '').replace(/\.[^.]+$/, ''),
|
||||||
|
timeEnd: (formState.timeRange[1] || '').replace(/\.[^.]+$/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ''
|
||||||
|
}
|
||||||
318
frontend/src/views/steady/checksquare/utils/checksquareTable.ts
Normal file
318
frontend/src/views/steady/checksquare/utils/checksquareTable.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
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 formatDataIntegrity = (value?: number | null, text?: string | null) => {
|
||||||
|
if (text) return text
|
||||||
|
const integrityValue = value === null || value === undefined || !Number.isFinite(Number(value)) ? null : Number(value)
|
||||||
|
|
||||||
|
if (integrityValue === null) return '-'
|
||||||
|
|
||||||
|
return `${(integrityValue * 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 formatDataIntegrity(summary.dataIntegrity, summary.dataIntegrityText)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
Boolean(item.itemId) ||
|
||||||
|
Boolean(item.abnormalPointCount) ||
|
||||||
|
Boolean(item.harmonicParityAbnormalPointCount) ||
|
||||||
|
Boolean(item.missingPointCount) ||
|
||||||
|
(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)
|
||||||
|
|
||||||
|
return {
|
||||||
|
statType,
|
||||||
|
supported: supportedSummaries.length > 0,
|
||||||
|
hasData: supportedSummaries.length > 0 && supportedSummaries.every(summary => summary.hasData === true),
|
||||||
|
expectedPointCount,
|
||||||
|
actualPointCount,
|
||||||
|
missingPointCount,
|
||||||
|
dataIntegrity: expectedPointCount ? actualPointCount / expectedPointCount : null,
|
||||||
|
dataIntegrityText: expectedPointCount ? undefined : '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 statSummaries = CHECKSQUARE_STAT_TYPES.map(statType => summarizeStatType(children, statType))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parentItem,
|
||||||
|
hasData: children.every(item => item.hasData === true),
|
||||||
|
expectedPointCount,
|
||||||
|
actualPointCount,
|
||||||
|
missingPointCount,
|
||||||
|
dataIntegrity: expectedPointCount ? actualPointCount / expectedPointCount : null,
|
||||||
|
dataIntegrityText: expectedPointCount ? undefined : '-',
|
||||||
|
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,48 @@
|
|||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
|
||||||
|
export interface ChecksquareTaskSearchParams extends SteadyDataView.SteadyChecksquareTaskQueryParams {
|
||||||
|
taskTimeRange?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildChecksquareTaskQueryParams = (
|
||||||
|
params: ChecksquareTaskSearchParams
|
||||||
|
): SteadyDataView.SteadyChecksquareTaskQueryParams => {
|
||||||
|
const { taskTimeRange, ...rest } = params
|
||||||
|
const queryParams: SteadyDataView.SteadyChecksquareTaskQueryParams = { ...rest }
|
||||||
|
|
||||||
|
if (taskTimeRange?.[0]) queryParams.timeStart = taskTimeRange[0]
|
||||||
|
if (taskTimeRange?.[1]) queryParams.timeEnd = taskTimeRange[1]
|
||||||
|
|
||||||
|
return queryParams
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatChecksquareTaskStatus = (status?: string) => {
|
||||||
|
if (!status) return '--'
|
||||||
|
if (status === 'SUCCESS') return '成功'
|
||||||
|
if (status === 'FAIL') return '失败'
|
||||||
|
if (status === 'RUNNING') return '执行中'
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveChecksquareTaskStatusType = (status?: string) => {
|
||||||
|
if (status === 'SUCCESS') return 'success'
|
||||||
|
if (status === 'FAIL') return 'danger'
|
||||||
|
if (status === 'RUNNING') return 'warning'
|
||||||
|
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatChecksquareIntegrity = (value?: number | null) => {
|
||||||
|
const integrityValue = value === null || value === undefined || !Number.isFinite(Number(value)) ? null : Number(value)
|
||||||
|
|
||||||
|
if (integrityValue === null) return '--'
|
||||||
|
|
||||||
|
return `${(integrityValue * 100).toFixed(2)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveChecksquareText = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '--'
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
@@ -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">
|
<section class="card steady-tree-card indicator-tree">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">稳态指标</span>
|
<span class="panel-title">稳态指标</span>
|
||||||
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-scrollbar class="tree-scrollbar">
|
<el-scrollbar class="tree-scrollbar">
|
||||||
@@ -13,6 +12,7 @@
|
|||||||
node-key="treeKey"
|
node-key="treeKey"
|
||||||
show-checkbox
|
show-checkbox
|
||||||
default-expand-all
|
default-expand-all
|
||||||
|
:default-checked-keys="defaultCheckedKeys"
|
||||||
:expand-on-click-node="false"
|
:expand-on-click-node="false"
|
||||||
:props="{ label: 'name', children: 'children' }"
|
:props="{ label: 'name', children: 'children' }"
|
||||||
@check="handleCheck"
|
@check="handleCheck"
|
||||||
@@ -29,8 +29,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
|
||||||
import type { TreeInstance } from 'element-plus'
|
import type { TreeInstance } from 'element-plus'
|
||||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
import { collectLeafIndicators } from '../utils/selectionRules'
|
import { collectLeafIndicators } from '../utils/selectionRules'
|
||||||
@@ -41,11 +40,10 @@ defineOptions({
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
treeData: SteadyDataView.SteadyIndicatorNode[]
|
treeData: SteadyDataView.SteadyIndicatorNode[]
|
||||||
loading: boolean
|
defaultCheckedKeys: string[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: []
|
|
||||||
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -67,9 +65,22 @@ const normalizedTreeData = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleCheck = () => {
|
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))
|
emit('change', collectLeafIndicators(checkedNodes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyDefaultCheckedKeys = async () => {
|
||||||
|
await nextTick()
|
||||||
|
treeRef.value?.setCheckedKeys(props.defaultCheckedKeys, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [normalizedTreeData.value, props.defaultCheckedKeys],
|
||||||
|
() => {
|
||||||
|
applyDefaultCheckedKeys()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -81,7 +92,6 @@ const handleCheck = () => {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header,
|
|
||||||
.tree-node {
|
.tree-node {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -89,6 +99,13 @@ const handleCheck = () => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,47 +1,67 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="card steady-tree-card">
|
<section class="card steady-tree-card" :class="{ 'is-collapsed': collapsed }">
|
||||||
<div class="panel-header">
|
<div v-show="collapsed" class="collapsed-panel">
|
||||||
<span class="panel-title">台账监测点</span>
|
<el-tooltip content="展开设备树" placement="right">
|
||||||
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
<el-button class="panel-toggle" type="primary" :icon="ArrowRight" circle @click="emit('toggle')" />
|
||||||
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-input
|
<div v-show="!collapsed" class="expanded-panel">
|
||||||
:model-value="keyword"
|
<div class="panel-header">
|
||||||
clearable
|
<span class="panel-title">设备树</span>
|
||||||
placeholder="搜索工程、项目、设备、监测点"
|
<el-tooltip content="收缩设备树" placement="top">
|
||||||
@update:model-value="handleKeywordChange"
|
<el-button class="panel-toggle" type="primary" :icon="ArrowLeft" circle @click="emit('toggle')" />
|
||||||
/>
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-scrollbar class="tree-scrollbar">
|
<div class="tree-search-row">
|
||||||
<el-tree
|
<el-input
|
||||||
ref="treeRef"
|
:model-value="keyword"
|
||||||
class="ledger-tree"
|
clearable
|
||||||
:data="treeData"
|
placeholder="搜索工程、项目、设备、监测点"
|
||||||
node-key="id"
|
@update:model-value="handleKeywordChange"
|
||||||
show-checkbox
|
></el-input>
|
||||||
default-expand-all
|
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
||||||
:expand-on-click-node="false"
|
</div>
|
||||||
:props="{ label: 'name', children: 'children' }"
|
|
||||||
@check="handleCheck"
|
<el-scrollbar class="tree-scrollbar">
|
||||||
>
|
<el-tree
|
||||||
<template #default="{ data }">
|
ref="treeRef"
|
||||||
<div class="tree-node">
|
class="ledger-tree"
|
||||||
<span class="node-name">{{ data.name }}</span>
|
:data="treeData"
|
||||||
<span class="node-count">
|
node-key="id"
|
||||||
<template v-if="Number(data.deviceCount) || Number(data.lineCount)">
|
show-checkbox
|
||||||
{{ Number(data.deviceCount || 0) }} / {{ Number(data.lineCount || 0) }}
|
default-expand-all
|
||||||
</template>
|
:default-checked-keys="defaultCheckedKeys"
|
||||||
</span>
|
:expand-on-click-node="false"
|
||||||
</div>
|
:props="{ label: 'name', children: 'children' }"
|
||||||
</template>
|
@check="handleCheck"
|
||||||
</el-tree>
|
>
|
||||||
</el-scrollbar>
|
<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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { nextTick, ref, watch } from 'vue'
|
||||||
import { Refresh } from '@element-plus/icons-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 { TreeInstance } from 'element-plus'
|
||||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
|
||||||
@@ -49,27 +69,75 @@ defineOptions({
|
|||||||
name: 'SteadyLedgerTree'
|
name: 'SteadyLedgerTree'
|
||||||
})
|
})
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
treeData: SteadyDataView.SteadyLedgerNode[]
|
treeData: SteadyDataView.SteadyLedgerNode[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
keyword: string
|
keyword: string
|
||||||
|
defaultCheckedKeys: string[]
|
||||||
|
collapsed: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: []
|
refresh: []
|
||||||
search: [value: string]
|
search: [value: string]
|
||||||
change: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
change: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
||||||
|
toggle: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const treeRef = ref<TreeInstance>()
|
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) => {
|
const handleKeywordChange = (value: string) => {
|
||||||
emit('search', value)
|
emit('search', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheck = () => {
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -81,6 +149,46 @@ const handleCheck = () => {
|
|||||||
padding: 12px;
|
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,
|
.panel-header,
|
||||||
.tree-node {
|
.tree-node {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -95,6 +203,21 @@ const handleCheck = () => {
|
|||||||
color: var(--el-text-color-primary);
|
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 {
|
.tree-scrollbar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -105,6 +228,35 @@ const handleCheck = () => {
|
|||||||
min-width: 0;
|
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 {
|
.node-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -1,26 +1,78 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="card trend-chart-panel" v-loading="loading">
|
<section class="card trend-chart-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">趋势图</span>
|
<SteadyTrendChartTools
|
||||||
<span class="panel-meta">
|
:tool-groups="trendToolGroups"
|
||||||
<template v-if="trendResult">
|
:is-tool-active="isTrendToolActive"
|
||||||
{{ trendResult.bucket || '-' }} / {{ trendResult.displayPointCount || 0 }} 点
|
:is-tool-disabled="isTrendToolDisabled"
|
||||||
</template>
|
:get-tool-tooltip="getTrendToolTooltip"
|
||||||
</span>
|
@tool-action="handleTrendToolAction"
|
||||||
|
/>
|
||||||
|
<span v-if="trendResult" class="panel-meta">总点数:{{ trendResult.displayPointCount || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasSeries" class="chart-body">
|
<div class="chart-panel-body" v-loading="loading">
|
||||||
<LineChart :options="chartOptions" />
|
<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>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 LineChart from '@/components/echarts/line/index.vue'
|
||||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
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({
|
defineOptions({
|
||||||
name: 'SteadyTrendChartPanel'
|
name: 'SteadyTrendChartPanel'
|
||||||
@@ -31,16 +83,305 @@ const props = defineProps<{
|
|||||||
loading: boolean
|
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 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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.trend-chart-panel {
|
.trend-chart-panel {
|
||||||
|
--steady-trend-chart-gap: 8px;
|
||||||
|
--steady-trend-visible-chart-count: 3;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,24 +389,55 @@ const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResu
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 10px;
|
gap: 0;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-meta {
|
.panel-meta {
|
||||||
|
margin-left: 15px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
font-size: 12px;
|
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 {
|
.chart-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding: 0 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-empty {
|
.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>
|
<template>
|
||||||
<section class="card trend-toolbar">
|
<section class="card trend-toolbar">
|
||||||
<TimePeriodSearch
|
<div class="toolbar-field toolbar-field--time">
|
||||||
class="trend-toolbar__time"
|
<span class="toolbar-field__label">时间:</span>
|
||||||
:unit="modelValue.timeUnit"
|
<TimePeriodSearch
|
||||||
:model-value="modelValue.timeBaseDate"
|
class="trend-toolbar__time"
|
||||||
@update:unit="handleTimeUnitChange"
|
:unit="modelValue.timeUnit"
|
||||||
@update:model-value="handleTimeBaseDateChange"
|
:model-value="modelValue.timeBaseDate"
|
||||||
/>
|
@update:unit="handleTimeUnitChange"
|
||||||
|
@update:model-value="handleTimeBaseDateChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-select
|
<div class="toolbar-field">
|
||||||
:model-value="modelValue.phases"
|
<span class="toolbar-field__label">统计:</span>
|
||||||
multiple
|
<el-select
|
||||||
collapse-tags
|
:model-value="modelValue.statType"
|
||||||
collapse-tags-tooltip
|
placeholder="选择统计类型"
|
||||||
placeholder="选择相别"
|
@update:model-value="updateField('statType', $event)"
|
||||||
@update:model-value="updateField('phases', $event)"
|
>
|
||||||
>
|
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
|
||||||
<el-option v-for="item in phaseOptions" :key="item" :label="resolvePhaseLabel(item)" :value="item" />
|
</el-select>
|
||||||
</el-select>
|
</div>
|
||||||
|
|
||||||
<el-select
|
<div class="toolbar-field">
|
||||||
:model-value="modelValue.statTypes"
|
<span class="toolbar-field__label">数据质量:</span>
|
||||||
multiple
|
<el-switch
|
||||||
collapse-tags
|
:model-value="modelValue.qualityFlag ?? 0"
|
||||||
collapse-tags-tooltip
|
class="quality-switch"
|
||||||
placeholder="选择统计类型"
|
width="72"
|
||||||
@update:model-value="updateField('statTypes', $event)"
|
inline-prompt
|
||||||
>
|
active-text="有效"
|
||||||
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
|
inactive-text="无效"
|
||||||
</el-select>
|
:active-value="0"
|
||||||
|
:inactive-value="1"
|
||||||
|
@update:model-value="handleQualityFlagChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-select
|
<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">
|
||||||
:model-value="modelValue.bucket"
|
<span class="toolbar-field__label">谐波次数:</span>
|
||||||
placeholder="选择时间粒度"
|
<el-select
|
||||||
@update:model-value="updateField('bucket', $event)"
|
:model-value="modelValue.harmonicOrders"
|
||||||
>
|
multiple
|
||||||
<el-option v-for="item in bucketOptions" :key="item.value" :label="item.label" :value="item.value" />
|
placeholder="选择谐波次数"
|
||||||
</el-select>
|
@update:model-value="handleHarmonicOrdersChange"
|
||||||
|
>
|
||||||
<el-select
|
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
|
||||||
:model-value="modelValue.qualityFlag"
|
</el-select>
|
||||||
clearable
|
</div>
|
||||||
placeholder="选择数据质量"
|
|
||||||
@update:model-value="updateField('qualityFlag', $event)"
|
|
||||||
>
|
|
||||||
<el-option label="仅有效数据" :value="1" />
|
|
||||||
<el-option label="仅无效数据" :value="0" />
|
|
||||||
</el-select>
|
|
||||||
|
|
||||||
<el-select
|
|
||||||
v-if="showHarmonicOrders"
|
|
||||||
:model-value="modelValue.harmonicOrders"
|
|
||||||
class="harmonic-select"
|
|
||||||
multiple
|
|
||||||
collapse-tags
|
|
||||||
collapse-tags-tooltip
|
|
||||||
placeholder="选择谐波次数"
|
|
||||||
@update:model-value="updateField('harmonicOrders', $event)"
|
|
||||||
>
|
|
||||||
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
|
|
||||||
</el-select>
|
|
||||||
|
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-button type="primary" :loading="loading" @click="emit('query')">查询</el-button>
|
<el-button type="primary" :loading="loading" @click="emit('query')">查询</el-button>
|
||||||
@@ -69,9 +57,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
||||||
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||||
|
import { MAX_HARMONIC_ORDER_COUNT } from '../utils/selectionRules'
|
||||||
import type { SteadyTrendFormState } from '../utils/trendPayload'
|
import type { SteadyTrendFormState } from '../utils/trendPayload'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -80,7 +70,6 @@ defineOptions({
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: SteadyTrendFormState
|
modelValue: SteadyTrendFormState
|
||||||
phaseOptions: string[]
|
|
||||||
statOptions: SteadyDataView.SteadyTrendStatType[]
|
statOptions: SteadyDataView.SteadyTrendStatType[]
|
||||||
showHarmonicOrders: boolean
|
showHarmonicOrders: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -92,13 +81,6 @@ const emit = defineEmits<{
|
|||||||
reset: []
|
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 harmonicOrderOptions = Array.from({ length: 49 }, (_item, index) => index + 2)
|
||||||
const statLabelMap: Record<SteadyDataView.SteadyTrendStatType, string> = {
|
const statLabelMap: Record<SteadyDataView.SteadyTrendStatType, string> = {
|
||||||
AVG: '平均值',
|
AVG: '平均值',
|
||||||
@@ -106,14 +88,6 @@ const statLabelMap: Record<SteadyDataView.SteadyTrendStatType, string> = {
|
|||||||
MIN: '最小值',
|
MIN: '最小值',
|
||||||
CP95: '95%概率大值'
|
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]) => {
|
const updateField = <K extends keyof SteadyTrendFormState>(field: K, value: SteadyTrendFormState[K]) => {
|
||||||
emit('update:modelValue', {
|
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) => {
|
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
|
||||||
emit('update:modelValue', {
|
emit('update:modelValue', {
|
||||||
...props.modelValue,
|
...props.modelValue,
|
||||||
@@ -143,22 +151,61 @@ const handleTimeBaseDateChange = (value: Date) => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.trend-toolbar {
|
.trend-toolbar {
|
||||||
display: grid;
|
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;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px;
|
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 {
|
.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 {
|
.harmonic-select {
|
||||||
grid-column: span 2;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-actions {
|
.toolbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
grid-column: 5;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
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')
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user