refactor(event): 重构事件列表和稳态数据视图组件结构
- 将事件列表页面逻辑拆分为 EventListTable 组件 - 新增 MeasurementPointDialog 和 VoltageToleranceDialog 弹窗组件 - 重构稳态数据视图为主工作台组件 SteadyTrendWorkbench - 移除不再使用的相别参数和相关逻辑 - 更新事件详情工具函数和接口参数映射 - 优化波形查看功能的数据传递方式 - 修正事件描述字段命名和严重程度解析逻辑
This commit is contained in:
@@ -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 检查。
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
@@ -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,10 +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) => {
|
export const exportTransientWaveforms = (params: EventList.TransientWaveformExportParams) => {
|
||||||
return http.downloadWithHeaders('/event/list/transient/waveform/export', params)
|
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[]
|
||||||
@@ -38,19 +40,15 @@ export namespace EventList {
|
|||||||
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
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ 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
|
bucket?: string
|
||||||
|
|||||||
@@ -1,42 +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/],
|
|
||||||
[
|
|
||||||
'event list requests preserve explicit time range and fallback to current visible range',
|
|
||||||
/const resolveCurrentSearchParams[\s\S]*startTimeRange:\s*params\.startTimeRange\s*\?\?\s*buildTimePeriodRange\(eventTimeUnit\.value,\s*eventTimeBaseDate\.value\)[\s\S]*getTransientEventPage\(buildEventQueryParams\(resolveCurrentSearchParams\(params\)\)\)/
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'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\)\)/
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
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,61 +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 = [
|
|
||||||
[
|
|
||||||
'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\)/
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'table keeps requested visible event column order',
|
|
||||||
/label:\s*'发生时刻'[\s\S]*label:\s*'监测点名称'[\s\S]*label:\s*'暂降\/暂升幅值\(%\)'[\s\S]*label:\s*'持续时间\(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*'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]*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/],
|
|
||||||
['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]*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' }
|
||||||
@@ -4,29 +4,39 @@ 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 pageFile = path.join(currentDir, 'index.vue')
|
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||||
const apiFile = path.resolve(currentDir, '../../../api/event/eventList/index.ts')
|
const componentDir = path.join(currentDir, '..', 'components')
|
||||||
const interfaceFile = path.resolve(currentDir, '../../../api/event/eventList/interface/index.ts')
|
const apiFile = path.resolve(currentDir, '../../../../api/event/eventList/index.ts')
|
||||||
const source = fs.readFileSync(pageFile, 'utf8')
|
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 apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||||
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||||
const eventExportButtonBlocks = (source.match(/<el-button[\s\S]*?<\/el-button>/g) || []).filter(
|
const eventExportButtonBlocks = (source.match(/<el-button[\s\S]*?<\/el-button>/g) || []).filter(
|
||||||
block => /@click="handleEventExport"/.test(block) && />事件导出<\/el-button>/.test(block)
|
block => /@click="handleEventExport"/.test(block)
|
||||||
)
|
)
|
||||||
|
|
||||||
const expectations = [
|
const expectations = [
|
||||||
['waveform export api is imported by the page', /import \{[\s\S]*exportTransientWaveforms[\s\S]*\} from '@\/api\/event\/eventList'/],
|
['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>/],
|
['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 named explicitly', /<el-button[\s\S]*@click="handleEventExport"[\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))],
|
['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 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 payload uses event ids only', /eventIds:\s*rows\.map\(row => row\.eventId\)/],
|
||||||
['waveform export uses server filename download hook', /useDownloadWithServerFileName\(exportTransientWaveforms,\s*'事件波形导出'/],
|
['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]*\}/],
|
['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]*return getTransientEventPage/],
|
['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]*syncEventTimeRange\(\)/],
|
['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 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 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)]
|
['waveform export params type contains eventIds', /export interface TransientWaveformExportParams\s*\{[\s\S]*eventIds:\s*string\[\][\s\S]*\}/.test(interfaceSource)]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -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,347 +1,70 @@
|
|||||||
<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="handleEventExport">事件导出</el-button>
|
@view-waveform="handleViewWaveform"
|
||||||
<el-button
|
@supplement-waveform="handleSupplementWaveform"
|
||||||
type="primary"
|
/>
|
||||||
plain
|
|
||||||
:icon="Download"
|
|
||||||
:disabled="!selectedWaveformRows.length"
|
|
||||||
@click="handleWaveformExport"
|
|
||||||
>波形导出</el-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
波形补招
|
|
||||||
</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, ElCheckbox, ElRadioButton, ElRadioGroup } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Download, RefreshRight, View } from '@element-plus/icons-vue'
|
|
||||||
import ProTable from '@/components/ProTable/index.vue'
|
|
||||||
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
|
||||||
import {
|
import {
|
||||||
exportTransientEvents,
|
exportTransientEvents,
|
||||||
exportTransientWaveforms,
|
exportTransientWaveforms,
|
||||||
getTransientEventDetail,
|
getTransientEventDetail,
|
||||||
getTransientEventPage
|
getTransientEventPage,
|
||||||
|
getTransientEventWave
|
||||||
} from '@/api/event/eventList'
|
} 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 selectedWaveformRows = ref<EventList.TransientEventRecord[]>([])
|
const voltageToleranceDialogVisible = ref(false)
|
||||||
const eventTimeUnit = ref<TimePeriodUnit>('month')
|
const voltageToleranceEventData = ref<EventList.TransientEventRecord | null>(null)
|
||||||
const eventTimeBaseDate = ref(new Date())
|
|
||||||
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()
|
|
||||||
clearWaveformSelection()
|
|
||||||
syncEventTimeRange()
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveCurrentSearchParams = (params: EventSearchParams = {}) => ({
|
|
||||||
...params,
|
|
||||||
startTimeRange: 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: '' }, () => '全部'),
|
|
||||||
...fileFlagOptions.map(option =>
|
|
||||||
h(ElRadioButton, { key: option.value, label: option.value }, () => option.label)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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: '波形选择',
|
|
||||||
fixed: 'left',
|
|
||||||
width: 90,
|
|
||||||
isSetting: false,
|
|
||||||
headerRender: renderWaveformSelectionHeader,
|
|
||||||
render: renderWaveformSelectionCell
|
|
||||||
},
|
|
||||||
{ 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: 'featureAmplitude', label: '暂降/暂升幅值(%)', minWidth: 160 },
|
|
||||||
{ prop: 'duration', label: '持续时间(s)', minWidth: 130 },
|
|
||||||
{
|
|
||||||
prop: 'eventTypeName',
|
|
||||||
label: '事件类型',
|
|
||||||
minWidth: 160,
|
|
||||||
enum: eventTypeOptions,
|
|
||||||
fieldNames: { label: 'name', value: 'code' },
|
|
||||||
isFilterEnum: false,
|
|
||||||
render: ({ row }) => resolveEventTypeName(row, eventTypeOptions.value),
|
|
||||||
search: {
|
|
||||||
key: 'eventType',
|
|
||||||
el: 'select'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'phase',
|
|
||||||
label: '相别',
|
|
||||||
minWidth: 90,
|
|
||||||
enum: phaseOptions
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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) => {
|
||||||
clearWaveformSelection()
|
|
||||||
// 分页查询按 API_DEBUG.md 转换时间范围与枚举筛选参数。
|
// 分页查询按 API_DEBUG.md 转换时间范围与枚举筛选参数。
|
||||||
return getTransientEventPage(buildEventQueryParams(resolveCurrentSearchParams(params)))
|
return getTransientEventPage(buildEventQueryParams(params))
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,41 +80,47 @@ 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 handleViewVoltageTolerance = (row: EventList.TransientEventRecord) => {
|
||||||
|
voltageToleranceEventData.value = row
|
||||||
|
voltageToleranceDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const handleSupplementWaveform = () => {
|
const handleSupplementWaveform = () => {
|
||||||
// 波形补招需要后端补招接口,当前先保留操作入口避免误触发未知流程。
|
// 波形补招需要后端补招接口,当前保留操作入口避免误触发未知流程。
|
||||||
ElMessage.warning('暂无波形补招接口,无法发起补招')
|
ElMessage.warning('\u6682\u65e0\u6ce2\u5f62\u8865\u62db\u63a5\u53e3\uff0c\u65e0\u6cd5\u53d1\u8d77\u8865\u62db')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEventExport = () => {
|
const handleEventExport = (params: EventSearchParams) => {
|
||||||
const searchParam = (proTable.value?.searchParam || {}) as EventSearchParams
|
useDownloadWithServerFileName(exportTransientEvents, '\u6682\u6001\u4e8b\u4ef6\u5217\u8868', buildEventQueryParams(params), false)
|
||||||
useDownloadWithServerFileName(exportTransientEvents, '暂态事件列表', buildEventQueryParams(resolveCurrentSearchParams(searchParam)), false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWaveformExport = () => {
|
const handleWaveformExport = (rows: EventList.TransientEventRecord[]) => {
|
||||||
const exportableRows = selectedWaveformRows.value.filter(isWaveformExportable)
|
if (!rows.length) {
|
||||||
|
ElMessage.warning('\u8bf7\u5148\u9009\u62e9\u5b58\u5728\u6ce2\u5f62\u7684\u4e8b\u4ef6')
|
||||||
if (!exportableRows.length) {
|
|
||||||
ElMessage.warning('请先选择存在波形的事件')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
useDownloadWithServerFileName(exportTransientWaveforms, '事件波形导出', {
|
useDownloadWithServerFileName(exportTransientWaveforms, '\u4e8b\u4ef6\u6ce2\u5f62\u5bfc\u51fa', {
|
||||||
eventIds: exportableRows.map(row => row.eventId)
|
eventIds: rows.map(row => row.eventId)
|
||||||
}, false, '.zip')
|
}, false, '.zip')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -406,8 +135,4 @@ const handleWaveformExport = () => {
|
|||||||
.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,115 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="indicator-floating-panel" :class="{ 'is-collapsed': collapsed }">
|
||||||
|
<el-button
|
||||||
|
class="indicator-toggle"
|
||||||
|
:icon="collapsed ? ArrowLeft : ArrowRight"
|
||||||
|
circle
|
||||||
|
@click="emit('update:collapsed', !collapsed)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="collapsed"
|
||||||
|
class="indicator-collapsed-trigger"
|
||||||
|
type="button"
|
||||||
|
@click="emit('update:collapsed', false)"
|
||||||
|
>
|
||||||
|
{{ collapsedLabel }}
|
||||||
|
</button>
|
||||||
|
<div v-show="!collapsed" class="indicator-panel-body">
|
||||||
|
<SteadyIndicatorTree
|
||||||
|
:key="selectorResetKey"
|
||||||
|
:tree-data="treeData"
|
||||||
|
:loading="loading"
|
||||||
|
@refresh="emit('refresh')"
|
||||||
|
@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'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
collapsed: boolean
|
||||||
|
treeData: SteadyDataView.SteadyIndicatorNode[]
|
||||||
|
loading: boolean
|
||||||
|
selectorResetKey: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:collapsed': [value: boolean]
|
||||||
|
refresh: []
|
||||||
|
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const collapsedLabel = '\u7a33\u6001\u6307\u6807'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.indicator-floating-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 360px;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-floating-panel.is-collapsed {
|
||||||
|
width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: -18px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-panel-body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-panel-body :deep(.steady-tree-card) {
|
||||||
|
height: 100%;
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-collapsed-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px 0;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-collapsed-trigger:hover {
|
||||||
|
border-color: var(--el-color-primary-light-5);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.indicator-floating-panel {
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -67,7 +67,7 @@ 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))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const handleKeywordChange = (value: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCheck = () => {
|
const handleCheck = () => {
|
||||||
emit('change', (treeRef.value?.getCheckedNodes(false, true) || []) as SteadyDataView.SteadyLedgerNode[])
|
emit('change', (treeRef.value?.getCheckedNodes(false, false) || []) as SteadyDataView.SteadyLedgerNode[])
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,67 @@
|
|||||||
<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.statTypes"
|
||||||
collapse-tags-tooltip
|
multiple
|
||||||
placeholder="选择相别"
|
collapse-tags
|
||||||
@update:model-value="updateField('phases', $event)"
|
collapse-tags-tooltip
|
||||||
>
|
placeholder="选择统计类型"
|
||||||
<el-option v-for="item in phaseOptions" :key="item" :label="resolvePhaseLabel(item)" :value="item" />
|
@update:model-value="updateField('statTypes', $event)"
|
||||||
</el-select>
|
>
|
||||||
|
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-select
|
<div class="toolbar-field">
|
||||||
:model-value="modelValue.statTypes"
|
<span class="toolbar-field__label">粒度:</span>
|
||||||
multiple
|
<el-select
|
||||||
collapse-tags
|
:model-value="modelValue.bucket"
|
||||||
collapse-tags-tooltip
|
placeholder="选择时间粒度"
|
||||||
placeholder="选择统计类型"
|
@update:model-value="updateField('bucket', $event)"
|
||||||
@update:model-value="updateField('statTypes', $event)"
|
>
|
||||||
>
|
<el-option v-for="item in bucketOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
|
</el-select>
|
||||||
</el-select>
|
</div>
|
||||||
|
|
||||||
<el-select
|
<div class="toolbar-field">
|
||||||
:model-value="modelValue.bucket"
|
<span class="toolbar-field__label">数据:</span>
|
||||||
placeholder="选择时间粒度"
|
<el-select
|
||||||
@update:model-value="updateField('bucket', $event)"
|
:model-value="modelValue.qualityFlag"
|
||||||
>
|
clearable
|
||||||
<el-option v-for="item in bucketOptions" :key="item.value" :label="item.label" :value="item.value" />
|
placeholder="选择数据质量"
|
||||||
</el-select>
|
@update:model-value="updateField('qualityFlag', $event)"
|
||||||
|
>
|
||||||
|
<el-option label="仅有效数据" :value="1" />
|
||||||
|
<el-option label="仅无效数据" :value="0" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-select
|
<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">
|
||||||
:model-value="modelValue.qualityFlag"
|
<span class="toolbar-field__label">谐波次数:</span>
|
||||||
clearable
|
<el-select
|
||||||
placeholder="选择数据质量"
|
:model-value="modelValue.harmonicOrders"
|
||||||
@update:model-value="updateField('qualityFlag', $event)"
|
multiple
|
||||||
>
|
collapse-tags
|
||||||
<el-option label="仅有效数据" :value="1" />
|
collapse-tags-tooltip
|
||||||
<el-option label="仅无效数据" :value="0" />
|
placeholder="选择谐波次数"
|
||||||
</el-select>
|
@update:model-value="updateField('harmonicOrders', $event)"
|
||||||
|
>
|
||||||
<el-select
|
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
|
||||||
v-if="showHarmonicOrders"
|
</el-select>
|
||||||
:model-value="modelValue.harmonicOrders"
|
</div>
|
||||||
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>
|
||||||
@@ -80,7 +82,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
|
||||||
@@ -106,14 +107,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', {
|
||||||
@@ -143,13 +136,37 @@ 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(312px, 1.4fr) repeat(3, minmax(178px, 0.8fr)) 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: 312px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.trend-toolbar__time {
|
.trend-toolbar__time {
|
||||||
|
flex: 1 1 0;
|
||||||
min-width: 260px;
|
min-width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div class="steady-trend-layout">
|
||||||
|
<aside class="selector-column">
|
||||||
|
<SteadyLedgerTree
|
||||||
|
:key="selectorResetKey"
|
||||||
|
:tree-data="ledgerTree"
|
||||||
|
:loading="loading.ledger"
|
||||||
|
:keyword="ledgerKeyword"
|
||||||
|
@refresh="emit('refreshLedger')"
|
||||||
|
@search="emit('ledgerSearch', $event)"
|
||||||
|
@change="emit('ledgerChange', $event)"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
:loading="loading.indicator"
|
||||||
|
@refresh="emit('refreshIndicator')"
|
||||||
|
@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
|
||||||
|
indicatorPanelCollapsed: boolean
|
||||||
|
selectorResetKey: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:trendForm': [value: SteadyTrendFormState]
|
||||||
|
'update:indicatorPanelCollapsed': [value: boolean]
|
||||||
|
refreshLedger: []
|
||||||
|
ledgerSearch: [value: string]
|
||||||
|
ledgerChange: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
||||||
|
refreshIndicator: []
|
||||||
|
indicatorChange: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||||
|
queryTrend: []
|
||||||
|
resetTrend: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const trendFormProxy = computed({
|
||||||
|
get: () => props.trendForm,
|
||||||
|
set: value => emit('update:trendForm', 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-column {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-content {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-content :deep(.trend-chart-panel) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.steady-trend-layout {
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,7 +4,7 @@ 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 srcRoot = path.resolve(currentDir, '../../..')
|
const srcRoot = path.resolve(currentDir, '../../../..')
|
||||||
const staticRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/staticRouter.ts'), 'utf8')
|
const staticRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/staticRouter.ts'), 'utf8')
|
||||||
const dynamicRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/dynamicRouter.ts'), 'utf8')
|
const dynamicRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/dynamicRouter.ts'), 'utf8')
|
||||||
const authStore = fs.readFileSync(path.join(srcRoot, 'stores/modules/auth.ts'), 'utf8')
|
const authStore = fs.readFileSync(path.join(srcRoot, 'stores/modules/auth.ts'), 'utf8')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const componentDir = path.join(currentDir, '..', 'components')
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
[
|
||||||
|
'ledger tree excludes half-checked parents when collecting checked nodes',
|
||||||
|
/getCheckedNodes\(\s*false\s*,\s*false\s*\)/,
|
||||||
|
read('SteadyLedgerTree.vue')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'indicator tree excludes half-checked parents when collecting checked nodes',
|
||||||
|
/getCheckedNodes\(\s*false\s*,\s*false\s*\)/,
|
||||||
|
read('SteadyIndicatorTree.vue')
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, pattern, source]) => !pattern.test(source))
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyDataView selection contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyDataView selection contract passed')
|
||||||
@@ -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 pageFile = path.join(currentDir, 'index.vue')
|
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||||
const apiFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/index.ts')
|
const apiFile = path.resolve(currentDir, '../../../../api/steady/steadyDataView/index.ts')
|
||||||
const interfaceFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/interface/index.ts')
|
const interfaceFile = path.resolve(currentDir, '../../../../api/steady/steadyDataView/interface/index.ts')
|
||||||
const componentDir = path.join(currentDir, 'components')
|
const componentDir = path.join(currentDir, '..', 'components')
|
||||||
const utilsDir = path.join(currentDir, 'utils')
|
const utilsDir = path.join(currentDir, '..', 'utils')
|
||||||
|
|
||||||
const read = file => fs.readFileSync(file, 'utf8')
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
const pageSource = read(pageFile)
|
const pageSource = read(pageFile)
|
||||||
@@ -30,13 +30,16 @@ const utilitySource = fs.existsSync(utilsDir)
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
const expectations = [
|
const expectations = [
|
||||||
['page imports ledger tree panel', /SteadyLedgerTree/],
|
['page imports extracted trend workbench', /SteadyTrendWorkbench/],
|
||||||
['page imports indicator tree panel', /SteadyIndicatorTree/],
|
['trend workbench component exists', fs.existsSync(path.join(componentDir, 'SteadyTrendWorkbench.vue'))],
|
||||||
['page imports trend toolbar', /SteadyTrendToolbar/],
|
['floating indicator panel component exists', fs.existsSync(path.join(componentDir, 'SteadyIndicatorFloatingPanel.vue'))],
|
||||||
['page imports trend chart panel', /SteadyTrendChartPanel/],
|
['components import ledger tree panel', /SteadyLedgerTree/],
|
||||||
|
['components import indicator tree panel', /SteadyIndicatorTree/],
|
||||||
|
['components import trend toolbar', /SteadyTrendToolbar/],
|
||||||
|
['components import trend chart panel', /SteadyTrendChartPanel/],
|
||||||
['page does not import trend summary panel', /SteadyTrendSummaryPanel/],
|
['page does not import trend summary panel', /SteadyTrendSummaryPanel/],
|
||||||
['page does not import data table panel', /SteadyDataTablePanel/],
|
['page does not import data table panel', /SteadyDataTablePanel/],
|
||||||
['page renders floating indicator panel', /indicator-floating-panel/],
|
['components render floating indicator panel', /indicator-floating-panel/],
|
||||||
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
|
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
|
||||||
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
|
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
|
||||||
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
|
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
|
||||||
@@ -50,24 +53,33 @@ const expectations = [
|
|||||||
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
|
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
|
||||||
['components reuse LineChart', /<LineChart/],
|
['components reuse LineChart', /<LineChart/],
|
||||||
['toolbar uses shared time period search', /TimePeriodSearch/],
|
['toolbar uses shared time period search', /TimePeriodSearch/],
|
||||||
['toolbar labels phase options descriptively', /resolvePhaseLabel/],
|
['toolbar labels stat bucket quality filters', /toolbar-field__label[\s\S]*统计:[\s\S]*toolbar-field__label[\s\S]*粒度:[\s\S]*toolbar-field__label[\s\S]*数据:/],
|
||||||
|
['toolbar does not render phase selector', /modelValue\.phases|phaseOptions|resolvePhaseLabel/],
|
||||||
['toolbar labels bucket options descriptively', /bucketOptions[\s\S]*1分钟[\s\S]*1小时/],
|
['toolbar labels bucket options descriptively', /bucketOptions[\s\S]*1分钟[\s\S]*1小时/],
|
||||||
['toolbar labels quality options descriptively', /仅有效数据[\s\S]*仅无效数据/],
|
['toolbar labels quality options descriptively', /仅有效数据[\s\S]*仅无效数据/],
|
||||||
['utilities collect selected line ids', /export const collectSelectedLineIds/],
|
['utilities collect selected line ids', /export const collectSelectedLineIds/],
|
||||||
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
|
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
|
||||||
|
['utilities do not require phase selection', /if\s*\(!phases\.length\)/],
|
||||||
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*6/],
|
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*6/],
|
||||||
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
|
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
|
||||||
|
['utilities strip milliseconds from trend query time', /formatSteadyTrendQueryTime[\s\S]*replace\(\s*\/\\\.\[\^.\]\+\$\//],
|
||||||
|
['utilities do not send phases in trend query payload', /phases:\s*formState\.phases/],
|
||||||
|
['trend query params do not include phases', /phases:\s*string\[\]/],
|
||||||
['utilities build chart options', /export const buildSteadyTrendChartOptions/]
|
['utilities build chart options', /export const buildSteadyTrendChartOptions/]
|
||||||
]
|
]
|
||||||
|
|
||||||
const sourceByExpectation = [
|
const sourceByExpectation = [
|
||||||
pageSource,
|
pageSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
pageSource,
|
pageSource,
|
||||||
pageSource,
|
pageSource,
|
||||||
pageSource,
|
componentSource,
|
||||||
pageSource,
|
|
||||||
pageSource,
|
|
||||||
pageSource,
|
|
||||||
pageSource,
|
pageSource,
|
||||||
apiSource,
|
apiSource,
|
||||||
apiSource,
|
apiSource,
|
||||||
@@ -88,11 +100,15 @@ const sourceByExpectation = [
|
|||||||
utilitySource,
|
utilitySource,
|
||||||
utilitySource,
|
utilitySource,
|
||||||
utilitySource,
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
interfaceSource,
|
||||||
utilitySource
|
utilitySource
|
||||||
]
|
]
|
||||||
|
|
||||||
const failures = expectations.filter(([name, pattern], index) => {
|
const failures = expectations.filter(([name, pattern], index) => {
|
||||||
const matched = pattern.test(sourceByExpectation[index])
|
const matched = typeof pattern === 'boolean' ? pattern : pattern.test(sourceByExpectation[index])
|
||||||
return name.includes('does not') || name.includes('do not') ? matched : !matched
|
return name.includes('does not') || name.includes('do not') ? matched : !matched
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4,11 +4,20 @@ 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 pageFile = path.join(currentDir, 'index.vue')
|
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||||
const apiFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/index.ts')
|
const componentDir = path.join(currentDir, '..', 'components')
|
||||||
const interfaceFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/interface/index.ts')
|
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 source = fs.readFileSync(pageFile, 'utf8')
|
||||||
|
const componentSource = fs.existsSync(componentDir)
|
||||||
|
? fs
|
||||||
|
.readdirSync(componentDir)
|
||||||
|
.filter(file => file.endsWith('.vue'))
|
||||||
|
.map(file => fs.readFileSync(path.join(componentDir, file), 'utf8'))
|
||||||
|
.join('\n')
|
||||||
|
: ''
|
||||||
|
const viewSource = `${source}\n${componentSource}`
|
||||||
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||||
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||||
|
|
||||||
@@ -36,10 +45,13 @@ const forbiddenPatterns = [
|
|||||||
|
|
||||||
const requiredPatterns = [
|
const requiredPatterns = [
|
||||||
['page defines SteadyDataView component name', /name:\s*'SteadyDataView'/, source],
|
['page defines SteadyDataView component name', /name:\s*'SteadyDataView'/, source],
|
||||||
['page keeps trend chart panel', /SteadyTrendChartPanel/, source],
|
['page renders extracted trend workbench', /<SteadyTrendWorkbench/, source],
|
||||||
['page keeps right floating indicator panel', /indicator-floating-panel/, source],
|
['trend workbench component exists', /SteadyTrendWorkbench/, viewSource],
|
||||||
|
['floating indicator panel component exists', /SteadyIndicatorFloatingPanel/, viewSource],
|
||||||
|
['components keep trend chart panel', /SteadyTrendChartPanel/, viewSource],
|
||||||
|
['components keep right floating indicator panel', /indicator-floating-panel/, viewSource],
|
||||||
['indicator panel defaults expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/, source],
|
['indicator panel defaults expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/, source],
|
||||||
['indicator panel supports collapsed state', /is-collapsed/, source],
|
['indicator panel supports collapsed state', /is-collapsed/, viewSource],
|
||||||
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource]
|
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1,77 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="table-box steady-data-view-page">
|
<div class="table-box steady-data-view-page">
|
||||||
<div class="steady-trend-layout">
|
<SteadyTrendWorkbench
|
||||||
<aside class="selector-column">
|
v-model:trend-form="trendForm"
|
||||||
<SteadyLedgerTree
|
v-model:indicator-panel-collapsed="indicatorPanelCollapsed"
|
||||||
:key="selectorResetKey"
|
:ledger-tree="ledgerTree"
|
||||||
:tree-data="ledgerTree"
|
:indicator-tree="indicatorTree"
|
||||||
:loading="loading.ledger"
|
:trend-result="trendResult"
|
||||||
:keyword="ledgerKeyword"
|
:stat-options="statOptions"
|
||||||
@refresh="loadLedgerTree"
|
:show-harmonic-orders="showHarmonicOrders"
|
||||||
@search="handleLedgerSearch"
|
:loading="loading"
|
||||||
@change="handleLedgerChange"
|
:ledger-keyword="ledgerKeyword"
|
||||||
/>
|
:selector-reset-key="selectorResetKey"
|
||||||
</aside>
|
@refresh-ledger="loadLedgerTree"
|
||||||
|
@ledger-search="handleLedgerSearch"
|
||||||
<main class="trend-main">
|
@ledger-change="handleLedgerChange"
|
||||||
<SteadyTrendToolbar
|
@refresh-indicator="loadIndicatorTree"
|
||||||
v-model="trendForm"
|
@indicator-change="handleIndicatorChange"
|
||||||
:phase-options="phaseOptions"
|
@query-trend="handleQueryTrend"
|
||||||
:stat-options="statOptions"
|
@reset-trend="resetTrendState"
|
||||||
:show-harmonic-orders="showHarmonicOrders"
|
/>
|
||||||
:loading="loading.trend"
|
|
||||||
@query="handleQueryTrend"
|
|
||||||
@reset="resetTrendState"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="trend-content">
|
|
||||||
<SteadyTrendChartPanel :trend-result="trendResult" :loading="loading.trend" />
|
|
||||||
|
|
||||||
<aside class="indicator-floating-panel" :class="{ 'is-collapsed': indicatorPanelCollapsed }">
|
|
||||||
<el-button
|
|
||||||
class="indicator-toggle"
|
|
||||||
:icon="indicatorPanelCollapsed ? ArrowLeft : ArrowRight"
|
|
||||||
circle
|
|
||||||
@click="indicatorPanelCollapsed = !indicatorPanelCollapsed"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="indicatorPanelCollapsed"
|
|
||||||
class="indicator-collapsed-trigger"
|
|
||||||
type="button"
|
|
||||||
@click="indicatorPanelCollapsed = false"
|
|
||||||
>
|
|
||||||
稳态指标
|
|
||||||
</button>
|
|
||||||
<div v-show="!indicatorPanelCollapsed" class="indicator-panel-body">
|
|
||||||
<SteadyIndicatorTree
|
|
||||||
:key="selectorResetKey"
|
|
||||||
:tree-data="indicatorTree"
|
|
||||||
:loading="loading.indicator"
|
|
||||||
@refresh="loadIndicatorTree"
|
|
||||||
@change="handleIndicatorChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
|
||||||
import { getSteadyTrendIndicatorTree, getSteadyTrendLedgerTree, querySteadyTrend } from '@/api/steady/steadyDataView'
|
import { getSteadyTrendIndicatorTree, getSteadyTrendLedgerTree, querySteadyTrend } from '@/api/steady/steadyDataView'
|
||||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
import SteadyIndicatorTree from './components/SteadyIndicatorTree.vue'
|
import SteadyTrendWorkbench from './components/SteadyTrendWorkbench.vue'
|
||||||
import SteadyLedgerTree from './components/SteadyLedgerTree.vue'
|
|
||||||
import SteadyTrendChartPanel from './components/SteadyTrendChartPanel.vue'
|
|
||||||
import SteadyTrendToolbar from './components/SteadyTrendToolbar.vue'
|
|
||||||
import {
|
import {
|
||||||
collectSelectedLineIds,
|
collectSelectedLineIds,
|
||||||
hasHarmonicIndicator,
|
hasHarmonicIndicator,
|
||||||
resolveAvailablePhases,
|
|
||||||
resolveAvailableStats,
|
resolveAvailableStats,
|
||||||
validateTrendSelection
|
validateTrendSelection
|
||||||
} from './utils/selectionRules'
|
} from './utils/selectionRules'
|
||||||
@@ -98,10 +57,6 @@ const loading = reactive({
|
|||||||
|
|
||||||
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
|
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
|
||||||
const showHarmonicOrders = computed(() => hasHarmonicIndicator(selectedIndicators.value))
|
const showHarmonicOrders = computed(() => hasHarmonicIndicator(selectedIndicators.value))
|
||||||
const phaseOptions = computed(() => {
|
|
||||||
const phases = resolveAvailablePhases(selectedIndicators.value)
|
|
||||||
return phases.length ? phases : ['A', 'B', 'C', 'T']
|
|
||||||
})
|
|
||||||
const statOptions = computed<SteadyDataView.SteadyTrendStatType[]>(() => {
|
const statOptions = computed<SteadyDataView.SteadyTrendStatType[]>(() => {
|
||||||
const stats = resolveAvailableStats(selectedIndicators.value)
|
const stats = resolveAvailableStats(selectedIndicators.value)
|
||||||
return stats.length ? stats : ['AVG', 'MAX', 'MIN', 'CP95']
|
return stats.length ? stats : ['AVG', 'MAX', 'MIN', 'CP95']
|
||||||
@@ -162,7 +117,6 @@ const handleQueryTrend = async () => {
|
|||||||
const selectionError = validateTrendSelection({
|
const selectionError = validateTrendSelection({
|
||||||
lineIds: lineIds.value,
|
lineIds: lineIds.value,
|
||||||
indicators: selectedIndicators.value,
|
indicators: selectedIndicators.value,
|
||||||
phases: trendForm.value.phases,
|
|
||||||
statTypes: trendForm.value.statTypes,
|
statTypes: trendForm.value.statTypes,
|
||||||
harmonicOrders: trendForm.value.harmonicOrders
|
harmonicOrders: trendForm.value.harmonicOrders
|
||||||
})
|
})
|
||||||
@@ -171,7 +125,7 @@ const handleQueryTrend = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!trendForm.value.timeRange[0] || !trendForm.value.timeRange[1]) {
|
if (!trendForm.value.timeRange[0] || !trendForm.value.timeRange[1]) {
|
||||||
ElMessage.warning('请选择趋势时间范围')
|
ElMessage.warning('\u8bf7\u9009\u62e9\u8d8b\u52bf\u65f6\u95f4\u8303\u56f4')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +133,7 @@ const handleQueryTrend = async () => {
|
|||||||
|
|
||||||
loading.trend = true
|
loading.trend = true
|
||||||
try {
|
try {
|
||||||
// 趋势查询只驱动主图,右侧稳态指标作为筛选面板独立加载,避免额外摘要请求拖慢页面响应。
|
// 趋势查询只驱动主图,右侧指标树作为筛选面板独立加载,避免额外请求拖慢页面响应。
|
||||||
const trendResponse = await querySteadyTrend(payload)
|
const trendResponse = await querySteadyTrend(payload)
|
||||||
trendResult.value = unwrapData(trendResponse)
|
trendResult.value = unwrapData(trendResponse)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -190,19 +144,14 @@ const handleQueryTrend = async () => {
|
|||||||
watch(
|
watch(
|
||||||
selectedIndicators,
|
selectedIndicators,
|
||||||
() => {
|
() => {
|
||||||
const availablePhases = phaseOptions.value
|
|
||||||
const availableStats = statOptions.value
|
const availableStats = statOptions.value
|
||||||
|
|
||||||
trendForm.value = {
|
trendForm.value = {
|
||||||
...trendForm.value,
|
...trendForm.value,
|
||||||
phases: trendForm.value.phases.filter(phase => availablePhases.includes(phase)),
|
|
||||||
statTypes: trendForm.value.statTypes.filter(stat => availableStats.includes(stat)),
|
statTypes: trendForm.value.statTypes.filter(stat => availableStats.includes(stat)),
|
||||||
harmonicOrders: showHarmonicOrders.value ? trendForm.value.harmonicOrders : []
|
harmonicOrders: showHarmonicOrders.value ? trendForm.value.harmonicOrders : []
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!trendForm.value.phases.length) {
|
|
||||||
trendForm.value.phases = availablePhases.slice(0, Math.min(3, availablePhases.length))
|
|
||||||
}
|
|
||||||
if (!trendForm.value.statTypes.length) {
|
if (!trendForm.value.statTypes.length) {
|
||||||
trendForm.value.statTypes = availableStats.slice(0, 1)
|
trendForm.value.statTypes = availableStats.slice(0, 1)
|
||||||
}
|
}
|
||||||
@@ -221,101 +170,4 @@ onMounted(() => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.steady-trend-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 320px minmax(0, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-column {
|
|
||||||
display: grid;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-main {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-content {
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-content :deep(.trend-chart-panel) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-floating-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
bottom: 12px;
|
|
||||||
z-index: 2;
|
|
||||||
width: 360px;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-floating-panel.is-collapsed {
|
|
||||||
width: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-toggle {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
left: -18px;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-panel-body {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-panel-body :deep(.steady-tree-card) {
|
|
||||||
height: 100%;
|
|
||||||
box-shadow: var(--el-box-shadow-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-collapsed-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 8px 0;
|
|
||||||
border: 1px solid var(--el-border-color-light);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
box-shadow: var(--el-box-shadow-light);
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-collapsed-trigger:hover {
|
|
||||||
border-color: var(--el-color-primary-light-5);
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1360px) {
|
|
||||||
.steady-trend-layout {
|
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-floating-panel {
|
|
||||||
width: 320px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -42,16 +42,6 @@ export const hasHarmonicIndicator = (indicators: SteadyDataView.SteadyIndicatorN
|
|||||||
return indicators.some(item => item.harmonic || Boolean(item.harmonicOrderStart || item.harmonicOrderEnd))
|
return indicators.some(item => item.harmonic || Boolean(item.harmonicOrderStart || item.harmonicOrderEnd))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolveAvailablePhases = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
|
||||||
const phaseSet = new Set<string>()
|
|
||||||
|
|
||||||
indicators.forEach(indicator => {
|
|
||||||
indicator.phaseCodes?.forEach(phase => phaseSet.add(phase))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(phaseSet)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||||
const statSet = new Set<SteadyDataView.SteadyTrendStatType>()
|
const statSet = new Set<SteadyDataView.SteadyTrendStatType>()
|
||||||
|
|
||||||
@@ -65,18 +55,16 @@ export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicator
|
|||||||
export const estimateTrendSeriesCount = (
|
export const estimateTrendSeriesCount = (
|
||||||
lineIds: string[],
|
lineIds: string[],
|
||||||
indicators: SteadyDataView.SteadyIndicatorNode[],
|
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||||
phases: string[],
|
|
||||||
statTypes: SteadyDataView.SteadyTrendStatType[],
|
statTypes: SteadyDataView.SteadyTrendStatType[],
|
||||||
harmonicOrders: number[]
|
harmonicOrders: number[]
|
||||||
) => {
|
) => {
|
||||||
const harmonicMultiplier = hasHarmonicIndicator(indicators) ? Math.max(harmonicOrders.length, 1) : 1
|
const harmonicMultiplier = hasHarmonicIndicator(indicators) ? Math.max(harmonicOrders.length, 1) : 1
|
||||||
|
|
||||||
return indicators.reduce((count, indicator) => {
|
return indicators.reduce((count, indicator) => {
|
||||||
const indicatorPhases = indicator.phaseCodes?.length ? indicator.phaseCodes : phases
|
const phaseCount = indicator.phaseCodes?.length || 1
|
||||||
const selectedPhaseCount = indicatorPhases.filter(phase => phases.includes(phase)).length || indicatorPhases.length || 1
|
|
||||||
const fieldCount = Math.max(indicator.seriesFields?.length || indicator.baseFields?.length || 1, 1)
|
const fieldCount = Math.max(indicator.seriesFields?.length || indicator.baseFields?.length || 1, 1)
|
||||||
|
|
||||||
return count + lineIds.length * selectedPhaseCount * Math.max(statTypes.length, 1) * fieldCount * harmonicMultiplier
|
return count + lineIds.length * phaseCount * Math.max(statTypes.length, 1) * fieldCount * harmonicMultiplier
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,24 +82,22 @@ export const validateHarmonicOrders = (
|
|||||||
export const validateTrendSelection = (params: {
|
export const validateTrendSelection = (params: {
|
||||||
lineIds: string[]
|
lineIds: string[]
|
||||||
indicators: SteadyDataView.SteadyIndicatorNode[]
|
indicators: SteadyDataView.SteadyIndicatorNode[]
|
||||||
phases: string[]
|
|
||||||
statTypes: SteadyDataView.SteadyTrendStatType[]
|
statTypes: SteadyDataView.SteadyTrendStatType[]
|
||||||
harmonicOrders: number[]
|
harmonicOrders: number[]
|
||||||
}) => {
|
}) => {
|
||||||
const { lineIds, indicators, phases, statTypes, harmonicOrders } = params
|
const { lineIds, indicators, statTypes, harmonicOrders } = params
|
||||||
|
|
||||||
if (!lineIds.length) return '请选择监测点'
|
if (!lineIds.length) return '请选择监测点'
|
||||||
if (!indicators.length) return '请选择趋势指标'
|
if (!indicators.length) return '请选择趋势指标'
|
||||||
if (lineIds.length > 1 && indicators.length > 1) return '多监测点查询时只能选择 1 个指标'
|
if (lineIds.length > 1 && indicators.length > 1) return '多监测点查询时只能选择 1 个指标'
|
||||||
if (!statTypes.length) return '请选择统计类型'
|
if (!statTypes.length) return '请选择统计类型'
|
||||||
if (!phases.length) return '请选择相别'
|
|
||||||
|
|
||||||
const harmonicError = validateHarmonicOrders(indicators, harmonicOrders)
|
const harmonicError = validateHarmonicOrders(indicators, harmonicOrders)
|
||||||
if (harmonicError) return harmonicError
|
if (harmonicError) return harmonicError
|
||||||
|
|
||||||
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, phases, statTypes, harmonicOrders)
|
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, statTypes, harmonicOrders)
|
||||||
if (seriesCount > MAX_TREND_SERIES_COUNT) {
|
if (seriesCount > MAX_TREND_SERIES_COUNT) {
|
||||||
return '趋势曲线数量不能超过 24 条,请缩小监测点、指标、相别或统计类型范围'
|
return '趋势曲线数量不能超过 24 条,请缩小监测点、指标或统计类型范围'
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export interface SteadyTrendFormState {
|
|||||||
timeRange: string[]
|
timeRange: string[]
|
||||||
timeUnit: TimePeriodUnit
|
timeUnit: TimePeriodUnit
|
||||||
timeBaseDate: Date
|
timeBaseDate: Date
|
||||||
phases: string[]
|
|
||||||
statTypes: SteadyDataView.SteadyTrendStatType[]
|
statTypes: SteadyDataView.SteadyTrendStatType[]
|
||||||
bucket: string
|
bucket: string
|
||||||
qualityFlag?: number
|
qualityFlag?: number
|
||||||
@@ -19,7 +18,6 @@ export const defaultTrendFormState = (): SteadyTrendFormState => {
|
|||||||
timeRange: buildTimePeriodRange('month', baseDate),
|
timeRange: buildTimePeriodRange('month', baseDate),
|
||||||
timeUnit: 'month',
|
timeUnit: 'month',
|
||||||
timeBaseDate: baseDate,
|
timeBaseDate: baseDate,
|
||||||
phases: ['A', 'B', 'C'],
|
|
||||||
statTypes: ['AVG'],
|
statTypes: ['AVG'],
|
||||||
bucket: '10m',
|
bucket: '10m',
|
||||||
qualityFlag: 1,
|
qualityFlag: 1,
|
||||||
@@ -27,6 +25,11 @@ export const defaultTrendFormState = (): SteadyTrendFormState => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatSteadyTrendQueryTime = (value: string) => {
|
||||||
|
// 后端趋势接口只接受 yyyy-MM-dd HH:mm:ss,公共时间组件生成的毫秒需要在入参层收敛。
|
||||||
|
return value.replace(/\.[^.]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
export const buildSteadyTrendQueryPayload = (
|
export const buildSteadyTrendQueryPayload = (
|
||||||
lineIds: string[],
|
lineIds: string[],
|
||||||
indicators: SteadyDataView.SteadyIndicatorNode[],
|
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||||
@@ -36,9 +39,8 @@ export const buildSteadyTrendQueryPayload = (
|
|||||||
lineIds,
|
lineIds,
|
||||||
indicatorCodes: indicators.map(item => item.indicatorCode).filter(Boolean) as string[],
|
indicatorCodes: indicators.map(item => item.indicatorCode).filter(Boolean) as string[],
|
||||||
statTypes: formState.statTypes,
|
statTypes: formState.statTypes,
|
||||||
phases: formState.phases,
|
timeStart: formatSteadyTrendQueryTime(formState.timeRange[0] || ''),
|
||||||
timeStart: formState.timeRange[0] || '',
|
timeEnd: formatSteadyTrendQueryTime(formState.timeRange[1] || ''),
|
||||||
timeEnd: formState.timeRange[1] || '',
|
|
||||||
bucket: formState.bucket,
|
bucket: formState.bucket,
|
||||||
qualityFlag: formState.qualityFlag,
|
qualityFlag: formState.qualityFlag,
|
||||||
harmonicOrders: formState.harmonicOrders.length ? formState.harmonicOrders : undefined
|
harmonicOrders: formState.harmonicOrders.length ? formState.harmonicOrders : undefined
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { parseComtradeApi, parseComtradeVectorApi } from '@/api/tools/waveform'
|
import { parseComtradeApi, parseComtradeVectorApi } from '@/api/tools/waveform'
|
||||||
@@ -125,6 +125,7 @@ const vectorParseResult = ref<Waveform.WaveComtradeVectorResultVO | null>(null)
|
|||||||
const lastParseErrorMessage = ref('')
|
const lastParseErrorMessage = ref('')
|
||||||
const lastVectorParseErrorMessage = ref('')
|
const lastVectorParseErrorMessage = ref('')
|
||||||
const waveformFileAccept = '.cfg,.dat'
|
const waveformFileAccept = '.cfg,.dat'
|
||||||
|
const EVENT_LIST_WAVEFORM_SESSION_KEY = 'eventList:waveformParseResult'
|
||||||
const trendXZoomRange = ref<TrendZoomRange>({ start: 0, end: 100 })
|
const trendXZoomRange = ref<TrendZoomRange>({ start: 0, end: 100 })
|
||||||
const trendYZoomScale = ref(1)
|
const trendYZoomScale = ref(1)
|
||||||
const activeTrendInteractionMode = ref<'none' | 'box-zoom' | 'pan' | 'mark'>('none')
|
const activeTrendInteractionMode = ref<'none' | 'box-zoom' | 'pan' | 'mark'>('none')
|
||||||
@@ -447,6 +448,35 @@ const resetTrendToolState = () => {
|
|||||||
trendMarkers.value = []
|
trendMarkers.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyWaveformParseResult = (parseResult: Waveform.WaveComtradeResultVO) => {
|
||||||
|
waveformParseResult.value = parseResult
|
||||||
|
vectorParseResult.value = null
|
||||||
|
sourceValueMode.value = resolveSourceValueMode(parseResult.waveData?.szValueType)
|
||||||
|
temporaryPtRatio.value = parseResult.waveData?.pt
|
||||||
|
temporaryCtRatio.value = parseResult.waveData?.ct
|
||||||
|
activeValueMode.value = 'primary'
|
||||||
|
activeChannelIndex.value = 'all'
|
||||||
|
lastParseErrorMessage.value = ''
|
||||||
|
lastVectorParseErrorMessage.value = ''
|
||||||
|
resetTrendToolState()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const storedWaveform = sessionStorage.getItem(EVENT_LIST_WAVEFORM_SESSION_KEY)
|
||||||
|
|
||||||
|
if (!storedWaveform) return
|
||||||
|
|
||||||
|
sessionStorage.removeItem(EVENT_LIST_WAVEFORM_SESSION_KEY)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// eventList 已由后端按事件定位并解析波形,这里只恢复解析结果以复用当前展示能力。
|
||||||
|
applyWaveformParseResult(JSON.parse(storedWaveform) as Waveform.WaveComtradeResultVO)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[waveform] restore event list waveform failed', error)
|
||||||
|
ElMessage.error('事件波形数据解析失败,请重新查看波形')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const zoomTrendXAxis = (ratio: number) => {
|
const zoomTrendXAxis = (ratio: number) => {
|
||||||
const { start, end } = trendXZoomRange.value
|
const { start, end } = trendXZoomRange.value
|
||||||
const center = (start + end) / 2
|
const center = (start + end) / 2
|
||||||
@@ -1216,11 +1246,7 @@ const loadWaveformData = async (cfgFile: File, datFile: File) => {
|
|||||||
if (waveformResult.status === 'fulfilled') {
|
if (waveformResult.status === 'fulfilled') {
|
||||||
const parseResult = waveformResult.value.data
|
const parseResult = waveformResult.value.data
|
||||||
|
|
||||||
waveformParseResult.value = parseResult
|
applyWaveformParseResult(parseResult)
|
||||||
sourceValueMode.value = resolveSourceValueMode(parseResult.waveData?.szValueType)
|
|
||||||
temporaryPtRatio.value = parseResult.waveData?.pt
|
|
||||||
temporaryCtRatio.value = parseResult.waveData?.ct
|
|
||||||
activeValueMode.value = 'primary'
|
|
||||||
} else {
|
} else {
|
||||||
waveformParseResult.value = null
|
waveformParseResult.value = null
|
||||||
sourceValueMode.value = 'secondary'
|
sourceValueMode.value = 'secondary'
|
||||||
|
|||||||
Reference in New Issue
Block a user