docs(addLedger): 添加台账管理API调试文档并优化表单组件
- 新增 add-ledger API 调试文档,包含完整接口说明和使用示例 - 在动态路由中注册 toolAddLedger 和 eventList 模块 - 重构工程表单组件,添加简化/完整模式切换和只读状态支持 - 重构设备表单组件,实现动态表单项显示和表单验证规则 - 扩展台账接口定义,添加字典类型和数据相关接口定义 - 实现字典类型和数据的API调用方法 - 更新波形图接口,添加通道总数和相位数量字段 - 优化台账主界面,集成上下文面板和工具栏功能 - 实现台账字典选项的缓存管理和动态加载机制
This commit is contained in:
14
frontend/src/api/event/eventList/index.ts
Normal file
14
frontend/src/api/event/eventList/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import http from '@/api'
|
||||
import type { EventList } from './interface'
|
||||
|
||||
export const getTransientEventPage = (params: EventList.TransientPageParams) => {
|
||||
return http.post<EventList.PageResult<EventList.TransientEventRecord>>('/event/list/transient/page', params)
|
||||
}
|
||||
|
||||
export const getTransientEventDetail = (eventId: string) => {
|
||||
return http.get<EventList.TransientEventRecord>(`/event/list/transient/${eventId}`)
|
||||
}
|
||||
|
||||
export const exportTransientEvents = (params: EventList.TransientPageParams) => {
|
||||
return http.downloadWithHeaders('/event/list/transient/export', params)
|
||||
}
|
||||
50
frontend/src/api/event/eventList/interface/index.ts
Normal file
50
frontend/src/api/event/eventList/interface/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ReqPage } from '@/api/interface'
|
||||
|
||||
export namespace EventList {
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
current: number
|
||||
size: number
|
||||
total: number
|
||||
pages?: number
|
||||
}
|
||||
|
||||
export interface TransientPageParams extends ReqPage {
|
||||
startTimeStart?: string
|
||||
startTimeEnd?: string
|
||||
eventType?: string
|
||||
phase?: string
|
||||
eventDescribe?: string
|
||||
durationMin?: number
|
||||
durationMax?: number
|
||||
featureAmplitudeMin?: number
|
||||
featureAmplitudeMax?: number
|
||||
fileFlag?: number
|
||||
dealFlag?: number
|
||||
lineIds?: string[]
|
||||
engineeringName?: string
|
||||
projectName?: string
|
||||
equipmentName?: string
|
||||
lineName?: string
|
||||
}
|
||||
|
||||
export interface TransientEventRecord {
|
||||
eventId: string
|
||||
measurementPointId?: string
|
||||
eventType?: string
|
||||
equipmentName?: string
|
||||
engineeringName?: string
|
||||
projectName?: string
|
||||
startTime?: string
|
||||
lineName?: string
|
||||
eventDescribe?: string
|
||||
sagsource?: string
|
||||
phase?: string
|
||||
duration?: number
|
||||
featureAmplitude?: number
|
||||
wavePath?: string
|
||||
fileFlag?: number
|
||||
dealFlag?: number
|
||||
createTime?: string
|
||||
}
|
||||
}
|
||||
@@ -170,3 +170,21 @@ export const getAvailableLineNos = (params: AddLedger.AvailableLineNoParams) =>
|
||||
export const deleteAddLedgerNode = (params: AddLedger.DeleteNodeParams) => {
|
||||
return requestAddLedger<boolean>('delete', '/node', params)
|
||||
}
|
||||
|
||||
export const getAddLedgerDictTypeList = (params: AddLedger.DictTypeListParams) => {
|
||||
return http.post<AddLedger.PageResult<AddLedger.DictTypeRecord> | AddLedger.DictTypeRecord[]>('/dictType/list', params, {
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
|
||||
export const getAddLedgerDictDataList = (params: AddLedger.DictDataListParams) => {
|
||||
return http.post<AddLedger.PageResult<AddLedger.DictDataRecord> | AddLedger.DictDataRecord[]>('/dictData/listByTypeId', params, {
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
|
||||
export const getAddLedgerDictDataById = (id: string) => {
|
||||
return http.post<AddLedger.DictDataRecord>('/dictData/getDicDataById', id, {
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,4 +107,34 @@ export namespace AddLedger {
|
||||
label: string
|
||||
value: T
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
records?: T[]
|
||||
}
|
||||
|
||||
export interface DictTypeListParams {
|
||||
code: string
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface DictTypeRecord {
|
||||
id?: string
|
||||
name?: string
|
||||
code?: string
|
||||
state?: number
|
||||
}
|
||||
|
||||
export interface DictDataListParams {
|
||||
typeId: string
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface DictDataRecord {
|
||||
id?: string
|
||||
name?: string
|
||||
code?: string
|
||||
state?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const parseComtradeApi = (params: Waveform.ParseComtradeParams) => {
|
||||
|
||||
formData.append('cfgFile', params.cfgFile)
|
||||
formData.append('datFile', params.datFile)
|
||||
appendFormDataValue(formData, 'parseType', params.parseType)
|
||||
|
||||
return http.post<Waveform.WaveComtradeResultVO>(`/wave/parseComtrade`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
|
||||
@@ -39,6 +39,8 @@ export namespace Waveform {
|
||||
c?: string
|
||||
channelName?: string
|
||||
unit?: string
|
||||
totalChannels?: number
|
||||
phaseCount?: number
|
||||
isOpen?: boolean
|
||||
title?: string
|
||||
colors?: string[]
|
||||
@@ -68,10 +70,14 @@ export namespace Waveform {
|
||||
listRmsMinData?: number[][]
|
||||
iPhasic?: number
|
||||
ptType?: number
|
||||
szValueType?: string
|
||||
pt?: number
|
||||
ct?: number
|
||||
time?: string
|
||||
monitorName?: string
|
||||
unit?: string
|
||||
totalChannels?: number
|
||||
phaseCount?: number
|
||||
}
|
||||
|
||||
export interface EigenvalueDTO {
|
||||
@@ -141,5 +147,8 @@ export namespace Waveform {
|
||||
waveData?: WaveDataDTO
|
||||
waveDataDetails?: WaveDataDetail[]
|
||||
eigenvalues?: EigenvalueDTO[]
|
||||
unit?: string
|
||||
totalChannels?: number
|
||||
phaseCount?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const STATIC_ROUTE_NAMES = new Set([
|
||||
'toolWaveform',
|
||||
'toolMmsMapping',
|
||||
'toolAddData',
|
||||
'toolAddLedger',
|
||||
'eventList',
|
||||
'systemMonitor',
|
||||
'diskMonitor',
|
||||
'403',
|
||||
|
||||
@@ -75,6 +75,15 @@ export const staticRouter: RouteRecordRaw[] = [
|
||||
title: '数据台账'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/eventList/index',
|
||||
name: 'eventList',
|
||||
alias: ['/event/eventList'],
|
||||
component: () => import('@/views/event/eventList/index.vue'),
|
||||
meta: {
|
||||
title: '事件列表'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: '403',
|
||||
|
||||
309
frontend/src/views/event/eventList/index.vue
Normal file
309
frontend/src/views/event/eventList/index.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<div class="table-box event-list-page">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
row-key="eventId"
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:search-col="{ xs: 1, sm: 2, md: 2, lg: 3, xl: 4 }"
|
||||
>
|
||||
<template #tableHeader>
|
||||
<el-button type="primary" plain :icon="Download" @click="handleExport">导出</el-button>
|
||||
</template>
|
||||
|
||||
<template #fileFlag="{ row }">
|
||||
<el-tag :type="row.fileFlag === 1 ? 'success' : 'info'" effect="light">
|
||||
{{ resolveFileFlagText(row.fileFlag) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template #dealFlag="{ row }">
|
||||
<el-tag :type="resolveDealFlagTag(row.dealFlag)" effect="light">
|
||||
{{ resolveDealFlagText(row.dealFlag) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template #operation="{ row }">
|
||||
<el-button type="primary" link :icon="View" @click="handleViewDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
|
||||
<el-dialog v-model="detailDialogVisible" title="暂态事件详情" width="760px">
|
||||
<el-skeleton v-if="detailLoading" :rows="6" animated />
|
||||
<el-descriptions v-else :column="2" border>
|
||||
<el-descriptions-item v-for="item in detailItems" :key="item.prop" :label="item.label">
|
||||
{{ item.formatter ? item.formatter(detailData?.[item.prop]) : resolveText(detailData?.[item.prop]) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Download, View } from '@element-plus/icons-vue'
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
||||
import { exportTransientEvents, getTransientEventDetail, getTransientEventPage } from '@/api/event/eventList'
|
||||
import type { EventList } from '@/api/event/eventList/interface'
|
||||
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
|
||||
|
||||
defineOptions({
|
||||
name: 'EventListView'
|
||||
})
|
||||
|
||||
type EventSearchParams = EventList.TransientPageParams & {
|
||||
startTimeRange?: string[]
|
||||
}
|
||||
|
||||
type DetailItem = {
|
||||
label: string
|
||||
prop: keyof EventList.TransientEventRecord
|
||||
formatter?: (value: unknown) => string
|
||||
}
|
||||
|
||||
const proTable = ref<ProTableInstance>()
|
||||
const detailDialogVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<EventList.TransientEventRecord | null>(null)
|
||||
|
||||
const phaseOptions = [
|
||||
{ label: 'A 相', value: 'A' },
|
||||
{ label: 'B 相', value: 'B' },
|
||||
{ label: 'C 相', value: 'C' },
|
||||
{ label: '三相', value: 'ABC' }
|
||||
]
|
||||
|
||||
const fileFlagOptions = [
|
||||
{ label: '未招', value: 0 },
|
||||
{ label: '已招', value: 1 }
|
||||
]
|
||||
|
||||
const dealFlagOptions = [
|
||||
{ label: '未处理', value: 0 },
|
||||
{ label: '已处理', value: 1 },
|
||||
{ label: '已处理无结果', value: 2 },
|
||||
{ label: '计算失败', value: 3 }
|
||||
]
|
||||
|
||||
const columns = reactive<ColumnProps<EventList.TransientEventRecord>[]>([
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||
{
|
||||
prop: 'startTime',
|
||||
label: '发生时刻',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
el: 'date-picker',
|
||||
key: 'startTimeRange',
|
||||
props: {
|
||||
type: 'datetimerange',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'eventDescribe',
|
||||
label: '事件描述',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'eventType',
|
||||
label: '事件类型',
|
||||
minWidth: 160,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'phase',
|
||||
label: '相别',
|
||||
minWidth: 90,
|
||||
enum: phaseOptions,
|
||||
search: {
|
||||
el: 'select'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'engineeringName',
|
||||
label: '工程名称',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'projectName',
|
||||
label: '项目名称',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'equipmentName',
|
||||
label: '设备名称',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'lineName',
|
||||
label: '监测点名称',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{ prop: 'sagsource', label: '事件发生位置', minWidth: 140 },
|
||||
{ prop: 'duration', label: '持续时间(s)', minWidth: 130 },
|
||||
{ prop: 'featureAmplitude', label: '暂降/暂升幅值(%)', minWidth: 160 },
|
||||
{
|
||||
prop: 'fileFlag',
|
||||
label: '波形文件状态',
|
||||
minWidth: 130,
|
||||
enum: fileFlagOptions,
|
||||
search: {
|
||||
el: 'select'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'dealFlag',
|
||||
label: '处理状态',
|
||||
minWidth: 140,
|
||||
enum: dealFlagOptions,
|
||||
search: {
|
||||
el: 'select'
|
||||
}
|
||||
},
|
||||
{ prop: 'createTime', label: '创建时间', minWidth: 180 },
|
||||
{ prop: 'operation', label: '操作', fixed: 'right', width: 100 }
|
||||
])
|
||||
|
||||
const detailItems: DetailItem[] = [
|
||||
{ label: '事件 ID', prop: 'eventId' },
|
||||
{ label: '监测点 ID', prop: 'measurementPointId' },
|
||||
{ label: '事件类型', prop: 'eventType' },
|
||||
{ label: '工程名称', prop: 'engineeringName' },
|
||||
{ label: '项目名称', prop: 'projectName' },
|
||||
{ label: '设备名称', prop: 'equipmentName' },
|
||||
{ label: '监测点名称', prop: 'lineName' },
|
||||
{ label: '发生时刻', prop: 'startTime' },
|
||||
{ label: '事件描述', prop: 'eventDescribe' },
|
||||
{ label: '事件发生位置', prop: 'sagsource' },
|
||||
{ label: '相别', prop: 'phase' },
|
||||
{ label: '持续时间(s)', prop: 'duration' },
|
||||
{ label: '暂降/暂升幅值(%)', prop: 'featureAmplitude' },
|
||||
{ label: '波形文件路径', prop: 'wavePath' },
|
||||
{ label: '波形文件状态', prop: 'fileFlag', formatter: resolveFileFlagText },
|
||||
{ label: '处理状态', prop: 'dealFlag', formatter: resolveDealFlagText },
|
||||
{ label: '创建时间', prop: 'createTime' }
|
||||
]
|
||||
|
||||
const resolveText = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const resolveOptionalText = (value: unknown) => {
|
||||
if (value === null || value === undefined) return undefined
|
||||
const text = String(value).trim()
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
const resolveOptionalNumber = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
function resolveFileFlagText(value: unknown) {
|
||||
if (Number(value) === 0) return '未招'
|
||||
if (Number(value) === 1) return '已招'
|
||||
return '--'
|
||||
}
|
||||
|
||||
function resolveDealFlagText(value: unknown) {
|
||||
if (Number(value) === 0) return '未处理'
|
||||
if (Number(value) === 1) return '已处理'
|
||||
if (Number(value) === 2) return '已处理无结果'
|
||||
if (Number(value) === 3) return '计算失败'
|
||||
return '--'
|
||||
}
|
||||
|
||||
const resolveDealFlagTag = (value: unknown) => {
|
||||
if (Number(value) === 0) return 'info'
|
||||
if (Number(value) === 1) return 'success'
|
||||
if (Number(value) === 2) return 'warning'
|
||||
if (Number(value) === 3) return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const pruneEmptyParams = (params: EventList.TransientPageParams) => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined && value !== '')
|
||||
) as EventList.TransientPageParams
|
||||
}
|
||||
|
||||
const buildEventQueryParams = (params: EventSearchParams = {}) => {
|
||||
const timeRange = Array.isArray(params.startTimeRange) ? params.startTimeRange : []
|
||||
|
||||
return pruneEmptyParams({
|
||||
pageNum: params.pageNum,
|
||||
pageSize: params.pageSize,
|
||||
startTimeStart: resolveOptionalText(timeRange[0]),
|
||||
startTimeEnd: resolveOptionalText(timeRange[1]),
|
||||
eventType: resolveOptionalText(params.eventType),
|
||||
phase: resolveOptionalText(params.phase),
|
||||
eventDescribe: resolveOptionalText(params.eventDescribe),
|
||||
fileFlag: resolveOptionalNumber(params.fileFlag),
|
||||
dealFlag: resolveOptionalNumber(params.dealFlag),
|
||||
engineeringName: resolveOptionalText(params.engineeringName),
|
||||
projectName: resolveOptionalText(params.projectName),
|
||||
equipmentName: resolveOptionalText(params.equipmentName),
|
||||
lineName: resolveOptionalText(params.lineName)
|
||||
})
|
||||
}
|
||||
|
||||
const getTableList = (params: EventSearchParams) => {
|
||||
// 分页查询按 API_DEBUG.md 转换时间范围与枚举筛选参数。
|
||||
return getTransientEventPage(buildEventQueryParams(params))
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row: EventList.TransientEventRecord) => {
|
||||
if (!row.eventId) {
|
||||
ElMessage.warning('缺少事件 ID,无法查询详情')
|
||||
return
|
||||
}
|
||||
|
||||
detailDialogVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailData.value = null
|
||||
|
||||
try {
|
||||
const response = await getTransientEventDetail(row.eventId)
|
||||
detailData.value = response.data
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const searchParam = (proTable.value?.searchParam || {}) as EventSearchParams
|
||||
useDownloadWithServerFileName(exportTransientEvents, '暂态事件列表', buildEventQueryParams(searchParam), false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.event-list-page {
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-list-page :deep(.el-descriptions__cell) {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
484
frontend/src/views/tools/addLedger/API_DEBUG.md
Normal file
484
frontend/src/views/tools/addLedger/API_DEBUG.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# add-ledger API 调试文档
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 服务模块 | `tools/add-ledger` |
|
||||
| 默认本地地址 | `http://127.0.0.1:18192` |
|
||||
| 接口前缀 | `/addLedger` |
|
||||
| 数据格式 | `application/json;charset=UTF-8` |
|
||||
| 认证方式 | 走全局认证过滤器,除登录和 Swagger 外均需携带访问令牌 |
|
||||
|
||||
请求头:
|
||||
|
||||
```http
|
||||
Authorization: Bearer ${accessToken}
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `Authorization` 的真实 Header 名称和前缀来自公共组件 `SecurityConstants`,当前过滤器按 `SecurityConstants.AUTHORIZATION_KEY` 和 `SecurityConstants.AUTHORIZATION_PREFIX` 校验。
|
||||
- 以下响应示例只固定 `data` 结构;`code`、`message` 以 `HttpResultUtil` 和 `CommonResponseEnum.SUCCESS` 的运行时序列化结果为准。
|
||||
- 编辑项目、设备、测点时不支持搬迁父节点;如果请求中传父级 ID,后端仍以当前台账节点已有父级关系为准。
|
||||
- 删除父节点前端必须二次确认;后端收到删除请求后直接执行级联软删除。
|
||||
|
||||
统一成功响应结构示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SUCCESS_CODE",
|
||||
"message": "SUCCESS_MESSAGE",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 调试顺序建议
|
||||
|
||||
1. 调用登录接口获取 `accessToken`。
|
||||
2. 新增工程。
|
||||
3. 使用工程 ID 新增项目。
|
||||
4. 使用项目 ID 新增设备。
|
||||
5. 查询设备可用线路号。
|
||||
6. 使用设备 ID 和可用线路号新增测点。
|
||||
7. 调用台账树和详情接口核对层级与字段。
|
||||
8. 调用删除接口验证软删除和级联软删除。
|
||||
|
||||
## 3. 接口总览
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/addLedger/tree` | 查询台账树 |
|
||||
| `GET` | `/addLedger/detail` | 查询节点详情 |
|
||||
| `POST` | `/addLedger/engineering/save` | 新增或保存工程 |
|
||||
| `POST` | `/addLedger/project/save` | 新增或保存项目 |
|
||||
| `POST` | `/addLedger/equipment/save` | 新增或保存设备 |
|
||||
| `POST` | `/addLedger/line/save` | 新增或保存测点 |
|
||||
| `GET` | `/addLedger/line/availableLineNos` | 查询设备可用线路号 |
|
||||
| `DELETE` | `/addLedger/node` | 删除节点并级联软删除子节点 |
|
||||
|
||||
## 4. 查询台账树
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
```http
|
||||
GET /addLedger/tree?keyword=测试
|
||||
```
|
||||
|
||||
Query 参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `keyword` | `String` | 否 | 按 `cs_ledger.Name` 模糊查询 |
|
||||
|
||||
curl:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://127.0.0.1:18192/addLedger/tree?keyword=测试" \
|
||||
-H "Authorization: Bearer ${accessToken}"
|
||||
```
|
||||
|
||||
### 4.2 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SUCCESS_CODE",
|
||||
"message": "SUCCESS_MESSAGE",
|
||||
"data": [
|
||||
{
|
||||
"id": "engineeringId",
|
||||
"parentId": "0",
|
||||
"parentIds": "0",
|
||||
"name": "测试工程",
|
||||
"level": 0,
|
||||
"sort": 0,
|
||||
"children": [
|
||||
{
|
||||
"id": "projectId",
|
||||
"parentId": "engineeringId",
|
||||
"parentIds": "0,engineeringId",
|
||||
"name": "测试项目",
|
||||
"level": 1,
|
||||
"sort": 0,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 查询节点详情
|
||||
|
||||
### 5.1 请求
|
||||
|
||||
```http
|
||||
GET /addLedger/detail?id=engineeringId&level=0
|
||||
```
|
||||
|
||||
Query 参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | `String` | 是 | 节点 ID,与业务表主键一致 |
|
||||
| `level` | `Integer` | 是 | `0` 工程,`1` 项目,`2` 设备,`3` 测点 |
|
||||
|
||||
curl:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://127.0.0.1:18192/addLedger/detail?id=engineeringId&level=0" \
|
||||
-H "Authorization: Bearer ${accessToken}"
|
||||
```
|
||||
|
||||
### 5.2 响应
|
||||
|
||||
工程详情:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SUCCESS_CODE",
|
||||
"message": "SUCCESS_MESSAGE",
|
||||
"data": {
|
||||
"id": "engineeringId",
|
||||
"level": 0,
|
||||
"parentId": "0",
|
||||
"parentIds": "0",
|
||||
"name": "测试工程",
|
||||
"sort": 0,
|
||||
"province": "320000000000",
|
||||
"city": "320100000000",
|
||||
"description": "工程描述"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 新增或保存工程
|
||||
|
||||
### 6.1 请求
|
||||
|
||||
```http
|
||||
POST /addLedger/engineering/save
|
||||
```
|
||||
|
||||
Body 参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | `String` | 否 | 为空时新增;非空时编辑 |
|
||||
| `name` | `String` | 是 | 工程名称,同步为树节点名称 |
|
||||
| `province` | `String` | 否 | 省 |
|
||||
| `city` | `String` | 否 | 市 |
|
||||
| `description` | `String` | 否 | 描述 |
|
||||
|
||||
新增示例:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:18192/addLedger/engineering/save" \
|
||||
-H "Authorization: Bearer ${accessToken}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
-d "{\"name\":\"测试工程\",\"province\":\"320000000000\",\"city\":\"320100000000\",\"description\":\"工程描述\"}"
|
||||
```
|
||||
|
||||
编辑示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "engineeringId",
|
||||
"name": "测试工程-修改",
|
||||
"province": "320000000000",
|
||||
"city": "320100000000",
|
||||
"description": "工程描述修改"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SUCCESS_CODE",
|
||||
"message": "SUCCESS_MESSAGE",
|
||||
"data": {
|
||||
"id": "engineeringId",
|
||||
"level": 0,
|
||||
"parentId": "0",
|
||||
"parentIds": "0",
|
||||
"name": "测试工程",
|
||||
"province": "320000000000",
|
||||
"city": "320100000000",
|
||||
"description": "工程描述"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 新增或保存项目
|
||||
|
||||
### 7.1 请求
|
||||
|
||||
```http
|
||||
POST /addLedger/project/save
|
||||
```
|
||||
|
||||
Body 参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | `String` | 否 | 为空时新增;非空时编辑 |
|
||||
| `engineeringId` | `String` | 新增必填 | 父级工程 ID |
|
||||
| `name` | `String` | 是 | 项目名称,同步为树节点名称 |
|
||||
| `area` | `String` | 否 | 相对位置 |
|
||||
| `description` | `String` | 否 | 项目描述 |
|
||||
|
||||
新增示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"engineeringId": "engineeringId",
|
||||
"name": "测试项目",
|
||||
"area": "灿能园区",
|
||||
"description": "项目描述"
|
||||
}
|
||||
```
|
||||
|
||||
curl:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:18192/addLedger/project/save" \
|
||||
-H "Authorization: Bearer ${accessToken}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
-d "{\"engineeringId\":\"engineeringId\",\"name\":\"测试项目\",\"area\":\"灿能园区\",\"description\":\"项目描述\"}"
|
||||
```
|
||||
|
||||
## 8. 新增或保存设备
|
||||
|
||||
### 8.1 请求
|
||||
|
||||
```http
|
||||
POST /addLedger/equipment/save
|
||||
```
|
||||
|
||||
Body 参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | `String` | 否 | 为空时新增;非空时编辑 |
|
||||
| `projectId` | `String` | 新增必填 | 父级项目 ID |
|
||||
| `name` | `String` | 是 | 装置名称,同步为树节点名称 |
|
||||
| `ndid` | `String` | 是 | 网络设备 ID |
|
||||
| `mac` | `String` | 是 | 装置 MAC 地址 |
|
||||
| `devType` | `String` | 否 | 装置类型,保存字典数据 ID |
|
||||
| `devModel` | `String` | 是 | 装置型号,保存字典数据 ID |
|
||||
| `devAccessMethod` | `String` | 否 | 装置接入方式 |
|
||||
| `nodeId` | `String` | 否 | 前置服务器 IP |
|
||||
| `nodeProcess` | `Integer` | 否 | 前置进程号 |
|
||||
| `upgrade` | `Integer` | 否 | 是否支持升级,`0` 否,`1` 是,默认 `0` |
|
||||
|
||||
新增示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"projectId": "projectId",
|
||||
"name": "测试设备",
|
||||
"ndid": "00B78D000001",
|
||||
"mac": "00:B7:8D:00:00:01",
|
||||
"devType": "dictDataIdForDeviceType",
|
||||
"devModel": "dictDataIdForDeviceModel",
|
||||
"devAccessMethod": "MQTT",
|
||||
"nodeId": "127.0.0.1",
|
||||
"nodeProcess": 1,
|
||||
"upgrade": 0
|
||||
}
|
||||
```
|
||||
|
||||
curl:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:18192/addLedger/equipment/save" \
|
||||
-H "Authorization: Bearer ${accessToken}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
-d "{\"projectId\":\"projectId\",\"name\":\"测试设备\",\"ndid\":\"00B78D000001\",\"mac\":\"00:B7:8D:00:00:01\",\"devModel\":\"dictDataIdForDeviceModel\",\"devAccessMethod\":\"MQTT\",\"upgrade\":0}"
|
||||
```
|
||||
|
||||
### 8.2 默认值
|
||||
|
||||
新增设备时后端默认填充:
|
||||
|
||||
| 字段 | 默认值 |
|
||||
|---|---|
|
||||
| `run_status` | `1` |
|
||||
| `status` | `1` |
|
||||
| `process` | `4` |
|
||||
| `usage_status` | `1` |
|
||||
| `sort` | `0` |
|
||||
| `upgrade` | 未传时为 `0` |
|
||||
|
||||
## 9. 查询设备可用线路号
|
||||
|
||||
### 9.1 请求
|
||||
|
||||
```http
|
||||
GET /addLedger/line/availableLineNos?deviceId=equipmentId&lineId=lineId
|
||||
```
|
||||
|
||||
Query 参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `deviceId` | `String` | 是 | 设备 ID |
|
||||
| `lineId` | `String` | 否 | 编辑测点时传入当前测点 ID |
|
||||
|
||||
curl:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://127.0.0.1:18192/addLedger/line/availableLineNos?deviceId=equipmentId" \
|
||||
-H "Authorization: Bearer ${accessToken}"
|
||||
```
|
||||
|
||||
### 9.2 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SUCCESS_CODE",
|
||||
"message": "SUCCESS_MESSAGE",
|
||||
"data": [1, 2, 3, 4, 5]
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 新增或保存测点
|
||||
|
||||
### 10.1 请求
|
||||
|
||||
```http
|
||||
POST /addLedger/line/save
|
||||
```
|
||||
|
||||
Body 参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `lineId` | `String` | 否 | 为空时新增;非空时编辑 |
|
||||
| `deviceId` | `String` | 新增必填 | 父级设备 ID |
|
||||
| `name` | `String` | 是 | 监测点名,同步为树节点名称 |
|
||||
| `lineNo` | `Integer` | 是 | 设备线路,范围 `1-20` |
|
||||
| `conType` | `Integer` | 是 | 接线方式,`0` 星型,`1` 角型,`2` V 型 |
|
||||
| `volGrade` | `Decimal` | 是 | 电压等级,仅支持 `0.38`、`10`、`35`、`110`、`220`、`500` |
|
||||
| `position` | `String` | 否 | 安装位置 |
|
||||
| `ctRatio` | `Decimal` | 是 | CT 一次额定值,非负数 |
|
||||
| `ct2Ratio` | `Decimal` | 是 | CT 二次额定值,非负数 |
|
||||
| `ptRatio` | `Decimal` | 是 | PT 一次额定值,非负数 |
|
||||
| `pt2Ratio` | `Decimal` | 是 | PT 二次额定值,非负数 |
|
||||
| `shortCircuitCapacity` | `Decimal` | 否 | 最小短路容量,非负数 |
|
||||
| `basicCapacity` | `Decimal` | 否 | 基准短路容量,非负数 |
|
||||
| `protocolCapacity` | `Decimal` | 否 | 用户协议容量,非负数 |
|
||||
| `devCapacity` | `Decimal` | 否 | 供电设备容量,非负数 |
|
||||
| `monitorObj` | `String` | 否 | 监测对象类型 |
|
||||
| `isGovern` | `Integer` | 否 | 是否治理,默认 `0` |
|
||||
| `monitorUser` | `String` | 否 | 敏感用户 ID |
|
||||
| `isImportant` | `Integer` | 否 | 是否主要监测点,默认 `0` |
|
||||
|
||||
新增示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceId": "equipmentId",
|
||||
"name": "测试测点1",
|
||||
"lineNo": 1,
|
||||
"conType": 0,
|
||||
"volGrade": 10,
|
||||
"position": "positionDictDataId",
|
||||
"ctRatio": 200,
|
||||
"ct2Ratio": 1,
|
||||
"ptRatio": 10,
|
||||
"pt2Ratio": 0.1,
|
||||
"shortCircuitCapacity": 10,
|
||||
"basicCapacity": 10,
|
||||
"protocolCapacity": 10,
|
||||
"devCapacity": 10,
|
||||
"monitorObj": "monitorObjDictDataId",
|
||||
"isGovern": 0,
|
||||
"monitorUser": "",
|
||||
"isImportant": 0
|
||||
}
|
||||
```
|
||||
|
||||
curl:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:18192/addLedger/line/save" \
|
||||
-H "Authorization: Bearer ${accessToken}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
-d "{\"deviceId\":\"equipmentId\",\"name\":\"测试测点1\",\"lineNo\":1,\"conType\":0,\"volGrade\":10,\"ctRatio\":200,\"ct2Ratio\":1,\"ptRatio\":10,\"pt2Ratio\":0.1,\"isGovern\":0,\"isImportant\":0}"
|
||||
```
|
||||
|
||||
### 10.2 校验规则
|
||||
|
||||
- `lineNo` 必须在 `1-20` 之间。
|
||||
- 同设备下正常测点的 `lineNo` 不可重复。
|
||||
- `conType` 只能是 `0`、`1`、`2`。
|
||||
- `volGrade` 只能是 `0.38`、`10`、`35`、`110`、`220`、`500`。
|
||||
- CT/PT 字段必填且不能为负数。
|
||||
- 容量字段传入时不能为负数。
|
||||
|
||||
## 11. 删除节点
|
||||
|
||||
### 11.1 请求
|
||||
|
||||
```http
|
||||
DELETE /addLedger/node?id=engineeringId&level=0
|
||||
```
|
||||
|
||||
Query 参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | `String` | 是 | 节点 ID |
|
||||
| `level` | `Integer` | 是 | `0` 工程,`1` 项目,`2` 设备,`3` 测点 |
|
||||
|
||||
curl:
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://127.0.0.1:18192/addLedger/node?id=engineeringId&level=0" \
|
||||
-H "Authorization: Bearer ${accessToken}"
|
||||
```
|
||||
|
||||
### 11.2 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SUCCESS_CODE",
|
||||
"message": "SUCCESS_MESSAGE",
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
### 11.3 删除影响范围
|
||||
|
||||
| 删除层级 | 后端处理 |
|
||||
|---|---|
|
||||
| 工程 | 软删除工程、下属项目、设备、测点和对应台账节点 |
|
||||
| 项目 | 软删除项目、下属设备、测点和对应台账节点 |
|
||||
| 设备 | 软删除设备、下属测点和对应台账节点 |
|
||||
| 测点 | 软删除测点和对应台账节点 |
|
||||
|
||||
软删除字段:
|
||||
|
||||
| 表 | 字段 | 删除值 |
|
||||
|---|---|---|
|
||||
| `cs_engineering` | `status` | `0` |
|
||||
| `cs_project` | `status` | `0` |
|
||||
| `cs_equipment_delivery` | `run_status` | `0` |
|
||||
| `cs_line` | `status` | `0` |
|
||||
| `cs_ledger` | `State` | `0` |
|
||||
|
||||
## 12. 常见错误
|
||||
|
||||
| 场景 | 典型错误信息 |
|
||||
|---|---|
|
||||
| 未传 token 或 token 格式错误 | 全局过滤器返回 token 解析失败 |
|
||||
| 新增项目未传有效工程 ID | `台账节点不存在或已删除` |
|
||||
| 新增设备未传有效项目 ID | `台账节点不存在或已删除` |
|
||||
| 新增测点未传有效设备 ID | `台账节点不存在或已删除` |
|
||||
| 测点线路号重复 | `同设备下 line_no 不可重复` |
|
||||
| 测点线路号超范围 | `line_no 必须在 1-20 之间` |
|
||||
| 接线方式非法 | `conType 只能是 0、1、2` |
|
||||
| 电压等级非法 | `vol_grade 只能是 0.38、10、35、110、220、500` |
|
||||
@@ -3,10 +3,8 @@
|
||||
<div class="form-header">
|
||||
<div>
|
||||
<div class="section-title">工程配置</div>
|
||||
<div class="section-description">维护工程基础信息,并从工程下继续新增项目。</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" :icon="CirclePlus" @click="emit('add-project')">新增项目</el-button>
|
||||
<div v-if="!readonly" class="form-actions">
|
||||
<el-button type="primary" :icon="Check" :loading="saving" @click="emit('save')">保存工程</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" :disabled="!localForm.id" @click="emit('delete')">
|
||||
删除工程
|
||||
@@ -14,24 +12,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="96px" class="ledger-form form-two">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="localForm"
|
||||
:rules="formRules"
|
||||
:disabled="readonly"
|
||||
label-width="96px"
|
||||
:class="['ledger-form', isSimpleMode ? 'form-simple' : 'form-three']"
|
||||
>
|
||||
<el-form-item label="工程名称" prop="name">
|
||||
<el-input v-model="localForm.name" maxlength="80" clearable placeholder="请输入工程名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="省" prop="province">
|
||||
<el-form-item v-if="!isSimpleMode" label="省" prop="province">
|
||||
<el-input v-model="localForm.province" maxlength="40" clearable placeholder="请输入省份" />
|
||||
</el-form-item>
|
||||
<el-form-item label="市" prop="city">
|
||||
<el-form-item v-if="!isSimpleMode" label="市" prop="city">
|
||||
<el-input v-model="localForm.city" maxlength="40" clearable placeholder="请输入城市" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-form-item label="描述" prop="description" class="form-item-wide">
|
||||
<el-input
|
||||
v-model="localForm.description"
|
||||
class="description-textarea"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:rows="1"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入工程描述"
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -39,8 +45,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Check, CirclePlus, Delete } from '@element-plus/icons-vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Check, Delete } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type { AddLedger } from '@/api/tools/addLedger/interface'
|
||||
|
||||
@@ -51,13 +57,14 @@ defineOptions({
|
||||
const props = defineProps<{
|
||||
form: AddLedger.EngineeringForm
|
||||
saving: boolean
|
||||
mode?: 'simple' | 'full'
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:form': [form: AddLedger.EngineeringForm]
|
||||
save: []
|
||||
delete: []
|
||||
'add-project': []
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
@@ -104,6 +111,8 @@ const formRules: FormRules<AddLedger.EngineeringForm> = {
|
||||
name: [{ required: true, message: '请输入工程名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const isSimpleMode = computed(() => props.mode === 'simple')
|
||||
|
||||
const validateForm = async () => {
|
||||
const result = await formRef.value?.validate().catch(() => false)
|
||||
return Boolean(result)
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
<div class="form-header">
|
||||
<div>
|
||||
<div class="section-title">设备配置</div>
|
||||
<div class="section-description">维护装置编号、型号和前置节点信息,并从设备下继续新增测点。</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" :icon="CirclePlus" @click="emit('add-line')">新增测点</el-button>
|
||||
<div v-if="!readonly" class="form-actions">
|
||||
<el-button type="primary" :icon="Check" :loading="saving" @click="emit('save')">保存设备</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" :disabled="!localForm.id" @click="emit('delete')">
|
||||
删除设备
|
||||
@@ -14,11 +12,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="126px" class="ledger-form form-two">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="localForm"
|
||||
:rules="formRules"
|
||||
:disabled="readonly"
|
||||
label-width="126px"
|
||||
:class="['ledger-form', isSimpleMode ? 'form-simple' : 'form-three']"
|
||||
>
|
||||
<el-form-item label="装置名称" prop="name">
|
||||
<el-input v-model="localForm.name" maxlength="80" clearable placeholder="请输入装置名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网络设备 ID" prop="ndid">
|
||||
<el-form-item v-if="!isSimpleMode" label="网络设备 ID" prop="ndid">
|
||||
<el-input v-model="localForm.ndid" maxlength="64" clearable placeholder="请输入网络设备 ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="装置 MAC 地址" prop="mac">
|
||||
@@ -26,33 +31,49 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="装置类型" prop="dev_type">
|
||||
<el-select v-model="localForm.dev_type" clearable placeholder="请选择装置类型">
|
||||
<el-option v-for="item in deviceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<el-option
|
||||
v-for="item in deviceTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="装置型号" prop="dev_model">
|
||||
<el-select v-model="localForm.dev_model" clearable placeholder="请选择装置型号">
|
||||
<el-option v-for="item in deviceModelOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<el-option
|
||||
v-for="item in deviceModelOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="接入方式" prop="dev_access_method">
|
||||
<el-form-item v-if="!isSimpleMode" label="接入方式" prop="dev_access_method">
|
||||
<el-input v-model="localForm.dev_access_method" maxlength="40" clearable placeholder="请输入接入方式" />
|
||||
</el-form-item>
|
||||
<el-form-item label="前置服务器 IP" prop="node_id">
|
||||
<el-form-item v-if="!isSimpleMode" label="前置服务器 IP" prop="node_id">
|
||||
<el-input v-model="localForm.node_id" maxlength="64" clearable placeholder="请输入前置服务器 IP" />
|
||||
</el-form-item>
|
||||
<el-form-item label="前置进程号" prop="node_process">
|
||||
<el-form-item v-if="!isSimpleMode" label="前置进程号" prop="node_process">
|
||||
<el-input v-model="localForm.node_process" maxlength="40" clearable placeholder="请输入前置进程号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否支持升级" prop="upgrade">
|
||||
<el-switch v-model="localForm.upgrade" :active-value="1" :inactive-value="0" active-text="支持" inactive-text="不支持" />
|
||||
<el-form-item v-if="!isSimpleMode" label="是否支持升级" prop="upgrade">
|
||||
<el-switch
|
||||
v-model="localForm.upgrade"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="支持"
|
||||
inactive-text="不支持"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Check, CirclePlus, Delete } from '@element-plus/icons-vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Check, Delete } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type { AddLedger } from '@/api/tools/addLedger/interface'
|
||||
|
||||
@@ -63,6 +84,8 @@ defineOptions({
|
||||
const props = defineProps<{
|
||||
form: AddLedger.EquipmentForm
|
||||
saving: boolean
|
||||
mode?: 'simple' | 'full'
|
||||
readonly?: boolean
|
||||
deviceTypeOptions: AddLedger.SelectOption[]
|
||||
deviceModelOptions: AddLedger.SelectOption[]
|
||||
}>()
|
||||
@@ -71,7 +94,6 @@ const emit = defineEmits<{
|
||||
'update:form': [form: AddLedger.EquipmentForm]
|
||||
save: []
|
||||
delete: []
|
||||
'add-line': []
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
@@ -130,12 +152,13 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const formRules: FormRules<AddLedger.EquipmentForm> = {
|
||||
const isSimpleMode = computed(() => props.mode === 'simple')
|
||||
const formRules = computed<FormRules<AddLedger.EquipmentForm>>(() => ({
|
||||
name: [{ required: true, message: '请输入装置名称', trigger: 'blur' }],
|
||||
ndid: [{ required: true, message: '请输入网络设备 ID', trigger: 'blur' }],
|
||||
...(isSimpleMode.value ? {} : { ndid: [{ required: true, message: '请输入网络设备 ID', trigger: 'blur' }] }),
|
||||
mac: [{ required: true, message: '请输入装置 MAC 地址', trigger: 'blur' }],
|
||||
dev_model: [{ required: true, message: '请选择装置型号', trigger: 'change' }]
|
||||
}
|
||||
}))
|
||||
|
||||
const validateForm = async () => {
|
||||
const result = await formRef.value?.validate().catch(() => false)
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="ledger-context">
|
||||
<EngineeringForm
|
||||
v-if="ledgerContext.engineering"
|
||||
ref="engineeringFormRef"
|
||||
v-model:form="engineeringFormModel"
|
||||
:saving="saving"
|
||||
:mode="mode"
|
||||
:readonly="!isEditableContextItem(0, ledgerContext.engineering)"
|
||||
@save="emit('save-engineering')"
|
||||
@delete="emit('delete')"
|
||||
/>
|
||||
|
||||
<el-tabs
|
||||
v-if="ledgerContext.engineering || ledgerContext.projects.length"
|
||||
v-model="projectActiveTab"
|
||||
addable
|
||||
class="ledger-level-tabs"
|
||||
@tab-click="pane => handleContextTabClick(pane, 1)"
|
||||
@tab-add="emit('add-project')"
|
||||
>
|
||||
<template #add-icon>
|
||||
<el-tooltip content="新增项目" placement="top">
|
||||
<el-icon class="ledger-tab-add-icon">
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<el-tab-pane v-for="item in ledgerContext.projects" :key="item.id" :label="item.label" :name="item.id">
|
||||
<ProjectForm
|
||||
v-if="isEditableContextItem(1, item)"
|
||||
:ref="value => setProjectFormRef(value, item)"
|
||||
v-model:form="projectFormModel"
|
||||
:saving="saving"
|
||||
:mode="mode"
|
||||
@save="emit('save-project')"
|
||||
@delete="emit('delete')"
|
||||
/>
|
||||
<ProjectForm v-else v-model:form="item.form" :saving="saving" :mode="mode" readonly />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-tabs
|
||||
v-if="ledgerContext.projects.length || ledgerContext.equipments.length"
|
||||
v-model="equipmentActiveTab"
|
||||
addable
|
||||
class="ledger-level-tabs"
|
||||
@tab-click="pane => handleContextTabClick(pane, 2)"
|
||||
@tab-add="emit('add-equipment')"
|
||||
>
|
||||
<template #add-icon>
|
||||
<el-tooltip content="新增设备" placement="top">
|
||||
<el-icon class="ledger-tab-add-icon">
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<el-tab-pane v-for="item in ledgerContext.equipments" :key="item.id" :label="item.label" :name="item.id">
|
||||
<EquipmentForm
|
||||
v-if="isEditableContextItem(2, item)"
|
||||
:ref="value => setEquipmentFormRef(value, item)"
|
||||
v-model:form="equipmentFormModel"
|
||||
:saving="saving"
|
||||
:device-type-options="deviceTypeOptions"
|
||||
:device-model-options="deviceModelOptions"
|
||||
:mode="mode"
|
||||
@save="emit('save-equipment')"
|
||||
@delete="emit('delete')"
|
||||
/>
|
||||
<EquipmentForm
|
||||
v-else
|
||||
v-model:form="item.form"
|
||||
:saving="saving"
|
||||
:device-type-options="deviceTypeOptions"
|
||||
:device-model-options="deviceModelOptions"
|
||||
:mode="mode"
|
||||
readonly
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-tabs
|
||||
v-if="ledgerContext.equipments.length || ledgerContext.lines.length"
|
||||
v-model="lineActiveTab"
|
||||
addable
|
||||
class="ledger-level-tabs"
|
||||
@tab-click="pane => handleContextTabClick(pane, 3)"
|
||||
@tab-add="emit('add-line')"
|
||||
>
|
||||
<template #add-icon>
|
||||
<el-tooltip content="新增测点" placement="top">
|
||||
<el-icon class="ledger-tab-add-icon">
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<el-tab-pane v-for="item in ledgerContext.lines" :key="item.id" :label="item.label" :name="item.id">
|
||||
<LineForm
|
||||
v-if="isEditableContextItem(3, item)"
|
||||
:ref="value => setLineFormRef(value, item)"
|
||||
v-model:form="lineFormModel"
|
||||
:saving="saving"
|
||||
:line-no-options="lineNoOptions"
|
||||
:mode="mode"
|
||||
@save="emit('save-line')"
|
||||
@delete="emit('delete')"
|
||||
/>
|
||||
<LineForm
|
||||
v-else
|
||||
v-model:form="item.form"
|
||||
:saving="saving"
|
||||
:line-no-options="allLineNoOptions"
|
||||
:mode="mode"
|
||||
readonly
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { CirclePlus } from '@element-plus/icons-vue'
|
||||
import type { TabsPaneContext } from 'element-plus'
|
||||
import type { AddLedger } from '@/api/tools/addLedger/interface'
|
||||
import EngineeringForm from './EngineeringForm.vue'
|
||||
import ProjectForm from './ProjectForm.vue'
|
||||
import EquipmentForm from './EquipmentForm.vue'
|
||||
import LineForm from './LineForm.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'LedgerContextPanel'
|
||||
})
|
||||
|
||||
type FormExpose = {
|
||||
validateForm: () => Promise<boolean>
|
||||
}
|
||||
|
||||
type LedgerContextItem<T> = {
|
||||
id: string
|
||||
label: string
|
||||
level: AddLedger.NodeLevel
|
||||
form: T
|
||||
node?: AddLedger.NormalizedTreeNode
|
||||
draft?: boolean
|
||||
}
|
||||
|
||||
type LedgerContextState = {
|
||||
engineering: LedgerContextItem<AddLedger.EngineeringForm> | null
|
||||
projects: LedgerContextItem<AddLedger.ProjectForm>[]
|
||||
equipments: LedgerContextItem<AddLedger.EquipmentForm>[]
|
||||
lines: LedgerContextItem<AddLedger.LineForm>[]
|
||||
}
|
||||
|
||||
type ActiveTabIds = {
|
||||
project: string
|
||||
equipment: string
|
||||
line: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
ledgerContext: LedgerContextState
|
||||
activeTabIds: ActiveTabIds
|
||||
activeLevel: AddLedger.NodeLevel | null
|
||||
selectedId: string
|
||||
engineeringForm: AddLedger.EngineeringForm
|
||||
projectForm: AddLedger.ProjectForm
|
||||
equipmentForm: AddLedger.EquipmentForm
|
||||
lineForm: AddLedger.LineForm
|
||||
saving: boolean
|
||||
deviceTypeOptions: AddLedger.SelectOption[]
|
||||
deviceModelOptions: AddLedger.SelectOption[]
|
||||
lineNoOptions: AddLedger.SelectOption<number>[]
|
||||
allLineNoOptions: AddLedger.SelectOption<number>[]
|
||||
mode: 'simple' | 'full'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:engineeringForm': [form: AddLedger.EngineeringForm]
|
||||
'update:projectForm': [form: AddLedger.ProjectForm]
|
||||
'update:equipmentForm': [form: AddLedger.EquipmentForm]
|
||||
'update:lineForm': [form: AddLedger.LineForm]
|
||||
'save-engineering': []
|
||||
'save-project': []
|
||||
'save-equipment': []
|
||||
'save-line': []
|
||||
delete: []
|
||||
'add-project': []
|
||||
'add-equipment': []
|
||||
'add-line': []
|
||||
'tab-click': [id: string, level: AddLedger.NodeLevel]
|
||||
'update-active-tab': [key: keyof ActiveTabIds, id: string]
|
||||
}>()
|
||||
|
||||
const engineeringFormRef = ref<FormExpose>()
|
||||
const projectFormRef = ref<FormExpose>()
|
||||
const equipmentFormRef = ref<FormExpose>()
|
||||
const lineFormRef = ref<FormExpose>()
|
||||
|
||||
const engineeringFormModel = computed({
|
||||
get: () => props.engineeringForm,
|
||||
set: value => emit('update:engineeringForm', value)
|
||||
})
|
||||
|
||||
const projectFormModel = computed({
|
||||
get: () => props.projectForm,
|
||||
set: value => emit('update:projectForm', value)
|
||||
})
|
||||
|
||||
const equipmentFormModel = computed({
|
||||
get: () => props.equipmentForm,
|
||||
set: value => emit('update:equipmentForm', value)
|
||||
})
|
||||
|
||||
const lineFormModel = computed({
|
||||
get: () => props.lineForm,
|
||||
set: value => emit('update:lineForm', value)
|
||||
})
|
||||
|
||||
const projectActiveTab = computed({
|
||||
get: () => props.activeTabIds.project,
|
||||
set: value => emit('update-active-tab', 'project', String(value || ''))
|
||||
})
|
||||
|
||||
const equipmentActiveTab = computed({
|
||||
get: () => props.activeTabIds.equipment,
|
||||
set: value => emit('update-active-tab', 'equipment', String(value || ''))
|
||||
})
|
||||
|
||||
const lineActiveTab = computed({
|
||||
get: () => props.activeTabIds.line,
|
||||
set: value => emit('update-active-tab', 'line', String(value || ''))
|
||||
})
|
||||
|
||||
const isEditableContextItem = (level: AddLedger.NodeLevel, item?: LedgerContextItem<unknown> | null) => {
|
||||
if (!item || props.activeLevel !== level) return false
|
||||
if (item.draft) return true
|
||||
return Boolean(props.selectedId && props.selectedId === item.id)
|
||||
}
|
||||
|
||||
const setProjectFormRef = (value: unknown, item: LedgerContextItem<AddLedger.ProjectForm>) => {
|
||||
if (isEditableContextItem(1, item)) {
|
||||
projectFormRef.value = value as FormExpose | undefined
|
||||
}
|
||||
}
|
||||
|
||||
const setEquipmentFormRef = (value: unknown, item: LedgerContextItem<AddLedger.EquipmentForm>) => {
|
||||
if (isEditableContextItem(2, item)) {
|
||||
equipmentFormRef.value = value as FormExpose | undefined
|
||||
}
|
||||
}
|
||||
|
||||
const setLineFormRef = (value: unknown, item: LedgerContextItem<AddLedger.LineForm>) => {
|
||||
if (isEditableContextItem(3, item)) {
|
||||
lineFormRef.value = value as FormExpose | undefined
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextTabClick = (pane: TabsPaneContext, level: AddLedger.NodeLevel) => {
|
||||
const id = String(pane.paneName || '')
|
||||
if (!id || id.startsWith('__draft_')) return
|
||||
|
||||
emit('tab-click', id, level)
|
||||
}
|
||||
|
||||
const validateActiveForm = async (level: AddLedger.NodeLevel | null) => {
|
||||
if (level === 0) return Boolean(await engineeringFormRef.value?.validateForm())
|
||||
if (level === 1) return Boolean(await projectFormRef.value?.validateForm())
|
||||
if (level === 2) return Boolean(await equipmentFormRef.value?.validateForm())
|
||||
if (level === 3) return Boolean(await lineFormRef.value?.validateForm())
|
||||
return false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validateActiveForm
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ledger-context {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ledger-level-tabs {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ledger-level-tabs :deep(.el-tabs__header) {
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ledger-level-tabs :deep(.el-tabs__nav-wrap) {
|
||||
flex: 0 1 auto;
|
||||
max-width: calc(100% - 36px);
|
||||
}
|
||||
|
||||
.ledger-level-tabs :deep(.el-tabs__nav-scroll) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ledger-level-tabs :deep(.el-tabs__new-tab) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 0 0 10px;
|
||||
color: var(--el-color-white);
|
||||
background-color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.ledger-level-tabs :deep(.el-tabs__new-tab:hover) {
|
||||
color: var(--el-color-white);
|
||||
background-color: var(--el-color-primary-light-3);
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
}
|
||||
|
||||
.ledger-tab-add-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ledger-level-tabs :deep(.el-tabs__content) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ledger-context :deep(.ledger-form-card) {
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,18 @@
|
||||
<div class="ledger-tree-header">
|
||||
<div class="section-title">台账树</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" :icon="Plus" @click="emit('add-engineering')">新增工程</el-button>
|
||||
<el-button type="primary" plain :icon="Refresh" :loading="loading" @click="emit('refresh')">刷新</el-button>
|
||||
<el-tooltip v-if="treeData.length === 0" content="新增工程" placement="top">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
circle
|
||||
aria-label="新增工程"
|
||||
@click="emit('add-engineering')"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-button type="primary" plain :icon="Refresh" :loading="loading" @click="emit('refresh')">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,11 +34,35 @@
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="ledger-tree-node">
|
||||
<div class="node-main">
|
||||
<el-tag size="small" effect="plain" :type="resolveTagType(data.level)">
|
||||
{{ resolveLevelName(data.level) }}
|
||||
</el-tag>
|
||||
<span class="node-name">{{ data.name }}</span>
|
||||
</div>
|
||||
<div class="node-actions">
|
||||
<el-tooltip v-if="normalizeLevel(data.level) === 0" content="新增工程" placement="top">
|
||||
<el-button
|
||||
class="node-action"
|
||||
type="primary"
|
||||
link
|
||||
:icon="Plus"
|
||||
aria-label="新增工程"
|
||||
@click.stop="emit('add-engineering')"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="`删除${resolveLevelName(data.level)}`" placement="top">
|
||||
<el-button
|
||||
class="node-action"
|
||||
type="danger"
|
||||
link
|
||||
:icon="Delete"
|
||||
:aria-label="`删除${resolveLevelName(data.level)}`"
|
||||
@click.stop="emit('delete-node', data)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-scrollbar>
|
||||
@@ -37,7 +71,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import { Delete, Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import type { TreeInstance } from 'element-plus'
|
||||
import type { AddLedger } from '@/api/tools/addLedger/interface'
|
||||
|
||||
@@ -55,6 +89,7 @@ const emit = defineEmits<{
|
||||
select: [node: AddLedger.NormalizedTreeNode]
|
||||
refresh: []
|
||||
'add-engineering': []
|
||||
'delete-node': [node: AddLedger.NormalizedTreeNode]
|
||||
}>()
|
||||
|
||||
const treeRef = ref<TreeInstance>()
|
||||
@@ -148,7 +183,15 @@ watch(
|
||||
}
|
||||
|
||||
.header-actions :deep(.el-button) {
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
margin-left: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-actions :deep(.el-button.is-circle) {
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tree-search {
|
||||
@@ -160,7 +203,21 @@ watch(
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree-scrollbar :deep(.el-scrollbar__view) {
|
||||
box-sizing: border-box;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.ledger-tree-node {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-main {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
@@ -172,4 +229,33 @@ watch(
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-right: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.node-action {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ledger-tree-node:hover .node-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__content > .el-tree-node__expand-icon + .ledger-tree-node) {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,47 +3,100 @@
|
||||
<div class="form-header">
|
||||
<div>
|
||||
<div class="section-title">监测点配置</div>
|
||||
<div class="section-description">维护线路号、接线方式、电压等级和 CT/PT 参数。</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<div v-if="!readonly" class="form-actions">
|
||||
<el-button type="primary" :icon="Check" :loading="saving" @click="emit('save')">保存测点</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" :disabled="!lineId" @click="emit('delete')">删除测点</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" :disabled="!lineId" @click="emit('delete')">
|
||||
删除测点
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="146px" class="ledger-form form-two">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="localForm"
|
||||
:rules="formRules"
|
||||
:disabled="readonly"
|
||||
label-width="146px"
|
||||
:class="['ledger-form', isSimpleMode ? 'form-simple' : 'form-three']"
|
||||
>
|
||||
<el-form-item label="监测点名" prop="name">
|
||||
<el-input v-model="localForm.name" maxlength="80" clearable placeholder="请输入监测点名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备线路" prop="line_no">
|
||||
<el-select v-model="localForm.line_no" clearable placeholder="请选择设备线路">
|
||||
<el-option v-for="item in lineNoOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<el-option
|
||||
v-for="item in lineNoOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="接线方式" prop="conType">
|
||||
<el-select v-model="localForm.conType" clearable placeholder="请选择接线方式">
|
||||
<el-option v-for="item in conTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<el-option
|
||||
v-for="item in conTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="电压等级" prop="vol_grade">
|
||||
<el-select v-model="localForm.vol_grade" clearable placeholder="请选择电压等级">
|
||||
<el-option v-for="item in voltageOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<el-option
|
||||
v-for="item in voltageOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="安装位置" prop="position">
|
||||
<el-form-item v-if="!isSimpleMode" label="安装位置" prop="position">
|
||||
<el-input v-model="localForm.position" maxlength="80" clearable placeholder="请输入安装位置" />
|
||||
</el-form-item>
|
||||
<el-form-item label="CT 一次额定值" prop="ct_ratio">
|
||||
<el-input-number v-model="localForm.ct_ratio" :min="0" :precision="3" controls-position="right" />
|
||||
<el-form-item label="CT变比" class="ratio-form-item">
|
||||
<div class="ratio-input-group">
|
||||
<el-form-item prop="ct_ratio" class="ratio-field">
|
||||
<el-input-number
|
||||
v-model="localForm.ct_ratio"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="CT 二次额定值" prop="ct2_ratio">
|
||||
<el-input-number v-model="localForm.ct2_ratio" :min="0" :precision="3" controls-position="right" />
|
||||
<span class="ratio-separator">:</span>
|
||||
<el-form-item prop="ct2_ratio" class="ratio-field">
|
||||
<el-input-number
|
||||
v-model="localForm.ct2_ratio"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="PT 一次额定值" prop="pt_ratio">
|
||||
<el-input-number v-model="localForm.pt_ratio" :min="0" :precision="3" controls-position="right" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="PT 二次额定值" prop="pt2_ratio">
|
||||
<el-input-number v-model="localForm.pt2_ratio" :min="0" :precision="3" controls-position="right" />
|
||||
<el-form-item label="PT变比" class="ratio-form-item">
|
||||
<div class="ratio-input-group">
|
||||
<el-form-item prop="pt_ratio" class="ratio-field">
|
||||
<el-input-number
|
||||
v-model="localForm.pt_ratio"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<span class="ratio-separator">:</span>
|
||||
<el-form-item prop="pt2_ratio" class="ratio-field">
|
||||
<el-input-number
|
||||
v-model="localForm.pt2_ratio"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最小短路容量(MVA)" prop="short_circuit_capacity">
|
||||
<el-input-number
|
||||
@@ -57,21 +110,32 @@
|
||||
<el-input-number v-model="localForm.basic_capacity" :min="0" :precision="3" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户协议容量(MVA)" prop="protocol_capacity">
|
||||
<el-input-number v-model="localForm.protocol_capacity" :min="0" :precision="3" controls-position="right" />
|
||||
<el-input-number
|
||||
v-model="localForm.protocol_capacity"
|
||||
:min="0"
|
||||
:precision="3"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="供电设备容量(MVA)" prop="dev_capacity">
|
||||
<el-input-number v-model="localForm.dev_capacity" :min="0" :precision="3" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="监测对象类型" prop="monitor_obj">
|
||||
<el-form-item v-if="!isSimpleMode" label="监测对象类型" prop="monitor_obj">
|
||||
<el-input v-model="localForm.monitor_obj" maxlength="80" clearable placeholder="请输入监测对象类型" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否治理" prop="is_govern">
|
||||
<el-switch v-model="localForm.is_govern" :active-value="1" :inactive-value="0" active-text="是" inactive-text="否" />
|
||||
<el-form-item v-if="!isSimpleMode" label="是否治理" prop="is_govern">
|
||||
<el-switch
|
||||
v-model="localForm.is_govern"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="敏感用户" prop="monitor_user">
|
||||
<el-form-item v-if="!isSimpleMode" label="敏感用户" prop="monitor_user">
|
||||
<el-input v-model="localForm.monitor_user" maxlength="80" clearable placeholder="请输入敏感用户 ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主要监测点" prop="is_important">
|
||||
<el-form-item v-if="!isSimpleMode" label="主要监测点" prop="is_important">
|
||||
<el-switch
|
||||
v-model="localForm.is_important"
|
||||
:active-value="1"
|
||||
@@ -97,6 +161,8 @@ defineOptions({
|
||||
const props = defineProps<{
|
||||
form: AddLedger.LineForm
|
||||
saving: boolean
|
||||
mode?: 'simple' | 'full'
|
||||
readonly?: boolean
|
||||
lineNoOptions: AddLedger.SelectOption<number>[]
|
||||
}>()
|
||||
|
||||
@@ -148,6 +214,7 @@ const voltageOptions: AddLedger.SelectOption<number>[] = [
|
||||
]
|
||||
|
||||
const lineId = computed(() => localForm.line_id || localForm.id || '')
|
||||
const isSimpleMode = computed(() => props.mode === 'simple')
|
||||
|
||||
const syncLocalForm = (form: AddLedger.LineForm) => {
|
||||
localForm.id = form.id || ''
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
<div class="form-header">
|
||||
<div>
|
||||
<div class="section-title">项目配置</div>
|
||||
<div class="section-description">维护项目相对位置和说明,并从项目下继续新增设备。</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" :icon="CirclePlus" @click="emit('add-equipment')">新增设备</el-button>
|
||||
<div v-if="!readonly" class="form-actions">
|
||||
<el-button type="primary" :icon="Check" :loading="saving" @click="emit('save')">保存项目</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" :disabled="!localForm.id" @click="emit('delete')">
|
||||
删除项目
|
||||
@@ -14,21 +12,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="96px" class="ledger-form form-two">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="localForm"
|
||||
:rules="formRules"
|
||||
:disabled="readonly"
|
||||
label-width="96px"
|
||||
:class="['ledger-form', isSimpleMode ? 'form-simple' : 'form-three']"
|
||||
>
|
||||
<el-form-item label="项目名称" prop="name">
|
||||
<el-input v-model="localForm.name" maxlength="80" clearable placeholder="请输入项目名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="相对位置" prop="area">
|
||||
<el-form-item v-if="!isSimpleMode" label="相对位置" prop="area">
|
||||
<el-input v-model="localForm.area" maxlength="80" clearable placeholder="请输入相对位置" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目描述" prop="description">
|
||||
<el-form-item label="描述" prop="description" class="form-item-wide">
|
||||
<el-input
|
||||
v-model="localForm.description"
|
||||
class="description-textarea"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:rows="1"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入项目描述"
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -36,8 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Check, CirclePlus, Delete } from '@element-plus/icons-vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Check, Delete } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type { AddLedger } from '@/api/tools/addLedger/interface'
|
||||
|
||||
@@ -48,13 +54,14 @@ defineOptions({
|
||||
const props = defineProps<{
|
||||
form: AddLedger.ProjectForm
|
||||
saving: boolean
|
||||
mode?: 'simple' | 'full'
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:form': [form: AddLedger.ProjectForm]
|
||||
save: []
|
||||
delete: []
|
||||
'add-equipment': []
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
@@ -103,6 +110,8 @@ const formRules: FormRules<AddLedger.ProjectForm> = {
|
||||
name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const isSimpleMode = computed(() => props.mode === 'simple')
|
||||
|
||||
const validateForm = async () => {
|
||||
const result = await formRef.value?.validate().catch(() => false)
|
||||
return Boolean(result)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
.ledger-form-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 5px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
padding: 10px 14px 5px;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
padding-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -22,40 +22,110 @@
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-actions :deep(.el-button) {
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
margin-left: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ledger-form {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ledger-form :deep(.el-form-item) {
|
||||
min-width: 0;
|
||||
margin-right: 0 !important;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ledger-form :deep(.el-form-item__label) {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.ledger-form :deep(.el-form-item__content) {
|
||||
min-width: 0;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.ledger-form.form-three,
|
||||
.ledger-form.form-simple {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 5px 14px;
|
||||
}
|
||||
|
||||
.ledger-form.form-three :deep(.el-form-item),
|
||||
.ledger-form.form-simple :deep(.el-form-item) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ledger-form :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ledger-form :deep(.el-input),
|
||||
.ledger-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ledger-form :deep(.ratio-form-item > .el-form-item__content) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ratio-input-group {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ratio-input-group :deep(.ratio-field) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ratio-input-group :deep(.ratio-field .el-form-item__content) {
|
||||
display: block;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.ratio-separator {
|
||||
flex: 0 0 auto;
|
||||
line-height: 32px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.ledger-form :deep(.el-textarea) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ledger-form.form-two :deep(.el-form-item) {
|
||||
width: 98%;
|
||||
.ledger-form :deep(.description-textarea .el-textarea__inner) {
|
||||
height: 48px;
|
||||
min-height: 48px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.ledger-form.form-three,
|
||||
.ledger-form.form-simple {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ledger-form.form-three,
|
||||
.ledger-form.form-simple {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,64 +8,66 @@
|
||||
@select="handleSelectNode"
|
||||
@refresh="loadTree"
|
||||
@add-engineering="handleAddEngineering"
|
||||
@delete-node="handleDeleteNode"
|
||||
/>
|
||||
|
||||
<section class="add-ledger-main">
|
||||
<el-alert
|
||||
v-if="loading.detail"
|
||||
class="detail-alert"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="正在加载节点详情"
|
||||
/>
|
||||
<div class="add-ledger-toolbar">
|
||||
<el-button-group>
|
||||
<el-button
|
||||
type="primary"
|
||||
:plain="ledgerFormMode !== 'simple'"
|
||||
@click="ledgerFormMode = 'simple'"
|
||||
>
|
||||
简化
|
||||
</el-button>
|
||||
<el-button type="primary" :plain="ledgerFormMode !== 'full'" @click="ledgerFormMode = 'full'">
|
||||
完整
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<EngineeringForm
|
||||
v-if="activeLevel === 0"
|
||||
ref="engineeringFormRef"
|
||||
v-model:form="engineeringForm"
|
||||
:saving="loading.saving"
|
||||
@save="handleSaveEngineering"
|
||||
@delete="handleDeleteNode"
|
||||
@add-project="handleAddProject"
|
||||
/>
|
||||
|
||||
<ProjectForm
|
||||
v-else-if="activeLevel === 1"
|
||||
ref="projectFormRef"
|
||||
v-model:form="projectForm"
|
||||
:saving="loading.saving"
|
||||
@save="handleSaveProject"
|
||||
@delete="handleDeleteNode"
|
||||
@add-equipment="handleAddEquipment"
|
||||
/>
|
||||
|
||||
<EquipmentForm
|
||||
v-else-if="activeLevel === 2"
|
||||
ref="equipmentFormRef"
|
||||
v-model:form="equipmentForm"
|
||||
<LedgerContextPanel
|
||||
v-if="hasLedgerContext"
|
||||
ref="ledgerContextPanelRef"
|
||||
v-model:engineering-form="engineeringForm"
|
||||
v-model:project-form="projectForm"
|
||||
v-model:equipment-form="equipmentForm"
|
||||
v-model:line-form="lineForm"
|
||||
:ledger-context="ledgerContext"
|
||||
:active-tab-ids="activeTabIds"
|
||||
:active-level="activeLevel"
|
||||
:selected-id="selectedNode?.id || ''"
|
||||
:saving="loading.saving"
|
||||
:device-type-options="deviceTypeOptions"
|
||||
:device-model-options="deviceModelOptions"
|
||||
@save="handleSaveEquipment"
|
||||
@delete="handleDeleteNode"
|
||||
@add-line="handleAddLine"
|
||||
/>
|
||||
|
||||
<LineForm
|
||||
v-else-if="activeLevel === 3"
|
||||
ref="lineFormRef"
|
||||
v-model:form="lineForm"
|
||||
:saving="loading.saving"
|
||||
:line-no-options="lineNoOptions"
|
||||
@save="handleSaveLine"
|
||||
:all-line-no-options="allLineNoOptions"
|
||||
:mode="ledgerFormMode"
|
||||
@save-engineering="handleSaveEngineering"
|
||||
@save-project="handleSaveProject"
|
||||
@save-equipment="handleSaveEquipment"
|
||||
@save-line="handleSaveLine"
|
||||
@delete="handleDeleteNode"
|
||||
@add-project="handleAddProject"
|
||||
@add-equipment="handleAddEquipment"
|
||||
@add-line="handleAddLine"
|
||||
@tab-click="handleContextTabClick"
|
||||
@update-active-tab="handleActiveTabUpdate"
|
||||
/>
|
||||
|
||||
<div v-else class="card add-ledger-empty">
|
||||
<div class="empty-title">请选择台账节点</div>
|
||||
<div class="empty-text">从左侧台账树选择工程、项目、设备或监测点,或先新增一个工程。</div>
|
||||
<el-button type="primary" :icon="CirclePlus" @click="handleAddEngineering">新增工程</el-button>
|
||||
<div class="empty-text">{{ emptyStateText }}</div>
|
||||
<el-tooltip v-if="treeData.length === 0" content="新增工程" placement="top">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="CirclePlus"
|
||||
circle
|
||||
aria-label="新增工程"
|
||||
@click="handleAddEngineering"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -89,24 +91,40 @@ import {
|
||||
import type { AddLedger } from '@/api/tools/addLedger/interface'
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
import LedgerTreePanel from './components/LedgerTreePanel.vue'
|
||||
import EngineeringForm from './components/EngineeringForm.vue'
|
||||
import ProjectForm from './components/ProjectForm.vue'
|
||||
import EquipmentForm from './components/EquipmentForm.vue'
|
||||
import LineForm from './components/LineForm.vue'
|
||||
import LedgerContextPanel from './components/LedgerContextPanel.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'AddLedgerView'
|
||||
})
|
||||
|
||||
type FormExpose = {
|
||||
validateForm: () => Promise<boolean>
|
||||
type LedgerContextPanelExpose = {
|
||||
validateActiveForm: (level: AddLedger.NodeLevel | null) => Promise<boolean>
|
||||
}
|
||||
|
||||
type LedgerContextIds = {
|
||||
engineeringId: string
|
||||
projectId: string
|
||||
deviceId: string
|
||||
}
|
||||
|
||||
type LedgerContextItem<T> = {
|
||||
id: string
|
||||
label: string
|
||||
level: AddLedger.NodeLevel
|
||||
form: T
|
||||
node?: AddLedger.NormalizedTreeNode
|
||||
draft?: boolean
|
||||
}
|
||||
|
||||
type LedgerDictCode = 'ledger_device_type' | 'ledger_device_model'
|
||||
|
||||
const dictStore = useDictStore()
|
||||
const treeData = ref<AddLedger.NormalizedTreeNode[]>([])
|
||||
const selectedNode = ref<AddLedger.NormalizedTreeNode | null>(null)
|
||||
const activeLevel = ref<AddLedger.NodeLevel | null>(null)
|
||||
const lineNoOptions = ref<AddLedger.SelectOption<number>[]>([])
|
||||
// 简化模式先收敛工程字段,项目、设备、测点后续按该模式继续精简。
|
||||
const ledgerFormMode = ref<'simple' | 'full'>('simple')
|
||||
const loading = reactive({
|
||||
tree: false,
|
||||
detail: false,
|
||||
@@ -114,16 +132,39 @@ const loading = reactive({
|
||||
lineNos: false
|
||||
})
|
||||
|
||||
const engineeringFormRef = ref<FormExpose>()
|
||||
const projectFormRef = ref<FormExpose>()
|
||||
const equipmentFormRef = ref<FormExpose>()
|
||||
const lineFormRef = ref<FormExpose>()
|
||||
const ledgerContextPanelRef = ref<LedgerContextPanelExpose>()
|
||||
|
||||
const engineeringForm = ref<AddLedger.EngineeringForm>(createEmptyEngineeringForm())
|
||||
const projectForm = ref<AddLedger.ProjectForm>(createEmptyProjectForm())
|
||||
const equipmentForm = ref<AddLedger.EquipmentForm>(createEmptyEquipmentForm())
|
||||
const lineForm = ref<AddLedger.LineForm>(createEmptyLineForm())
|
||||
|
||||
const draftIds = {
|
||||
engineering: '__draft_engineering__',
|
||||
project: '__draft_project__',
|
||||
equipment: '__draft_equipment__',
|
||||
line: '__draft_line__'
|
||||
}
|
||||
|
||||
const ledgerContext = reactive({
|
||||
engineering: null as LedgerContextItem<AddLedger.EngineeringForm> | null,
|
||||
projects: [] as LedgerContextItem<AddLedger.ProjectForm>[],
|
||||
equipments: [] as LedgerContextItem<AddLedger.EquipmentForm>[],
|
||||
lines: [] as LedgerContextItem<AddLedger.LineForm>[]
|
||||
})
|
||||
|
||||
const activeTabIds = reactive({
|
||||
project: '',
|
||||
equipment: '',
|
||||
line: ''
|
||||
})
|
||||
const ledgerDictOptions = reactive<Record<LedgerDictCode, AddLedger.SelectOption[]>>({
|
||||
ledger_device_type: [],
|
||||
ledger_device_model: []
|
||||
})
|
||||
|
||||
let detailRequestSeq = 0
|
||||
|
||||
const fallbackDeviceTypeOptions: AddLedger.SelectOption[] = [
|
||||
{ label: '直连设备', value: 'direct_device' },
|
||||
{ label: '网关', value: 'gateway' },
|
||||
@@ -137,6 +178,9 @@ const fallbackDeviceModelOptions: AddLedger.SelectOption[] = [
|
||||
|
||||
const deviceTypeOptions = computed(() => resolveDictOptions('ledger_device_type', fallbackDeviceTypeOptions))
|
||||
const deviceModelOptions = computed(() => resolveDictOptions('ledger_device_model', fallbackDeviceModelOptions))
|
||||
const emptyStateText = computed(() =>
|
||||
treeData.value.length === 0 ? '台账树为空,请先新增一个工程。' : '从左侧台账树选择工程、项目、设备或监测点。'
|
||||
)
|
||||
|
||||
function createEmptyEngineeringForm(): AddLedger.EngineeringForm {
|
||||
return {
|
||||
@@ -203,9 +247,12 @@ function createEmptyLineForm(parentDeviceId = ''): AddLedger.LineForm {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDictOptions = (code: string, fallback: AddLedger.SelectOption[]) => {
|
||||
const resolveFallbackDictOptions = (code: LedgerDictCode) =>
|
||||
code === 'ledger_device_type' ? fallbackDeviceTypeOptions : fallbackDeviceModelOptions
|
||||
|
||||
const resolveCachedDictOptions = (code: LedgerDictCode) => {
|
||||
const dictData = dictStore.getDictData(code)
|
||||
if (!dictData.length) return fallback
|
||||
if (!dictData.length) return []
|
||||
|
||||
return dictData.map(item => ({
|
||||
label: item.name,
|
||||
@@ -213,6 +260,57 @@ const resolveDictOptions = (code: string, fallback: AddLedger.SelectOption[]) =>
|
||||
}))
|
||||
}
|
||||
|
||||
const resolveDictOptions = (code: LedgerDictCode, fallback: AddLedger.SelectOption[]) => {
|
||||
if (ledgerDictOptions[code].length) return ledgerDictOptions[code]
|
||||
|
||||
const cachedOptions = resolveCachedDictOptions(code)
|
||||
return cachedOptions.length ? cachedOptions : fallback
|
||||
}
|
||||
|
||||
const appendLedgerDictOption = (code: LedgerDictCode, option: AddLedger.SelectOption) => {
|
||||
const currentOptions = ledgerDictOptions[code].length ? ledgerDictOptions[code] : resolveCachedDictOptions(code)
|
||||
const baseOptions = currentOptions.length ? currentOptions : resolveFallbackDictOptions(code)
|
||||
const optionValue = String(option.value)
|
||||
|
||||
if (baseOptions.some(item => String(item.value) === optionValue)) return
|
||||
|
||||
ledgerDictOptions[code] = [...baseOptions, option]
|
||||
}
|
||||
|
||||
const ensureLedgerDictOptionById = async (code: LedgerDictCode, value: string) => {
|
||||
if (!value) return
|
||||
|
||||
const currentOptions = resolveDictOptions(code, resolveFallbackDictOptions(code))
|
||||
if (currentOptions.some(item => String(item.value) === value)) return
|
||||
|
||||
appendLedgerDictOption(code, {
|
||||
label: value,
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
const ensureEquipmentDictOptions = async (form: AddLedger.EquipmentForm) => {
|
||||
await Promise.all([
|
||||
ensureLedgerDictOptionById('ledger_device_type', form.dev_type || ''),
|
||||
ensureLedgerDictOptionById('ledger_device_model', form.dev_model || '')
|
||||
])
|
||||
}
|
||||
|
||||
const loadLedgerDictOptionsByCode = (code: LedgerDictCode) => {
|
||||
const cachedOptions = resolveCachedDictOptions(code)
|
||||
if (cachedOptions.length) {
|
||||
ledgerDictOptions[code] = cachedOptions
|
||||
return
|
||||
}
|
||||
|
||||
ledgerDictOptions[code] = resolveFallbackDictOptions(code)
|
||||
}
|
||||
|
||||
const loadLedgerDictOptions = async () => {
|
||||
loadLedgerDictOptionsByCode('ledger_device_type')
|
||||
loadLedgerDictOptionsByCode('ledger_device_model')
|
||||
}
|
||||
|
||||
const resolveString = (data: Record<string, unknown>, ...keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
const value = data[key]
|
||||
@@ -281,9 +379,7 @@ const getCurrentPath = () => {
|
||||
return findNodePath(treeData.value, selectedNode.value.id)
|
||||
}
|
||||
|
||||
const resolveContext = () => {
|
||||
const path = getCurrentPath()
|
||||
|
||||
const resolveContextFromPath = (path: AddLedger.NormalizedTreeNode[]): LedgerContextIds => {
|
||||
return {
|
||||
engineeringId: path.find(item => item.level === 0)?.id || '',
|
||||
projectId: path.find(item => item.level === 1)?.id || '',
|
||||
@@ -291,6 +387,12 @@ const resolveContext = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveContext = () => {
|
||||
const path = getCurrentPath()
|
||||
|
||||
return resolveContextFromPath(path)
|
||||
}
|
||||
|
||||
function generateGuidText() {
|
||||
return window.crypto.randomUUID().replace(/-/g, '')
|
||||
}
|
||||
@@ -312,10 +414,10 @@ const normalizeEngineeringDetail = (
|
||||
|
||||
const normalizeProjectDetail = (
|
||||
detail: AddLedger.NodeDetail | null,
|
||||
node?: AddLedger.NormalizedTreeNode
|
||||
node?: AddLedger.NormalizedTreeNode,
|
||||
context = resolveContext()
|
||||
): AddLedger.ProjectForm => {
|
||||
const data = (detail || {}) as Record<string, unknown>
|
||||
const context = resolveContext()
|
||||
|
||||
return {
|
||||
id: resolveString(data, 'id', 'projectId') || node?.id || '',
|
||||
@@ -329,10 +431,10 @@ const normalizeProjectDetail = (
|
||||
|
||||
const normalizeEquipmentDetail = (
|
||||
detail: AddLedger.NodeDetail | null,
|
||||
node?: AddLedger.NormalizedTreeNode
|
||||
node?: AddLedger.NormalizedTreeNode,
|
||||
context = resolveContext()
|
||||
): AddLedger.EquipmentForm => {
|
||||
const data = (detail || {}) as Record<string, unknown>
|
||||
const context = resolveContext()
|
||||
|
||||
return {
|
||||
id: resolveString(data, 'id', 'equipmentId') || node?.id || '',
|
||||
@@ -351,9 +453,12 @@ const normalizeEquipmentDetail = (
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeLineDetail = (detail: AddLedger.NodeDetail | null, node?: AddLedger.NormalizedTreeNode): AddLedger.LineForm => {
|
||||
const normalizeLineDetail = (
|
||||
detail: AddLedger.NodeDetail | null,
|
||||
node?: AddLedger.NormalizedTreeNode,
|
||||
context = resolveContext()
|
||||
): AddLedger.LineForm => {
|
||||
const data = (detail || {}) as Record<string, unknown>
|
||||
const context = resolveContext()
|
||||
const lineId = resolveString(data, 'line_id', 'lineId', 'id') || node?.id || generateGuidText()
|
||||
|
||||
return {
|
||||
@@ -392,6 +497,8 @@ const buildLineNoOptions = (lineNos: number[], currentLineNo?: number) => {
|
||||
.map(item => ({ label: `${item} 号线路`, value: item }))
|
||||
}
|
||||
|
||||
const allLineNoOptions = computed(() => buildLineNoOptions(Array.from({ length: 20 }, (_item, index) => index + 1)))
|
||||
|
||||
const loadAvailableLineNoOptions = async (deviceId: string, lineId = '', currentLineNo?: number) => {
|
||||
if (!deviceId) {
|
||||
lineNoOptions.value = []
|
||||
@@ -408,11 +515,299 @@ const loadAvailableLineNoOptions = async (deviceId: string, lineId = '', current
|
||||
}
|
||||
}
|
||||
|
||||
const hasLedgerContext = computed(
|
||||
() =>
|
||||
Boolean(ledgerContext.engineering) ||
|
||||
ledgerContext.projects.length > 0 ||
|
||||
ledgerContext.equipments.length > 0 ||
|
||||
ledgerContext.lines.length > 0
|
||||
)
|
||||
|
||||
const resetLedgerContext = () => {
|
||||
ledgerContext.engineering = null
|
||||
ledgerContext.projects = []
|
||||
ledgerContext.equipments = []
|
||||
ledgerContext.lines = []
|
||||
activeTabIds.project = ''
|
||||
activeTabIds.equipment = ''
|
||||
activeTabIds.line = ''
|
||||
}
|
||||
|
||||
const createContextItem = <T,>(
|
||||
node: AddLedger.NormalizedTreeNode,
|
||||
form: T,
|
||||
label = node.name || '--'
|
||||
): LedgerContextItem<T> => ({
|
||||
id: node.id,
|
||||
label,
|
||||
level: node.level,
|
||||
form,
|
||||
node
|
||||
})
|
||||
|
||||
const createDraftContextItem = <T,>(
|
||||
id: string,
|
||||
label: string,
|
||||
level: AddLedger.NodeLevel,
|
||||
form: T
|
||||
): LedgerContextItem<T> => ({
|
||||
id,
|
||||
label,
|
||||
level,
|
||||
form,
|
||||
draft: true
|
||||
})
|
||||
|
||||
const resolveSavedContextItemId = (item?: LedgerContextItem<{ id?: string }> | null) => {
|
||||
if (!item || item.draft) return ''
|
||||
|
||||
return item.form.id || item.id
|
||||
}
|
||||
|
||||
const fetchNodeDetail = async (node: AddLedger.NormalizedTreeNode, activeDetail?: AddLedger.NodeDetail | null) => {
|
||||
if (selectedNode.value?.id === node.id && activeDetail !== undefined) {
|
||||
return activeDetail
|
||||
}
|
||||
|
||||
const response = await getAddLedgerDetail({ id: node.id, level: node.level })
|
||||
return response.data || null
|
||||
}
|
||||
|
||||
const loadProjectContextItems = async (
|
||||
nodes: AddLedger.NormalizedTreeNode[],
|
||||
context: LedgerContextIds,
|
||||
activeDetail?: AddLedger.NodeDetail | null
|
||||
) => {
|
||||
return Promise.all(
|
||||
nodes.map(async node =>
|
||||
createContextItem(node, normalizeProjectDetail(await fetchNodeDetail(node, activeDetail), node, context))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const loadEquipmentContextItems = async (
|
||||
nodes: AddLedger.NormalizedTreeNode[],
|
||||
context: LedgerContextIds,
|
||||
activeDetail?: AddLedger.NodeDetail | null
|
||||
) => {
|
||||
return Promise.all(
|
||||
nodes.map(async node => {
|
||||
const form = normalizeEquipmentDetail(await fetchNodeDetail(node, activeDetail), node, context)
|
||||
await ensureEquipmentDictOptions(form)
|
||||
return createContextItem(node, form)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const loadLineContextItems = async (
|
||||
nodes: AddLedger.NormalizedTreeNode[],
|
||||
context: LedgerContextIds,
|
||||
activeDetail?: AddLedger.NodeDetail | null
|
||||
) => {
|
||||
return Promise.all(
|
||||
nodes.map(async node =>
|
||||
createContextItem(node, normalizeLineDetail(await fetchNodeDetail(node, activeDetail), node, context))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const resolveActiveChild = (nodes: AddLedger.NormalizedTreeNode[], selectedId: string, fallbackId: string) =>
|
||||
nodes.find(item => item.id === selectedId) || nodes.find(item => item.id === fallbackId) || nodes[0] || null
|
||||
|
||||
const rebuildLedgerContext = async (
|
||||
path: AddLedger.NormalizedTreeNode[],
|
||||
activeNode: AddLedger.NormalizedTreeNode,
|
||||
activeDetail: AddLedger.NodeDetail | null
|
||||
) => {
|
||||
resetLedgerContext()
|
||||
|
||||
const engineeringNode = path.find(item => item.level === 0)
|
||||
if (!engineeringNode) return
|
||||
|
||||
const context = resolveContextFromPath(path)
|
||||
ledgerContext.engineering = createContextItem(
|
||||
engineeringNode,
|
||||
normalizeEngineeringDetail(await fetchNodeDetail(engineeringNode, activeDetail), engineeringNode)
|
||||
)
|
||||
engineeringForm.value =
|
||||
activeNode.level === 0 ? normalizeEngineeringDetail(activeDetail, activeNode) : ledgerContext.engineering.form
|
||||
|
||||
const projectNodes = engineeringNode.children.filter(item => item.level === 1)
|
||||
const activeProjectNode = resolveActiveChild(projectNodes, context.projectId, activeTabIds.project)
|
||||
if (!activeProjectNode) return
|
||||
|
||||
activeTabIds.project = activeProjectNode.id
|
||||
const projectContext = {
|
||||
...context,
|
||||
projectId: activeProjectNode.id
|
||||
}
|
||||
ledgerContext.projects = await loadProjectContextItems(projectNodes, projectContext, activeDetail)
|
||||
if (activeNode.level === 1) {
|
||||
projectForm.value = normalizeProjectDetail(activeDetail, activeNode, projectContext)
|
||||
}
|
||||
|
||||
const equipmentNodes = activeProjectNode.children.filter(item => item.level === 2)
|
||||
const activeEquipmentNode = resolveActiveChild(equipmentNodes, context.deviceId, activeTabIds.equipment)
|
||||
if (!activeEquipmentNode) return
|
||||
|
||||
activeTabIds.equipment = activeEquipmentNode.id
|
||||
const equipmentContext = {
|
||||
...projectContext,
|
||||
deviceId: activeEquipmentNode.id
|
||||
}
|
||||
ledgerContext.equipments = await loadEquipmentContextItems(equipmentNodes, equipmentContext, activeDetail)
|
||||
if (activeNode.level === 2) {
|
||||
equipmentForm.value = normalizeEquipmentDetail(activeDetail, activeNode, equipmentContext)
|
||||
}
|
||||
|
||||
const lineNodes = activeEquipmentNode.children.filter(item => item.level === 3)
|
||||
const activeLineNode = resolveActiveChild(lineNodes, activeNode.level === 3 ? activeNode.id : '', activeTabIds.line)
|
||||
if (!activeLineNode) return
|
||||
|
||||
activeTabIds.line = activeLineNode.id
|
||||
ledgerContext.lines = await loadLineContextItems(lineNodes, equipmentContext, activeDetail)
|
||||
if (activeNode.level === 3) {
|
||||
lineForm.value = normalizeLineDetail(activeDetail, activeNode, equipmentContext)
|
||||
await loadAvailableLineNoOptions(
|
||||
lineForm.value.deviceId || '',
|
||||
lineForm.value.line_id || '',
|
||||
lineForm.value.line_no
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const findNodeInTree = (id: string) => {
|
||||
const path = findNodePath(treeData.value, id)
|
||||
return path[path.length - 1] || null
|
||||
}
|
||||
|
||||
const handleActiveTabUpdate = (key: keyof typeof activeTabIds, id: string) => {
|
||||
activeTabIds[key] = id
|
||||
}
|
||||
|
||||
const refreshProjectTabContent = async (node: AddLedger.NormalizedTreeNode, detail: AddLedger.NodeDetail | null) => {
|
||||
const path = findNodePath(treeData.value, node.id)
|
||||
const context = resolveContextFromPath(path)
|
||||
const projectContext = {
|
||||
...context,
|
||||
projectId: node.id
|
||||
}
|
||||
const equipmentNodes = node.children.filter(item => item.level === 2)
|
||||
const activeEquipmentNode = resolveActiveChild(equipmentNodes, activeTabIds.equipment, context.deviceId)
|
||||
|
||||
activeTabIds.project = node.id
|
||||
activeTabIds.equipment = activeEquipmentNode?.id || ''
|
||||
activeTabIds.line = ''
|
||||
projectForm.value = normalizeProjectDetail(detail, node, projectContext)
|
||||
ledgerContext.equipments = await loadEquipmentContextItems(equipmentNodes, projectContext)
|
||||
|
||||
if (!activeEquipmentNode) {
|
||||
ledgerContext.lines = []
|
||||
return
|
||||
}
|
||||
|
||||
const equipmentContext = {
|
||||
...projectContext,
|
||||
deviceId: activeEquipmentNode.id
|
||||
}
|
||||
const equipmentDetail = await fetchNodeDetail(activeEquipmentNode)
|
||||
equipmentForm.value = normalizeEquipmentDetail(equipmentDetail, activeEquipmentNode, equipmentContext)
|
||||
await ensureEquipmentDictOptions(equipmentForm.value)
|
||||
|
||||
const lineNodes = activeEquipmentNode.children.filter(item => item.level === 3)
|
||||
const activeLineNode = resolveActiveChild(lineNodes, '', activeTabIds.line)
|
||||
activeTabIds.line = activeLineNode?.id || ''
|
||||
ledgerContext.lines = await loadLineContextItems(lineNodes, equipmentContext)
|
||||
|
||||
if (activeLineNode) {
|
||||
lineForm.value = normalizeLineDetail(await fetchNodeDetail(activeLineNode), activeLineNode, equipmentContext)
|
||||
await loadAvailableLineNoOptions(
|
||||
lineForm.value.deviceId || '',
|
||||
lineForm.value.line_id || '',
|
||||
lineForm.value.line_no
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshEquipmentTabContent = async (node: AddLedger.NormalizedTreeNode, detail: AddLedger.NodeDetail | null) => {
|
||||
const path = findNodePath(treeData.value, node.id)
|
||||
const context = resolveContextFromPath(path)
|
||||
const equipmentContext = {
|
||||
...context,
|
||||
deviceId: node.id
|
||||
}
|
||||
const lineNodes = node.children.filter(item => item.level === 3)
|
||||
const activeLineNode = resolveActiveChild(lineNodes, '', activeTabIds.line)
|
||||
|
||||
activeTabIds.equipment = node.id
|
||||
activeTabIds.line = activeLineNode?.id || ''
|
||||
equipmentForm.value = normalizeEquipmentDetail(detail, node, equipmentContext)
|
||||
await ensureEquipmentDictOptions(equipmentForm.value)
|
||||
ledgerContext.lines = await loadLineContextItems(lineNodes, equipmentContext)
|
||||
|
||||
if (activeLineNode) {
|
||||
lineForm.value = normalizeLineDetail(await fetchNodeDetail(activeLineNode), activeLineNode, equipmentContext)
|
||||
await loadAvailableLineNoOptions(
|
||||
lineForm.value.deviceId || '',
|
||||
lineForm.value.line_id || '',
|
||||
lineForm.value.line_no
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshLineTabContent = async (node: AddLedger.NormalizedTreeNode, detail: AddLedger.NodeDetail | null) => {
|
||||
const path = findNodePath(treeData.value, node.id)
|
||||
const context = resolveContextFromPath(path)
|
||||
|
||||
activeTabIds.line = node.id
|
||||
lineForm.value = normalizeLineDetail(detail, node, context)
|
||||
await loadAvailableLineNoOptions(
|
||||
lineForm.value.deviceId || '',
|
||||
lineForm.value.line_id || '',
|
||||
lineForm.value.line_no
|
||||
)
|
||||
}
|
||||
|
||||
const refreshContextTabContent = async (node: AddLedger.NormalizedTreeNode) => {
|
||||
const requestSeq = ++detailRequestSeq
|
||||
selectedNode.value = node
|
||||
activeLevel.value = node.level
|
||||
loading.detail = true
|
||||
|
||||
try {
|
||||
const detail = await fetchNodeDetail(node)
|
||||
if (requestSeq !== detailRequestSeq) return
|
||||
|
||||
if (node.level === 1) {
|
||||
await refreshProjectTabContent(node, detail)
|
||||
} else if (node.level === 2) {
|
||||
await refreshEquipmentTabContent(node, detail)
|
||||
} else if (node.level === 3) {
|
||||
await refreshLineTabContent(node, detail)
|
||||
}
|
||||
} finally {
|
||||
if (requestSeq === detailRequestSeq) {
|
||||
loading.detail = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextTabClick = (id: string, level: AddLedger.NodeLevel) => {
|
||||
if (!id || selectedNode.value?.id === id || id.startsWith('__draft_')) return
|
||||
|
||||
const node = findNodeInTree(id)
|
||||
if (node && node.level === level) {
|
||||
void refreshContextTabContent(node)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTree = async () => {
|
||||
loading.tree = true
|
||||
try {
|
||||
const response = await getAddLedgerTree()
|
||||
const nextTree = Array.isArray(response.data) ? response.data.map(normalizeTreeNode).filter(item => item.id) : []
|
||||
const nextTree = Array.isArray(response.data)
|
||||
? response.data.map(normalizeTreeNode).filter(item => item.id)
|
||||
: []
|
||||
treeData.value = nextTree
|
||||
|
||||
if (selectedNode.value?.id) {
|
||||
@@ -425,6 +820,10 @@ const loadTree = async () => {
|
||||
}
|
||||
|
||||
const loadNodeDetail = async (node: AddLedger.NormalizedTreeNode) => {
|
||||
const requestSeq = ++detailRequestSeq
|
||||
const path = findNodePath(treeData.value, node.id)
|
||||
const context = resolveContextFromPath(path)
|
||||
|
||||
selectedNode.value = node
|
||||
activeLevel.value = node.level
|
||||
loading.detail = true
|
||||
@@ -432,27 +831,26 @@ const loadNodeDetail = async (node: AddLedger.NormalizedTreeNode) => {
|
||||
try {
|
||||
const response = await getAddLedgerDetail({ id: node.id, level: node.level })
|
||||
const detail = response.data || null
|
||||
if (requestSeq !== detailRequestSeq) return
|
||||
|
||||
if (node.level === 0) {
|
||||
engineeringForm.value = normalizeEngineeringDetail(detail, node)
|
||||
return
|
||||
} else if (node.level === 1) {
|
||||
projectForm.value = normalizeProjectDetail(detail, node, context)
|
||||
} else if (node.level === 2) {
|
||||
const form = normalizeEquipmentDetail(detail, node, context)
|
||||
await ensureEquipmentDictOptions(form)
|
||||
equipmentForm.value = form
|
||||
} else {
|
||||
lineForm.value = normalizeLineDetail(detail, node, context)
|
||||
}
|
||||
|
||||
if (node.level === 1) {
|
||||
projectForm.value = normalizeProjectDetail(detail, node)
|
||||
return
|
||||
}
|
||||
|
||||
if (node.level === 2) {
|
||||
equipmentForm.value = normalizeEquipmentDetail(detail, node)
|
||||
return
|
||||
}
|
||||
|
||||
lineForm.value = normalizeLineDetail(detail, node)
|
||||
await loadAvailableLineNoOptions(lineForm.value.deviceId || '', lineForm.value.line_id || '', lineForm.value.line_no)
|
||||
await rebuildLedgerContext(path, node, detail)
|
||||
} finally {
|
||||
if (requestSeq === detailRequestSeq) {
|
||||
loading.detail = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectNode = (node: AddLedger.NormalizedTreeNode) => {
|
||||
@@ -463,11 +861,17 @@ const handleAddEngineering = () => {
|
||||
selectedNode.value = null
|
||||
activeLevel.value = 0
|
||||
engineeringForm.value = createEmptyEngineeringForm()
|
||||
resetLedgerContext()
|
||||
ledgerContext.engineering = createDraftContextItem(draftIds.engineering, '新增工程', 0, engineeringForm.value)
|
||||
}
|
||||
|
||||
const handleAddProject = () => {
|
||||
const context = resolveContext()
|
||||
const engineeringId = activeLevel.value === 0 ? engineeringForm.value.id || selectedNode.value?.id || '' : context.engineeringId
|
||||
const engineeringIdFromContext = resolveSavedContextItemId(ledgerContext.engineering)
|
||||
const engineeringId =
|
||||
activeLevel.value === 0
|
||||
? engineeringForm.value.id || selectedNode.value?.id || ''
|
||||
: context.engineeringId || engineeringIdFromContext
|
||||
|
||||
if (!engineeringId) {
|
||||
ElMessage.warning('请先选择或保存父级工程')
|
||||
@@ -477,12 +881,28 @@ const handleAddProject = () => {
|
||||
selectedNode.value = null
|
||||
activeLevel.value = 1
|
||||
projectForm.value = createEmptyProjectForm(engineeringId)
|
||||
ledgerContext.projects = [
|
||||
...ledgerContext.projects.filter(item => !item.draft),
|
||||
createDraftContextItem(draftIds.project, '新增项目', 1, projectForm.value)
|
||||
]
|
||||
activeTabIds.project = draftIds.project
|
||||
ledgerContext.equipments = []
|
||||
ledgerContext.lines = []
|
||||
}
|
||||
|
||||
const handleAddEquipment = () => {
|
||||
const context = resolveContext()
|
||||
const projectId = activeLevel.value === 1 ? projectForm.value.id || selectedNode.value?.id || '' : context.projectId
|
||||
const engineeringId = projectForm.value.engineeringId || context.engineeringId
|
||||
const activeProjectItem = ledgerContext.projects.find(item => item.id === activeTabIds.project)
|
||||
const projectIdFromTab = resolveSavedContextItemId(activeProjectItem)
|
||||
const projectId =
|
||||
activeLevel.value === 1
|
||||
? projectForm.value.id || selectedNode.value?.id || ''
|
||||
: context.projectId || projectIdFromTab
|
||||
const engineeringId =
|
||||
projectForm.value.engineeringId ||
|
||||
context.engineeringId ||
|
||||
activeProjectItem?.form.engineeringId ||
|
||||
resolveSavedContextItemId(ledgerContext.engineering)
|
||||
|
||||
if (!projectId) {
|
||||
ElMessage.warning('请先选择或保存父级项目')
|
||||
@@ -492,11 +912,22 @@ const handleAddEquipment = () => {
|
||||
selectedNode.value = null
|
||||
activeLevel.value = 2
|
||||
equipmentForm.value = createEmptyEquipmentForm(projectId, engineeringId)
|
||||
ledgerContext.equipments = [
|
||||
...ledgerContext.equipments.filter(item => !item.draft),
|
||||
createDraftContextItem(draftIds.equipment, '新增设备', 2, equipmentForm.value)
|
||||
]
|
||||
activeTabIds.equipment = draftIds.equipment
|
||||
ledgerContext.lines = []
|
||||
}
|
||||
|
||||
const handleAddLine = async () => {
|
||||
const context = resolveContext()
|
||||
const deviceId = activeLevel.value === 2 ? equipmentForm.value.id || selectedNode.value?.id || '' : context.deviceId
|
||||
const activeEquipmentItem = ledgerContext.equipments.find(item => item.id === activeTabIds.equipment)
|
||||
const deviceIdFromTab = resolveSavedContextItemId(activeEquipmentItem)
|
||||
const deviceId =
|
||||
activeLevel.value === 2
|
||||
? equipmentForm.value.id || selectedNode.value?.id || ''
|
||||
: context.deviceId || deviceIdFromTab
|
||||
|
||||
if (!deviceId) {
|
||||
ElMessage.warning('请先选择或保存父级设备')
|
||||
@@ -512,10 +943,15 @@ const handleAddLine = async () => {
|
||||
selectedNode.value = null
|
||||
activeLevel.value = 3
|
||||
lineForm.value = createEmptyLineForm(deviceId)
|
||||
ledgerContext.lines = [
|
||||
...ledgerContext.lines.filter(item => !item.draft),
|
||||
createDraftContextItem(draftIds.line, '新增监测点', 3, lineForm.value)
|
||||
]
|
||||
activeTabIds.line = draftIds.line
|
||||
}
|
||||
|
||||
const validateForm = async (formRef: FormExpose | undefined) => {
|
||||
const isValid = await formRef?.validateForm()
|
||||
const validateActiveForm = async () => {
|
||||
const isValid = await ledgerContextPanelRef.value?.validateActiveForm(activeLevel.value)
|
||||
return Boolean(isValid)
|
||||
}
|
||||
|
||||
@@ -531,7 +967,7 @@ const refreshAfterSave = async (id?: string) => {
|
||||
}
|
||||
|
||||
const handleSaveEngineering = async () => {
|
||||
if (!(await validateForm(engineeringFormRef.value))) return
|
||||
if (!(await validateActiveForm())) return
|
||||
|
||||
loading.saving = true
|
||||
try {
|
||||
@@ -545,7 +981,7 @@ const handleSaveEngineering = async () => {
|
||||
}
|
||||
|
||||
const handleSaveProject = async () => {
|
||||
if (!(await validateForm(projectFormRef.value))) return
|
||||
if (!(await validateActiveForm())) return
|
||||
|
||||
if (!projectForm.value.parentId && !projectForm.value.engineeringId) {
|
||||
ElMessage.warning('缺少父级工程,无法保存项目')
|
||||
@@ -564,7 +1000,7 @@ const handleSaveProject = async () => {
|
||||
}
|
||||
|
||||
const handleSaveEquipment = async () => {
|
||||
if (!(await validateForm(equipmentFormRef.value))) return
|
||||
if (!(await validateActiveForm())) return
|
||||
|
||||
if (!equipmentForm.value.parentId && !equipmentForm.value.projectId) {
|
||||
ElMessage.warning('缺少父级项目,无法保存设备')
|
||||
@@ -583,7 +1019,7 @@ const handleSaveEquipment = async () => {
|
||||
}
|
||||
|
||||
const handleSaveLine = async () => {
|
||||
if (!(await validateForm(lineFormRef.value))) return
|
||||
if (!(await validateActiveForm())) return
|
||||
|
||||
if (!lineForm.value.parentId && !lineForm.value.deviceId) {
|
||||
ElMessage.warning('缺少父级设备,无法保存测点')
|
||||
@@ -614,24 +1050,41 @@ const resolveActiveNodeId = () => {
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveDeleteMessage = () => {
|
||||
if (activeLevel.value === 0) return '确认删除当前工程?删除后会同时删除所有下级项目、设备和监测点。'
|
||||
if (activeLevel.value === 1) return '确认删除当前项目?删除后会同时删除所有下级设备和监测点。'
|
||||
if (activeLevel.value === 2) return '确认删除当前设备?删除后会同时删除所有下级监测点。'
|
||||
const resolveDeleteMessage = (level: AddLedger.NodeLevel) => {
|
||||
if (level === 0) return '确认删除当前工程?删除后会同时删除所有下级项目、设备和监测点。'
|
||||
if (level === 1) return '确认删除当前项目?删除后会同时删除所有下级设备和监测点。'
|
||||
if (level === 2) return '确认删除当前设备?删除后会同时删除所有下级监测点。'
|
||||
return '确认删除当前监测点?'
|
||||
}
|
||||
|
||||
const handleDeleteNode = async () => {
|
||||
if (activeLevel.value === null) return
|
||||
const resolveDeleteTarget = (node?: AddLedger.NormalizedTreeNode) => {
|
||||
if (node) {
|
||||
return {
|
||||
id: node.id,
|
||||
level: node.level
|
||||
}
|
||||
}
|
||||
|
||||
if (activeLevel.value === null) return null
|
||||
|
||||
const id = resolveActiveNodeId()
|
||||
if (!id) {
|
||||
if (!id) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
level: activeLevel.value
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteNode = async (node?: AddLedger.NormalizedTreeNode) => {
|
||||
const target = resolveDeleteTarget(node)
|
||||
if (!target) {
|
||||
ElMessage.warning('当前节点尚未保存,无法删除')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(resolveDeleteMessage(), '删除确认', {
|
||||
await ElMessageBox.confirm(resolveDeleteMessage(target.level), '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消'
|
||||
@@ -642,10 +1095,11 @@ const handleDeleteNode = async () => {
|
||||
|
||||
loading.saving = true
|
||||
try {
|
||||
await deleteAddLedgerNode({ id, level: activeLevel.value })
|
||||
await deleteAddLedgerNode({ id: target.id, level: target.level })
|
||||
ElMessage.success('删除成功')
|
||||
selectedNode.value = null
|
||||
activeLevel.value = null
|
||||
resetLedgerContext()
|
||||
await loadTree()
|
||||
} finally {
|
||||
loading.saving = false
|
||||
@@ -653,6 +1107,7 @@ const handleDeleteNode = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLedgerDictOptions()
|
||||
await loadTree()
|
||||
})
|
||||
</script>
|
||||
@@ -672,19 +1127,35 @@ onMounted(async () => {
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.add-ledger-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.detail-alert {
|
||||
flex: none;
|
||||
.add-ledger-toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
z-index: 3;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.add-ledger-toolbar :deep(.el-button) {
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.add-ledger-toolbar :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.add-ledger-empty {
|
||||
@@ -698,6 +1169,12 @@ onMounted(async () => {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.add-ledger-empty :deep(.el-button.is-circle) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
144
frontend/src/views/tools/waveform/check-value-mode-contract.mjs
Normal file
144
frontend/src/views/tools/waveform/check-value-mode-contract.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
/* 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 viewFile = path.join(currentDir, 'index.vue')
|
||||
const toolbarFile = path.join(currentDir, 'components', 'WaveformToolbar.vue')
|
||||
const trendPanelFile = path.join(currentDir, 'components', 'WaveformTrendPanel.vue')
|
||||
const interfaceFile = path.join(currentDir, '..', '..', '..', 'api', 'tools', 'waveform', 'interface', 'index.ts')
|
||||
|
||||
const viewSource = fs.readFileSync(viewFile, 'utf8')
|
||||
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
|
||||
const trendPanelSource = fs.readFileSync(trendPanelFile, 'utf8')
|
||||
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
['wave data supports szValueType', /interface\s+WaveDataDTO\s*{[\s\S]*szValueType\?:\s*string/],
|
||||
['wave data detail supports unit', /interface\s+WaveDataDetail\s*{[\s\S]*unit\?:\s*string/],
|
||||
['wave data detail supports totalChannels', /interface\s+WaveDataDetail\s*{[\s\S]*totalChannels\?:\s*number/],
|
||||
['wave data detail supports phaseCount', /interface\s+WaveDataDetail\s*{[\s\S]*phaseCount\?:\s*number/],
|
||||
['wave data supports unit fallback', /interface\s+WaveDataDTO\s*{[\s\S]*unit\?:\s*string/],
|
||||
['wave data supports totalChannels fallback', /interface\s+WaveDataDTO\s*{[\s\S]*totalChannels\?:\s*number/],
|
||||
['wave data supports phaseCount fallback', /interface\s+WaveDataDTO\s*{[\s\S]*phaseCount\?:\s*number/],
|
||||
['parse result supports root unit', /interface\s+WaveComtradeResultVO\s*{[\s\S]*unit\?:\s*string/],
|
||||
['parse result supports root totalChannels', /interface\s+WaveComtradeResultVO\s*{[\s\S]*totalChannels\?:\s*number/],
|
||||
['parse result supports root phaseCount', /interface\s+WaveComtradeResultVO\s*{[\s\S]*phaseCount\?:\s*number/],
|
||||
[
|
||||
'summary detail falls back to first normalized detail',
|
||||
/summaryWaveDetail[\s\S]*activeWaveDetail\.value\s*\|\|\s*normalizedWaveDetails\.value\[0\]/
|
||||
],
|
||||
[
|
||||
'normalized detail keeps backend unit fallback',
|
||||
/unit:\s*detail\?\.unit\s*\|\|\s*waveData\?\.unit\s*\|\|\s*parseResult\?\.unit\s*\|\|\s*''/
|
||||
],
|
||||
[
|
||||
'normalized detail keeps backend totalChannels',
|
||||
/totalChannels:\s*detail\?\.totalChannels\s*\?\?\s*waveData\?\.totalChannels\s*\?\?\s*parseResult\?\.totalChannels/
|
||||
],
|
||||
[
|
||||
'normalized detail keeps backend phaseCount',
|
||||
/phaseCount:\s*detail\?\.phaseCount\s*\?\?\s*waveData\?\.phaseCount\s*\?\?\s*parseResult\?\.phaseCount/
|
||||
],
|
||||
[
|
||||
'summary total channels prefers backend detail field',
|
||||
/detail\?\.totalChannels\s*\?\?\s*summaryDetail\?\.totalChannels\s*\?\?\s*waveData\?\.totalChannels\s*\?\?\s*parseResult\?\.totalChannels\s*\?\?\s*cfgData\?\.nChannelNum/
|
||||
],
|
||||
[
|
||||
'summary phase count prefers backend detail field',
|
||||
/detail\?\.phaseCount\s*\?\?\s*summaryDetail\?\.phaseCount\s*\?\?\s*waveData\?\.phaseCount\s*\?\?\s*parseResult\?\.phaseCount\s*\?\?\s*waveData\?\.iPhasic/
|
||||
],
|
||||
[
|
||||
'display unit prefers backend detail unit',
|
||||
/const\s+resolveSummaryUnit[\s\S]*detail\?\.unit[\s\S]*return\s+detail\.unit[\s\S]*return\s+resolveDisplayUnit\(detail\)/
|
||||
],
|
||||
['summary unit falls back to root unit', /label:\s*'单位'[\s\S]*summaryDetail[\s\S]*parseResult\?\.unit\s*\|\|\s*'--'/],
|
||||
['source value mode state exists', /sourceValueMode\s*=\s*ref<ValueMode>\('secondary'\)/],
|
||||
['P maps to primary value mode', /value\s*===\s*'P'[\s\S]*return\s*'primary'/],
|
||||
['S maps to secondary value mode', /value\s*===\s*'S'[\s\S]*return\s*'secondary'/],
|
||||
[
|
||||
'parsed waveform defaults to primary display mode',
|
||||
/sourceValueMode\.value\s*=\s*resolveSourceValueMode[\s\S]*activeValueMode\.value\s*=\s*'primary'/
|
||||
],
|
||||
['reset waveform defaults to primary display mode', /resetSelectedWaveformFiles[\s\S]*activeValueMode\.value\s*=\s*'primary'/],
|
||||
['current channel detects ampere unit first', /isCurrentChannel[\s\S]*unit\s*===\s*'A'[\s\S]*return\s*true/],
|
||||
['current display unit stays A', /resolveDisplayUnit[\s\S]*isCurrentChannel\(detail\)[\s\S]*return\s*'A'/],
|
||||
[
|
||||
'voltage channel primary display unit is kV',
|
||||
/resolveDisplayUnit[\s\S]*activeValueMode\.value\s*===\s*'primary'\s*\?\s*'kV'\s*:\s*'V'/
|
||||
],
|
||||
['same source and display mode keep scale 1', /sourceValueMode\.value\s*===\s*activeValueMode\.value[\s\S]*return\s*1/],
|
||||
[
|
||||
'secondary voltage source converts to primary kV by multiplying ratio and dividing 1000',
|
||||
/sourceValueMode\.value\s*===\s*'secondary'[\s\S]*return\s*isVoltageChannel\(detail\)\s*\?\s*normalizedRatio\s*\/\s*1000\s*:\s*normalizedRatio/
|
||||
],
|
||||
[
|
||||
'primary voltage source converts to secondary V by multiplying 1000 and dividing ratio',
|
||||
/return\s*isVoltageChannel\(detail\)\s*\?\s*1000\s*\/\s*normalizedRatio\s*:\s*1\s*\/\s*normalizedRatio/
|
||||
],
|
||||
['trend payload uses display unit', /unit:\s*resolveDisplayUnit\(detail\)/],
|
||||
['temporary PT ratio state exists', /temporaryPtRatio\s*=\s*ref<number\s*\|\s*undefined>/],
|
||||
['temporary CT ratio state exists', /temporaryCtRatio\s*=\s*ref<number\s*\|\s*undefined>/],
|
||||
[
|
||||
'value scale reads temporary ratios before parsed ratios',
|
||||
/isCurrentChannel\(detail\)\s*\?\s*temporaryCtRatio\.value\s*:\s*temporaryPtRatio\.value/
|
||||
],
|
||||
[
|
||||
'parsed waveform resets temporary ratios from wave data',
|
||||
/temporaryPtRatio\.value\s*=\s*parseResult\.waveData\?\.pt[\s\S]*temporaryCtRatio\.value\s*=\s*parseResult\.waveData\?\.ct/
|
||||
],
|
||||
['toolbar exposes PT CT adjustment event', /'update-waveform-ratio':\s*\[value:\s*WaveformRatioValue\]/],
|
||||
[
|
||||
'ratio configuration button uses icon and text',
|
||||
/<el-button[\s\S]*:icon="Setting"[\s\S]*参数配置[\s\S]*<\/el-button>/
|
||||
],
|
||||
[
|
||||
'ratio configuration button follows value mode switch',
|
||||
/class="value-mode-switch"[\s\S]*<\/el-radio-group>[\s\S]*:icon="Setting"[\s\S]*参数配置/
|
||||
],
|
||||
['toolbar does not render PT CT label text', /<div class="toolbar-label">PT\/CT<\/div>/, true],
|
||||
['summary does not render PT CT label text', /label:\s*'PT \/ CT'/, true],
|
||||
[
|
||||
'summary renders current PT ratio value',
|
||||
/label:\s*'PT变比'[\s\S]*formatNumber\(temporaryPtRatio\.value\s*\?\?\s*waveData\?\.pt/
|
||||
],
|
||||
[
|
||||
'summary renders current CT ratio value',
|
||||
/label:\s*'CT变比'[\s\S]*formatNumber\(temporaryCtRatio\.value\s*\?\?\s*waveData\?\.ct/
|
||||
],
|
||||
[
|
||||
'ratio dialog edits PT above CT',
|
||||
/label="PT变比"[\s\S]*v-model="ratioForm\.ptPrimary"[\s\S]*ratio-separator[\s\S]*v-model="ratioForm\.ptSecondary"[\s\S]*label="CT变比"[\s\S]*v-model="ratioForm\.ctPrimary"[\s\S]*ratio-separator[\s\S]*v-model="ratioForm\.ctSecondary"/
|
||||
],
|
||||
[
|
||||
'ratio confirm calculates final ratios by division',
|
||||
/pt:\s*Number\(ratioForm\.ptPrimary\)\s*\/\s*Number\(ratioForm\.ptSecondary\)[\s\S]*ct:\s*Number\(ratioForm\.ctPrimary\)\s*\/\s*Number\(ratioForm\.ctSecondary\)/
|
||||
],
|
||||
[
|
||||
'trend tools first group contains zoom reset and pan only',
|
||||
/key:\s*'viewport'[\s\S]*x-zoom-in[\s\S]*x-zoom-out[\s\S]*y-zoom-in[\s\S]*y-zoom-out[\s\S]*box-zoom[\s\S]*reset[\s\S]*pan[\s\S]*key:\s*'marker-view'/
|
||||
],
|
||||
[
|
||||
'trend tools second group contains mark and fullscreen only',
|
||||
/key:\s*'marker-view'[\s\S]*mark[\s\S]*fullscreen[\s\S]*key:\s*'export'/
|
||||
],
|
||||
['trend tools third group contains image and data downloads', /key:\s*'export'[\s\S]*download-image[\s\S]*download-data/],
|
||||
['trend tool group separator is dashed vertical line', /\.trend-tool-group\s*\+\s*\.trend-tool-group[\s\S]*border-left:\s*1px\s+dashed/]
|
||||
]
|
||||
|
||||
const combinedSource = `${viewSource}\n${toolbarSource}\n${trendPanelSource}\n${interfaceSource}`
|
||||
const failures = expectations.filter(([, pattern, shouldBeMissing]) => {
|
||||
const exists = pattern.test(combinedSource)
|
||||
return shouldBeMissing ? exists : !exists
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('waveform value mode contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('waveform value mode contract check passed')
|
||||
@@ -22,6 +22,23 @@
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<div class="toolbar-item">
|
||||
<div class="toolbar-label">解析类型</div>
|
||||
<el-select
|
||||
:model-value="activeParseType"
|
||||
class="parse-type-select"
|
||||
placeholder="选择解析类型"
|
||||
@update:model-value="handleParseTypeChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in parseTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div v-if="channelOptions.length" class="toolbar-item">
|
||||
<div class="toolbar-label">波形通道</div>
|
||||
<el-select
|
||||
@@ -68,19 +85,92 @@
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Setting"
|
||||
:disabled="!hasWaveformData"
|
||||
@click="openWaveformRatioDialog"
|
||||
>
|
||||
参数配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="ratioDialogVisible" title="参数配置" width="520px" append-to-body>
|
||||
<el-form label-width="82px" class="ratio-form">
|
||||
<el-form-item label="PT变比" class="ratio-form-item">
|
||||
<div class="ratio-input-group">
|
||||
<el-input-number
|
||||
v-model="ratioForm.ptPrimary"
|
||||
class="ratio-input"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="ratio-separator">:</span>
|
||||
<el-input-number
|
||||
v-model="ratioForm.ptSecondary"
|
||||
class="ratio-input"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="CT变比" class="ratio-form-item">
|
||||
<div class="ratio-input-group">
|
||||
<el-input-number
|
||||
v-model="ratioForm.ctPrimary"
|
||||
class="ratio-input"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="ratio-separator">:</span>
|
||||
<el-input-number
|
||||
v-model="ratioForm.ctSecondary"
|
||||
class="ratio-input"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="ratioDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleRatioConfirm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderOpened } from '@element-plus/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
import type { ChannelSelectValue, DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
|
||||
import { FolderOpened, Setting } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import type {
|
||||
ChannelSelectValue,
|
||||
DisplayMode,
|
||||
LabelValueOption,
|
||||
ParseType,
|
||||
ValueMode,
|
||||
WaveformDetailOption,
|
||||
WaveformRatioValue
|
||||
} from './types'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
selectedWaveformFileName: string
|
||||
isParsing: boolean
|
||||
hasWaveformData: boolean
|
||||
waveformFileAccept: string
|
||||
channelOptions: WaveformDetailOption[]
|
||||
activeChannelIndex: ChannelSelectValue
|
||||
@@ -88,16 +178,41 @@ defineProps<{
|
||||
activeDisplayMode: DisplayMode
|
||||
valueModeOptions: LabelValueOption<ValueMode>[]
|
||||
activeValueMode: ValueMode
|
||||
parseTypeOptions: LabelValueOption<ParseType>[]
|
||||
activeParseType: ParseType
|
||||
ptRatio?: number
|
||||
ctRatio?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeChannelIndex': [value: ChannelSelectValue]
|
||||
'update:activeDisplayMode': [value: DisplayMode]
|
||||
'update:activeValueMode': [value: ValueMode]
|
||||
'update:activeParseType': [value: ParseType]
|
||||
'update-waveform-ratio': [value: WaveformRatioValue]
|
||||
'waveform-file-change': [event: Event]
|
||||
}>()
|
||||
|
||||
const waveformFileInputRef = ref<HTMLInputElement>()
|
||||
const ratioDialogVisible = ref(false)
|
||||
const ratioForm = reactive({
|
||||
ctPrimary: 1,
|
||||
ctSecondary: 1,
|
||||
ptPrimary: 1,
|
||||
ptSecondary: 1
|
||||
})
|
||||
|
||||
const resolveRatioPrimaryValue = (value?: number) => {
|
||||
const ratio = Number(value)
|
||||
return Number.isFinite(ratio) && ratio > 0 ? Math.round(ratio) : 1
|
||||
}
|
||||
|
||||
const syncRatioForm = () => {
|
||||
ratioForm.ctPrimary = resolveRatioPrimaryValue(props.ctRatio)
|
||||
ratioForm.ctSecondary = 1
|
||||
ratioForm.ptPrimary = resolveRatioPrimaryValue(props.ptRatio)
|
||||
ratioForm.ptSecondary = 1
|
||||
}
|
||||
|
||||
const openWaveformFilePicker = () => {
|
||||
if (!waveformFileInputRef.value) return
|
||||
@@ -106,6 +221,13 @@ const openWaveformFilePicker = () => {
|
||||
waveformFileInputRef.value.click()
|
||||
}
|
||||
|
||||
const openWaveformRatioDialog = () => {
|
||||
if (!props.hasWaveformData) return
|
||||
|
||||
syncRatioForm()
|
||||
ratioDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleWaveformFileChange = (event: Event) => {
|
||||
emit('waveform-file-change', event)
|
||||
}
|
||||
@@ -123,6 +245,42 @@ const handleValueModeChange = (value: string | number | boolean | undefined) =>
|
||||
emit('update:activeValueMode', value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleParseTypeChange = (value: string | number | boolean | undefined) => {
|
||||
if (value === 0 || value === 1 || value === 2 || value === 3) {
|
||||
emit('update:activeParseType', value)
|
||||
}
|
||||
}
|
||||
|
||||
const isPositiveRatio = (value?: number) => {
|
||||
const ratio = Number(value)
|
||||
return Number.isInteger(ratio) && ratio > 0
|
||||
}
|
||||
|
||||
const handleRatioConfirm = () => {
|
||||
if (
|
||||
!isPositiveRatio(ratioForm.ptPrimary) ||
|
||||
!isPositiveRatio(ratioForm.ptSecondary) ||
|
||||
!isPositiveRatio(ratioForm.ctPrimary) ||
|
||||
!isPositiveRatio(ratioForm.ctSecondary)
|
||||
) {
|
||||
ElMessage.warning('请输入大于 0 的整数变比')
|
||||
return
|
||||
}
|
||||
|
||||
emit('update-waveform-ratio', {
|
||||
pt: Number(ratioForm.ptPrimary) / Number(ratioForm.ptSecondary),
|
||||
ct: Number(ratioForm.ctPrimary) / Number(ratioForm.ctSecondary)
|
||||
})
|
||||
ratioDialogVisible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.ptRatio, props.ctRatio],
|
||||
() => {
|
||||
if (!ratioDialogVisible.value) syncRatioForm()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -176,10 +334,40 @@ const handleValueModeChange = (value: string | number | boolean | undefined) =>
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.parse-type-select {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.value-mode-switch {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.ratio-form {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.ratio-form-item :deep(.el-form-item__content) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ratio-input-group {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ratio-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ratio-separator {
|
||||
flex: 0 0 auto;
|
||||
line-height: 32px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.waveform-file-input {
|
||||
display: none;
|
||||
}
|
||||
@@ -198,6 +386,7 @@ const handleValueModeChange = (value: string | number | boolean | undefined) =>
|
||||
|
||||
.channel-select,
|
||||
.display-mode-select,
|
||||
.parse-type-select,
|
||||
.file-input,
|
||||
.value-mode-switch {
|
||||
width: 100%;
|
||||
|
||||
@@ -124,22 +124,27 @@ const trendToolGroups: Array<{
|
||||
}>
|
||||
}> = [
|
||||
{
|
||||
key: 'scale',
|
||||
key: 'viewport',
|
||||
items: [
|
||||
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
|
||||
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
|
||||
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
||||
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
||||
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
||||
{ action: 'mark', label: '标记', icon: Location },
|
||||
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
||||
{ action: 'pan', label: '平移', icon: Pointer }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'marker-view',
|
||||
items: [
|
||||
{ action: 'mark', label: '标记', icon: Location },
|
||||
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
items: [
|
||||
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
|
||||
{ action: 'download-image', label: '下载图片', icon: Picture },
|
||||
{ action: 'download-data', label: '下载数据', icon: Download }
|
||||
]
|
||||
@@ -234,7 +239,7 @@ const handleChartClick = (value: TrendChartClickPayload) => {
|
||||
|
||||
.trend-tool-group + .trend-tool-group {
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
border-left: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.trend-tool-group :deep(.el-button.is-circle) {
|
||||
|
||||
@@ -2,6 +2,7 @@ export type TrendTabValue = 'instant' | 'rms'
|
||||
export type ValueMode = 'primary' | 'secondary'
|
||||
export type DisplayMode = 'single-channel' | 'multi-channel'
|
||||
export type ChannelSelectValue = number | 'all'
|
||||
export type ParseType = 0 | 1 | 2 | 3
|
||||
export type TrendToolAction =
|
||||
| 'x-zoom-in'
|
||||
| 'x-zoom-out'
|
||||
@@ -24,6 +25,11 @@ export interface WaveformDetailOption {
|
||||
value: ChannelSelectValue
|
||||
}
|
||||
|
||||
export interface WaveformRatioValue {
|
||||
pt?: number
|
||||
ct?: number
|
||||
}
|
||||
|
||||
export interface SingleChannelTrendOption {
|
||||
key: string
|
||||
group: string
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<WaveformToolbar
|
||||
:selected-waveform-file-name="selectedWaveformFileName"
|
||||
:is-parsing="isParsing"
|
||||
:has-waveform-data="hasParsedWaveform"
|
||||
:waveform-file-accept="waveformFileAccept"
|
||||
:channel-options="channelOptions"
|
||||
:active-channel-index="activeChannelIndex"
|
||||
@@ -10,9 +11,15 @@
|
||||
:active-display-mode="activeDisplayMode"
|
||||
:value-mode-options="valueModeOptions"
|
||||
:active-value-mode="activeValueMode"
|
||||
:parse-type-options="parseTypeOptions"
|
||||
:active-parse-type="activeParseType"
|
||||
:pt-ratio="temporaryPtRatio"
|
||||
:ct-ratio="temporaryCtRatio"
|
||||
@update:active-channel-index="activeChannelIndex = $event"
|
||||
@update:active-display-mode="activeDisplayMode = $event"
|
||||
@update:active-value-mode="activeValueMode = $event"
|
||||
@update:active-parse-type="handleParseTypeChange"
|
||||
@update-waveform-ratio="handleWaveformRatioUpdate"
|
||||
@waveform-file-change="handleWaveformFileChange"
|
||||
/>
|
||||
|
||||
@@ -64,6 +71,7 @@ import type {
|
||||
DisplayMode,
|
||||
LabelValueOption,
|
||||
MarkerDataItem,
|
||||
ParseType,
|
||||
SingleChannelTrendOption,
|
||||
SummaryItem,
|
||||
TrendChartClickPayload,
|
||||
@@ -71,7 +79,8 @@ import type {
|
||||
TrendToolAction,
|
||||
TrendTabValue,
|
||||
ValueMode,
|
||||
WaveformDetailOption
|
||||
WaveformDetailOption,
|
||||
WaveformRatioValue
|
||||
} from './components/types'
|
||||
|
||||
defineOptions({
|
||||
@@ -112,6 +121,10 @@ const activeTrendTab = ref<TrendTabValue>('instant')
|
||||
const activeValueMode = ref<ValueMode>('primary')
|
||||
const activeDisplayMode = ref<DisplayMode>('single-channel')
|
||||
const activeChannelIndex = ref<ChannelSelectValue>('all')
|
||||
const activeParseType = ref<ParseType>(1)
|
||||
const sourceValueMode = ref<ValueMode>('secondary')
|
||||
const temporaryPtRatio = ref<number | undefined>()
|
||||
const temporaryCtRatio = ref<number | undefined>()
|
||||
const singleChannelTrendChartGroup = 'waveform-single-channel-sync'
|
||||
const allChannelTrendChartGroup = 'waveform-all-channel-sync'
|
||||
const isParsing = ref(false)
|
||||
@@ -142,6 +155,13 @@ const displayModeOptions: LabelValueOption<DisplayMode>[] = [
|
||||
{ label: '多通道', value: 'multi-channel' }
|
||||
]
|
||||
|
||||
const parseTypeOptions: LabelValueOption<ParseType>[] = [
|
||||
{ label: '高级算法(32-128)', value: 0 },
|
||||
{ label: '普通展示(多采样率取最小)', value: 1 },
|
||||
{ label: 'App抽点(32)', value: 2 },
|
||||
{ label: '原始波形', value: 3 }
|
||||
]
|
||||
|
||||
const trendLabelMap: Record<TrendTabValue, string> = {
|
||||
instant: '瞬时波形',
|
||||
rms: 'RMS 波形'
|
||||
@@ -197,6 +217,7 @@ const buildSeriesPoints = (list: number[][] | undefined, valueIndex: number) =>
|
||||
}
|
||||
|
||||
const normalizedWaveDetails = computed<Waveform.WaveDataDetail[]>(() => {
|
||||
const parseResult = waveformParseResult.value
|
||||
const waveData = waveformParseResult.value?.waveData
|
||||
const detailList = waveformParseResult.value?.waveDataDetails || []
|
||||
const instantList = waveData?.listWaveData
|
||||
@@ -214,7 +235,9 @@ const normalizedWaveDetails = computed<Waveform.WaveDataDetail[]>(() => {
|
||||
return {
|
||||
channelName: detail?.channelName || waveData?.channelNames?.[startColumnIndex] || `通道${index + 1}`,
|
||||
title: detail?.title || titleSlice.join(' / ') || `三相波形 ${index + 1}`,
|
||||
unit: detail?.unit || '',
|
||||
unit: detail?.unit || waveData?.unit || parseResult?.unit || '',
|
||||
totalChannels: detail?.totalChannels ?? waveData?.totalChannels ?? parseResult?.totalChannels,
|
||||
phaseCount: detail?.phaseCount ?? waveData?.phaseCount ?? parseResult?.phaseCount,
|
||||
a: detail?.a || waveTitles[startColumnIndex] || 'A相',
|
||||
b: detail?.b || waveTitles[startColumnIndex + 1] || 'B相',
|
||||
c: detail?.c || waveTitles[startColumnIndex + 2] || 'C相',
|
||||
@@ -254,21 +277,58 @@ const activeWaveDetail = computed(() => {
|
||||
|
||||
const activeWaveData = computed(() => waveformParseResult.value?.waveData)
|
||||
|
||||
const summaryWaveDetail = computed(() => {
|
||||
return activeWaveDetail.value || normalizedWaveDetails.value[0] || null
|
||||
})
|
||||
|
||||
const normalizeRatio = (value?: number) => {
|
||||
const ratio = Number(value)
|
||||
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1
|
||||
}
|
||||
|
||||
const isCurrentChannel = (channelName?: string) => {
|
||||
return (channelName || '').toUpperCase().startsWith('I')
|
||||
const isCurrentChannel = (detail: Waveform.WaveDataDetail | null) => {
|
||||
const unit = (detail?.unit || '').trim()
|
||||
|
||||
if (unit === 'A') return true
|
||||
|
||||
return (detail?.channelName || '').toUpperCase().startsWith('I')
|
||||
}
|
||||
|
||||
const isVoltageChannel = (detail: Waveform.WaveDataDetail | null) => {
|
||||
return !isCurrentChannel(detail)
|
||||
}
|
||||
|
||||
const resolveSourceValueMode = (value?: string): ValueMode => {
|
||||
if (value === 'P') return 'primary'
|
||||
if (value === 'S') return 'secondary'
|
||||
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
const resolveDisplayUnit = (detail: Waveform.WaveDataDetail | null) => {
|
||||
if (isCurrentChannel(detail)) return 'A'
|
||||
|
||||
return activeValueMode.value === 'primary' ? 'kV' : 'V'
|
||||
}
|
||||
|
||||
const resolveSummaryUnit = (detail: Waveform.WaveDataDetail | null) => {
|
||||
if (detail?.unit) return detail.unit
|
||||
|
||||
return resolveDisplayUnit(detail)
|
||||
}
|
||||
|
||||
const getValueScale = (detail: Waveform.WaveDataDetail | null) => {
|
||||
if (activeValueMode.value === 'secondary') return 1
|
||||
|
||||
const waveData = activeWaveData.value
|
||||
const ratio = isCurrentChannel(detail?.channelName) ? waveData?.ct : waveData?.pt
|
||||
return normalizeRatio(ratio)
|
||||
const parsedRatio = isCurrentChannel(detail) ? waveData?.ct : waveData?.pt
|
||||
const temporaryRatio = isCurrentChannel(detail) ? temporaryCtRatio.value : temporaryPtRatio.value
|
||||
const normalizedRatio = normalizeRatio(temporaryRatio ?? parsedRatio)
|
||||
|
||||
if (sourceValueMode.value === activeValueMode.value) return 1
|
||||
|
||||
// 后端返回值类型由 szValueType 标识,电压一次值按 kV 展示,二次值按 V 展示。
|
||||
if (sourceValueMode.value === 'secondary') return isVoltageChannel(detail) ? normalizedRatio / 1000 : normalizedRatio
|
||||
|
||||
return isVoltageChannel(detail) ? 1000 / normalizedRatio : 1 / normalizedRatio
|
||||
}
|
||||
|
||||
const activeValueScale = computed(() => getValueScale(activeWaveDetail.value))
|
||||
@@ -287,6 +347,16 @@ const formatNumber = (value: unknown, fractionDigits = 3) => {
|
||||
return `${Number(numberValue.toFixed(fractionDigits))}`
|
||||
}
|
||||
|
||||
const formatTrendTimeLabel = (value: unknown) => {
|
||||
const numberValue = Number(value)
|
||||
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return value === undefined || value === null || value === '' ? '--' : `${value}`
|
||||
}
|
||||
|
||||
return numberValue.toFixed(3)
|
||||
}
|
||||
|
||||
const formatWaveformTime = (value?: string) => {
|
||||
if (!value) return '--'
|
||||
|
||||
@@ -332,8 +402,8 @@ const buildTrendPayload = (
|
||||
const max = flatSeriesData.length ? Math.max(...flatSeriesData) : undefined
|
||||
|
||||
return {
|
||||
timeLabels: timeSource.map(point => formatNumber(point[0])),
|
||||
unit: detail?.unit || '',
|
||||
timeLabels: timeSource.map(point => formatTrendTimeLabel(point[0])),
|
||||
unit: resolveDisplayUnit(detail),
|
||||
min,
|
||||
max,
|
||||
series
|
||||
@@ -715,7 +785,7 @@ const trendMarkerColors: Record<TrendMarker['name'], string> = {
|
||||
}
|
||||
|
||||
function buildTrendMarkerLabel(marker: TrendMarker) {
|
||||
return `${marker.name}: ${formatNumber(marker.axisValue, 2)} ms`
|
||||
return `${marker.name}: ${formatTrendTimeLabel(marker.axisValue)} ms`
|
||||
}
|
||||
|
||||
function buildTrendMarkerLine(seriesList: WaveformSeriesItem[], showMarkerLabel: boolean) {
|
||||
@@ -773,7 +843,7 @@ const buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
|
||||
return (value: string | number, index: number) => {
|
||||
// 横坐标只保留首尾标签,避免波形点位过多时底部文字拥挤。
|
||||
if (index !== 0 && index !== lastIndex) return ''
|
||||
return formatNumber(value, 2)
|
||||
return formatTrendTimeLabel(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,7 +868,7 @@ const buildTrendTooltipFormatter = (unit: string, showTime = true) => {
|
||||
return `<div>${marker}${seriesName}<span style="float:right;margin-left:12px;font-weight:600;">${valueText}</span></div>`
|
||||
})
|
||||
.join('')
|
||||
const timeText = timeValue === undefined ? '--' : `${formatNumber(timeValue, 2)} ms`
|
||||
const timeText = timeValue === undefined ? '--' : `${formatTrendTimeLabel(timeValue)} ms`
|
||||
|
||||
if (!showTime) return valueRows
|
||||
|
||||
@@ -998,15 +1068,26 @@ const allChannelTrendGroups = computed<AllChannelTrendGroup[]>(() => {
|
||||
})
|
||||
|
||||
const summaryItems = computed<SummaryItem[]>(() => {
|
||||
const parseResult = waveformParseResult.value
|
||||
const waveData = activeWaveData.value
|
||||
const cfgData = waveData?.comtradeCfgDTO
|
||||
const detail = activeWaveDetail.value
|
||||
const summaryDetail = summaryWaveDetail.value
|
||||
|
||||
return [
|
||||
{ label: '录波开始', value: formatWaveformTime(cfgData?.timeStart) },
|
||||
{ label: '触发时间', value: formatWaveformTime(cfgData?.timeTrige) },
|
||||
{ label: '采样率', value: cfgData?.finalSampleRate ? `${cfgData.finalSampleRate} Hz` : '--' },
|
||||
{ label: '总通道数', value: cfgData?.nChannelNum ?? '--' },
|
||||
{
|
||||
label: '总通道数',
|
||||
value:
|
||||
detail?.totalChannels ??
|
||||
summaryDetail?.totalChannels ??
|
||||
waveData?.totalChannels ??
|
||||
parseResult?.totalChannels ??
|
||||
cfgData?.nChannelNum ??
|
||||
'--'
|
||||
},
|
||||
{
|
||||
label: '当前通道',
|
||||
value: isAllChannelsActive.value
|
||||
@@ -1015,9 +1096,19 @@ const summaryItems = computed<SummaryItem[]>(() => {
|
||||
? buildChannelLabel(detail, activeChannelIndex.value as number)
|
||||
: '--'
|
||||
},
|
||||
{ label: '单位', value: detail?.unit || '--' },
|
||||
{ label: '相别数量', value: waveData?.iPhasic ?? '--' },
|
||||
{ label: 'PT / CT', value: `${formatNumber(waveData?.pt, 2)} / ${formatNumber(waveData?.ct, 2)}` },
|
||||
{ label: '单位', value: summaryDetail ? resolveSummaryUnit(summaryDetail) : parseResult?.unit || '--' },
|
||||
{
|
||||
label: '相别数量',
|
||||
value:
|
||||
detail?.phaseCount ??
|
||||
summaryDetail?.phaseCount ??
|
||||
waveData?.phaseCount ??
|
||||
parseResult?.phaseCount ??
|
||||
waveData?.iPhasic ??
|
||||
'--'
|
||||
},
|
||||
{ label: 'PT变比', value: formatNumber(temporaryPtRatio.value ?? waveData?.pt) },
|
||||
{ label: 'CT变比', value: formatNumber(temporaryCtRatio.value ?? waveData?.ct) },
|
||||
{ label: '数据类型', value: valueModeLabelMap[activeValueMode.value] }
|
||||
]
|
||||
})
|
||||
@@ -1050,7 +1141,7 @@ const markerDataItems = computed<MarkerDataItem[]>(() => {
|
||||
// 标记数值统一移到波形信息区,图内只保留最后一张图的 T1/T2 时间标签。
|
||||
return trendMarkers.value.map(marker => ({
|
||||
name: marker.name,
|
||||
axisValueText: `${formatNumber(marker.axisValue, 2)} ms`,
|
||||
axisValueText: `${formatTrendTimeLabel(marker.axisValue)} ms`,
|
||||
rows: visibleMarkerSeriesRows.value
|
||||
.filter(item => marker.dataIndex >= 0 && marker.dataIndex < item.data.length)
|
||||
.map(item => {
|
||||
@@ -1148,10 +1239,28 @@ const resetSelectedWaveformFiles = () => {
|
||||
selectedDatFile.value = null
|
||||
waveformParseResult.value = null
|
||||
vectorParseResult.value = null
|
||||
sourceValueMode.value = 'secondary'
|
||||
temporaryPtRatio.value = undefined
|
||||
temporaryCtRatio.value = undefined
|
||||
activeValueMode.value = 'primary'
|
||||
lastParseErrorMessage.value = ''
|
||||
lastVectorParseErrorMessage.value = ''
|
||||
}
|
||||
|
||||
const handleWaveformRatioUpdate = (value: WaveformRatioValue) => {
|
||||
temporaryPtRatio.value = value.pt
|
||||
temporaryCtRatio.value = value.ct
|
||||
resetTrendToolState()
|
||||
}
|
||||
|
||||
const handleParseTypeChange = async (value: ParseType) => {
|
||||
activeParseType.value = value
|
||||
|
||||
if (!selectedCfgFile.value || !selectedDatFile.value || isParsing.value) return
|
||||
|
||||
await loadWaveformData(selectedCfgFile.value, selectedDatFile.value)
|
||||
}
|
||||
|
||||
const handleWaveformFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const fileList = Array.from(input.files || [])
|
||||
@@ -1200,19 +1309,30 @@ const loadWaveformData = async (cfgFile: File, datFile: File) => {
|
||||
const [waveformResult, vectorResult] = await Promise.allSettled([
|
||||
parseComtradeApi({
|
||||
cfgFile,
|
||||
datFile
|
||||
datFile,
|
||||
parseType: activeParseType.value
|
||||
}),
|
||||
parseComtradeVectorApi({
|
||||
cfgFile,
|
||||
datFile,
|
||||
parseType: 3
|
||||
parseType: activeParseType.value
|
||||
})
|
||||
])
|
||||
|
||||
if (waveformResult.status === 'fulfilled') {
|
||||
waveformParseResult.value = waveformResult.value.data
|
||||
const parseResult = waveformResult.value.data
|
||||
|
||||
waveformParseResult.value = parseResult
|
||||
sourceValueMode.value = resolveSourceValueMode(parseResult.waveData?.szValueType)
|
||||
temporaryPtRatio.value = parseResult.waveData?.pt
|
||||
temporaryCtRatio.value = parseResult.waveData?.ct
|
||||
activeValueMode.value = 'primary'
|
||||
} else {
|
||||
waveformParseResult.value = null
|
||||
sourceValueMode.value = 'secondary'
|
||||
temporaryPtRatio.value = undefined
|
||||
temporaryCtRatio.value = undefined
|
||||
activeValueMode.value = 'primary'
|
||||
lastParseErrorMessage.value = getWaveformParseErrorMessage(waveformResult.reason)
|
||||
|
||||
console.error('[waveform] parseComtrade failed', {
|
||||
@@ -1243,14 +1363,15 @@ const loadWaveformData = async (cfgFile: File, datFile: File) => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildTrendExportFileName = (extension: string) => {
|
||||
const buildTrendExportFileName = (extension: string, includeTrendLabel = true) => {
|
||||
const channelLabel = isAllChannelsActive.value
|
||||
? '全部'
|
||||
: activeWaveDetail.value
|
||||
? buildChannelLabel(activeWaveDetail.value, activeChannelIndex.value as number)
|
||||
: '波形'
|
||||
const trendLabel = includeTrendLabel ? trendLabelMap[activeTrendTab.value] : '数据'
|
||||
|
||||
return `波形查看_${channelLabel}_${valueModeLabelMap[activeValueMode.value]}_${trendLabelMap[activeTrendTab.value]}.${extension}`
|
||||
return `波形查看_${channelLabel}_${valueModeLabelMap[activeValueMode.value]}_${trendLabel}.${extension}`
|
||||
}
|
||||
|
||||
const downloadTrendImage = async () => {
|
||||
@@ -1281,49 +1402,259 @@ const downloadTrendImage = async () => {
|
||||
ElMessage.success('趋势图图片下载成功')
|
||||
}
|
||||
|
||||
interface TrendExportPayloadGroup {
|
||||
label: string
|
||||
instantPayload: WaveformTrendPayload
|
||||
rmsPayload: WaveformTrendPayload
|
||||
}
|
||||
|
||||
type TrendExportMetric = 'instant' | 'rms'
|
||||
|
||||
interface TrendExportColumn {
|
||||
metric: TrendExportMetric
|
||||
channelName: string
|
||||
unit: string
|
||||
phaseName: string
|
||||
data: number[]
|
||||
}
|
||||
|
||||
interface TrendExportValueRange {
|
||||
max: number
|
||||
min: number
|
||||
}
|
||||
|
||||
const trendExportMetricLabelMap: Record<TrendExportMetric, string> = {
|
||||
instant: '瞬时值',
|
||||
rms: 'RMS值'
|
||||
}
|
||||
|
||||
const hasTrendExportSeries = (group: TrendExportPayloadGroup) => {
|
||||
return group.instantPayload.series.length > 0 || group.rmsPayload.series.length > 0
|
||||
}
|
||||
|
||||
const buildTrendExportPayloadGroup = (detail: Waveform.WaveDataDetail | null, index: number) => {
|
||||
const scale = getValueScale(detail)
|
||||
|
||||
return {
|
||||
label: detail ? buildChannelLabel(detail, index) : '',
|
||||
instantPayload: buildTrendPayload(detail, 'instant', scale),
|
||||
rmsPayload: buildTrendPayload(detail, 'rms', scale)
|
||||
}
|
||||
}
|
||||
|
||||
const buildTrendExportColumns = (group: TrendExportPayloadGroup, metric: TrendExportMetric): TrendExportColumn[] => {
|
||||
const payload = metric === 'instant' ? group.instantPayload : group.rmsPayload
|
||||
|
||||
return payload.series.map(item => ({
|
||||
metric,
|
||||
channelName: group.label,
|
||||
unit: payload.unit,
|
||||
phaseName: item.name,
|
||||
data: item.data
|
||||
}))
|
||||
}
|
||||
|
||||
const resolveTrendExportTimeLabels = (groups: TrendExportPayloadGroup[]) => {
|
||||
return (
|
||||
groups.find(item => item.instantPayload.timeLabels.length)?.instantPayload.timeLabels ||
|
||||
groups.find(item => item.rmsPayload.timeLabels.length)?.rmsPayload.timeLabels ||
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
const escapeHtml = (value: unknown) => {
|
||||
const text = value === undefined || value === null ? '' : `${value}`
|
||||
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
const buildTrendExportColumnKey = (column: TrendExportColumn) => {
|
||||
return `${column.metric}-${column.channelName}`
|
||||
}
|
||||
|
||||
const resolveTrendExportColumnRanges = (columns: TrendExportColumn[]) => {
|
||||
const rangeMap = new Map<string, TrendExportValueRange>()
|
||||
|
||||
columns.forEach(column => {
|
||||
const key = buildTrendExportColumnKey(column)
|
||||
const currentRange = rangeMap.get(key)
|
||||
const nextRange = column.data.reduce<TrendExportValueRange | null>((range, value) => {
|
||||
if (!Number.isFinite(value)) return range
|
||||
|
||||
if (!range) {
|
||||
return {
|
||||
max: value,
|
||||
min: value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
max: Math.max(range.max, value),
|
||||
min: Math.min(range.min, value)
|
||||
}
|
||||
}, currentRange || null)
|
||||
|
||||
if (nextRange) {
|
||||
rangeMap.set(key, nextRange)
|
||||
}
|
||||
})
|
||||
|
||||
return rangeMap
|
||||
}
|
||||
|
||||
const resolveTrendExportCellClass = (
|
||||
column: TrendExportColumn,
|
||||
value: number | undefined,
|
||||
rangeMap: Map<string, TrendExportValueRange>
|
||||
) => {
|
||||
if (value === undefined || !Number.isFinite(value)) return ''
|
||||
|
||||
const range = rangeMap.get(buildTrendExportColumnKey(column))
|
||||
|
||||
if (!range) return ''
|
||||
if (value === range.max) return ' class="max-value-cell"'
|
||||
if (value === range.min) return ' class="min-value-cell"'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const buildTrendExportLegendRow = (columnCount: number) => {
|
||||
return [
|
||||
`<tr><th colspan="${columnCount}">`,
|
||||
'<span class="legend-item"><span class="legend-swatch max-value-cell"></span>最大值单元格标红色底</span>',
|
||||
'<span class="legend-item"><span class="legend-swatch min-value-cell"></span>最小值单元格标蓝色底</span>',
|
||||
'</th></tr>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
const buildTrendExportHeaderRows = (columns: TrendExportColumn[]) => {
|
||||
const metricGroups = (['instant', 'rms'] as TrendExportMetric[])
|
||||
.map(metric => ({
|
||||
metric,
|
||||
count: columns.filter(item => item.metric === metric).length
|
||||
}))
|
||||
.filter(item => item.count > 0)
|
||||
const channelGroups = columns.reduce<Array<{ key: string; label: string; unit: string; count: number }>>(
|
||||
(groups, column) => {
|
||||
const key = buildTrendExportColumnKey(column)
|
||||
const lastGroup = groups[groups.length - 1]
|
||||
|
||||
if (lastGroup?.key === key) {
|
||||
lastGroup.count += 1
|
||||
return groups
|
||||
}
|
||||
|
||||
groups.push({
|
||||
key,
|
||||
label: column.channelName,
|
||||
unit: column.unit,
|
||||
count: 1
|
||||
})
|
||||
|
||||
return groups
|
||||
},
|
||||
[]
|
||||
)
|
||||
const firstHeaderCells = [
|
||||
'<th rowspan="3">时间</th>',
|
||||
...metricGroups.map(item => `<th colspan="${item.count}">${trendExportMetricLabelMap[item.metric]}</th>`)
|
||||
]
|
||||
const channelHeaderCells = channelGroups.map(item => {
|
||||
const unitText = item.unit ? `<br/>单位:${escapeHtml(item.unit)}` : ''
|
||||
|
||||
return `<th colspan="${item.count}">${escapeHtml(item.label)}${unitText}</th>`
|
||||
})
|
||||
const secondHeaderCells = columns.map(item => `<th>${escapeHtml(item.phaseName)}</th>`)
|
||||
|
||||
return [
|
||||
buildTrendExportLegendRow(columns.length + 1),
|
||||
`<tr>${firstHeaderCells.join('')}</tr>`,
|
||||
`<tr>${channelHeaderCells.join('')}</tr>`,
|
||||
`<tr>${secondHeaderCells.join('')}</tr>`
|
||||
].join('')
|
||||
}
|
||||
|
||||
const buildTrendExportRows = (timeLabels: string[], columns: TrendExportColumn[]) => {
|
||||
const rangeMap = resolveTrendExportColumnRanges(columns)
|
||||
|
||||
return timeLabels
|
||||
.map((time, rowIndex) => {
|
||||
const dataCells = columns.map(item => {
|
||||
const value = item.data[rowIndex]
|
||||
const cellClass = resolveTrendExportCellClass(item, value, rangeMap)
|
||||
|
||||
return `<td${cellClass}>${escapeHtml(value ?? '')}</td>`
|
||||
})
|
||||
|
||||
return `<tr><td class="time-cell">${escapeHtml(formatTrendTimeLabel(time))}</td>${dataCells.join('')}</tr>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const buildTrendExportExcelHtml = (timeLabels: string[], columns: TrendExportColumn[]) => {
|
||||
return [
|
||||
'<!DOCTYPE html>',
|
||||
'<html>',
|
||||
'<head>',
|
||||
'<meta charset="UTF-8" />',
|
||||
'<style>',
|
||||
'table{border-collapse:collapse;}',
|
||||
'th,td{border:1px solid #999;padding:4px 8px;white-space:nowrap;}',
|
||||
'th{background:#f5f7fa;font-weight:700;text-align:center;}',
|
||||
'td{text-align:center;}',
|
||||
'.time-cell{mso-number-format:"0.000";}',
|
||||
'.max-value-cell{background:#f8cbad;}',
|
||||
'.min-value-cell{background:#bdd7ee;}',
|
||||
'.legend-item{display:inline-block;margin:0 12px;}',
|
||||
'.legend-swatch{display:inline-block;width:14px;height:14px;border:1px solid #999;margin-right:4px;vertical-align:middle;}',
|
||||
'</style>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
'<table>',
|
||||
'<thead>',
|
||||
buildTrendExportHeaderRows(columns),
|
||||
'</thead>',
|
||||
'<tbody>',
|
||||
buildTrendExportRows(timeLabels, columns),
|
||||
'</tbody>',
|
||||
'</table>',
|
||||
'</body>',
|
||||
'</html>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
const downloadTrendData = () => {
|
||||
if (!hasWaveformData.value) {
|
||||
ElMessage.warning('暂无可导出的波形数据')
|
||||
return
|
||||
}
|
||||
|
||||
const trendPayload = activeTrendPayload.value
|
||||
const allChannelPayloads = normalizedWaveDetails.value
|
||||
.map((detail, index) => ({
|
||||
label: buildChannelLabel(detail, index),
|
||||
payload: buildTrendPayload(detail, activeTrendTab.value, getValueScale(detail))
|
||||
}))
|
||||
.filter(item => item.payload.series.length > 0)
|
||||
const exportPayload = isAllChannelsActive.value ? allChannelPayloads[0]?.payload : trendPayload
|
||||
const exportGroups = (
|
||||
isAllChannelsActive.value
|
||||
? normalizedWaveDetails.value.map((detail, index) => buildTrendExportPayloadGroup(detail, index))
|
||||
: [buildTrendExportPayloadGroup(activeWaveDetail.value, activeChannelIndex.value as number)]
|
||||
).filter(hasTrendExportSeries)
|
||||
const timeLabels = resolveTrendExportTimeLabels(exportGroups)
|
||||
const exportColumns = [
|
||||
...exportGroups.flatMap(item => buildTrendExportColumns(item, 'instant')),
|
||||
...exportGroups.flatMap(item => buildTrendExportColumns(item, 'rms'))
|
||||
]
|
||||
|
||||
if (!exportPayload) {
|
||||
if (!timeLabels.length || !exportColumns.length) {
|
||||
ElMessage.warning('暂无可导出的波形数据')
|
||||
return
|
||||
}
|
||||
|
||||
const header = ['时间', ...trendPayload.series.map(item => item.name)]
|
||||
const allChannelHeader = [
|
||||
'时间',
|
||||
...allChannelPayloads.flatMap(item => item.payload.series.map(series => `${item.label}-${series.name}`))
|
||||
]
|
||||
const rows = exportPayload.timeLabels.map((time, index) => {
|
||||
if (isAllChannelsActive.value) {
|
||||
return [
|
||||
time,
|
||||
...allChannelPayloads.flatMap(item => item.payload.series.map(series => series.data[index] ?? ''))
|
||||
]
|
||||
}
|
||||
|
||||
return [time, ...trendPayload.series.map(item => item.data[index] ?? '')]
|
||||
})
|
||||
|
||||
const csvContent = [isAllChannelsActive.value ? allChannelHeader : header, ...rows]
|
||||
.map(row => row.join(','))
|
||||
.join('\n')
|
||||
const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' })
|
||||
const excelContent = buildTrendExportExcelHtml(timeLabels, exportColumns)
|
||||
const blob = new Blob([`\uFEFF${excelContent}`], { type: 'application/vnd.ms-excel;charset=utf-8;' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const exportFile = document.createElement('a')
|
||||
const fileName = buildTrendExportFileName('csv')
|
||||
const fileName = buildTrendExportFileName('xls', false)
|
||||
|
||||
exportFile.style.display = 'none'
|
||||
exportFile.download = fileName
|
||||
|
||||
Reference in New Issue
Block a user