feat(tools): 新增台账管理功能模块

- 添加 addLedger API 接口定义和实现
- 创建工程配置表单组件 (EngineeringForm)
- 创建设备配置表单组件 (EquipmentForm)
- 创建项目和测点表单组件 (ProjectForm, LineForm)
- 实现台账树形结构面板 (LedgerTreePanel)
- 添加台账数据验证契约检查脚本
- 集成字典选项和动态表单验证功能
- 实现台账节点增删改查完整流程
- 优化 Echarts 图表组件分组渲染性能
This commit is contained in:
2026-05-09 07:53:32 +08:00
parent a1e1fb124a
commit cd54bb676c
14 changed files with 2005 additions and 26 deletions

View File

@@ -0,0 +1,39 @@
/* 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 apiFile = path.join(currentDir, 'index.ts')
const interfaceFile = path.join(currentDir, 'interface', 'index.ts')
const apiSource = fs.readFileSync(apiFile, 'utf8')
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
const expectations = [
['equipment payload maps devType', /devType:\s*params\.dev_type/],
['equipment payload maps devModel', /devModel:\s*params\.dev_model/],
['equipment payload maps devAccessMethod', /devAccessMethod:\s*params\.dev_access_method/],
['equipment payload maps nodeId', /nodeId:\s*params\.node_id/],
['equipment payload maps nodeProcess', /nodeProcess:\s*resolveOptionalNumber\(params\.node_process\)/],
['line payload maps lineId', /lineId:\s*resolveOptionalText\(params\.line_id\s*\|\|\s*params\.id\)/],
['line payload maps lineNo', /lineNo:\s*params\.line_no/],
['line payload maps volGrade', /volGrade:\s*params\.vol_grade/],
['line payload maps ctRatio', /ctRatio:\s*params\.ct_ratio/],
['line payload maps isGovern', /isGovern:\s*params\.is_govern/],
['tree node supports parentId', /parentId\?:\s*string/],
['tree node supports parentIds', /parentIds\?:\s*string/],
['delete response type is boolean', /requestAddLedger<boolean>\('delete',\s*'\/node'/]
]
const failures = expectations.filter(([, pattern]) => !pattern.test(`${apiSource}\n${interfaceSource}`))
if (failures.length) {
console.error('addLedger API_DEBUG contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('addLedger API_DEBUG contract check passed')

View File

@@ -0,0 +1,172 @@
import http from '@/api'
import type { ResultData } from '@/api/interface'
import type { AddLedger } from './interface'
type AddLedgerRequestMethod = 'get' | 'post' | 'delete'
const ADD_LEDGER_ROUTE_PATHS = ['/addLedger', '/api/addLedger'] as const
const ADD_LEDGER_BASE_URL = String(import.meta.env.VITE_API_URL || '').trim()
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
}
const toAddLedgerProjectPayload = (params: AddLedger.ProjectForm) => ({
id: resolveOptionalText(params.id),
engineeringId: resolveOptionalText(params.engineeringId || params.parentId),
name: params.name,
area: params.area,
description: params.description
})
const toAddLedgerEquipmentPayload = (params: AddLedger.EquipmentForm) => ({
id: resolveOptionalText(params.id),
projectId: resolveOptionalText(params.projectId || params.parentId),
name: params.name,
ndid: params.ndid,
mac: params.mac,
devType: params.dev_type,
devModel: params.dev_model,
devAccessMethod: params.dev_access_method,
nodeId: params.node_id,
nodeProcess: resolveOptionalNumber(params.node_process),
upgrade: params.upgrade
})
const toAddLedgerLinePayload = (params: AddLedger.LineForm) => ({
lineId: resolveOptionalText(params.line_id || params.id),
deviceId: resolveOptionalText(params.deviceId || params.parentId),
name: params.name,
lineNo: params.line_no,
conType: params.conType,
volGrade: params.vol_grade,
position: params.position,
ctRatio: params.ct_ratio,
ct2Ratio: params.ct2_ratio,
ptRatio: params.pt_ratio,
pt2Ratio: params.pt2_ratio,
shortCircuitCapacity: params.short_circuit_capacity,
basicCapacity: params.basic_capacity,
protocolCapacity: params.protocol_capacity,
devCapacity: params.dev_capacity,
monitorObj: params.monitor_obj,
isGovern: params.is_govern,
monitorUser: params.monitor_user,
isImportant: params.is_important
})
const resolveDevProxyTarget = () => {
const proxyConfig = import.meta.env.VITE_PROXY
if (!Array.isArray(proxyConfig)) return ''
const matchedProxy = proxyConfig.find(item => Array.isArray(item) && item[0] === '/api')
if (!matchedProxy?.[1]) return ''
return String(matchedProxy[1]).replace(/\/+$/, '')
}
const buildAddLedgerRequestPaths = (path: string) => {
const requestPaths = new Set<string>()
const devProxyTarget = resolveDevProxyTarget()
for (const routePath of ADD_LEDGER_ROUTE_PATHS) {
if (ADD_LEDGER_BASE_URL === '/api' && routePath.startsWith('/api/')) {
if (devProxyTarget) {
requestPaths.add(`${devProxyTarget}${routePath}${path}`)
}
requestPaths.add(`${window.location.origin}${routePath}${path}`)
continue
}
requestPaths.add(`${routePath}${path}`)
}
return Array.from(requestPaths)
}
const isFallbackableAddLedgerError = (error: unknown) => {
const responseCode = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : ''
const responseMessage = typeof error === 'object' && error !== null && 'message' in error ? String(error.message) : ''
const normalizedMessage = responseMessage.toLowerCase()
return (
responseCode === '404' ||
normalizedMessage.includes('unknown operate') ||
normalizedMessage.includes('not found') ||
normalizedMessage.includes('no handler found')
)
}
const requestAddLedger = async <T>(
method: AddLedgerRequestMethod,
path: string,
params?: object
): Promise<ResultData<T>> => {
let lastError: unknown
const requestPaths = buildAddLedgerRequestPaths(path)
for (let index = 0; index < requestPaths.length; index += 1) {
const requestPath = requestPaths[index]
try {
if (method === 'get') {
return await http.get<T>(requestPath, params)
}
if (method === 'delete') {
return await http.delete<T>(requestPath, params)
}
return await http.post<T>(requestPath, params)
} catch (error) {
lastError = error
if (index === requestPaths.length - 1 || !isFallbackableAddLedgerError(error)) {
throw error
}
}
}
throw lastError
}
export const getAddLedgerTree = () => {
return requestAddLedger<AddLedger.LedgerTreeNode[]>('get', '/tree')
}
export const getAddLedgerDetail = (params: AddLedger.DetailParams) => {
return requestAddLedger<AddLedger.NodeDetail>('get', '/detail', params)
}
export const saveAddLedgerEngineering = (params: AddLedger.EngineeringForm) => {
return requestAddLedger<AddLedger.EngineeringForm>('post', '/engineering/save', params)
}
export const saveAddLedgerProject = (params: AddLedger.ProjectForm) => {
return requestAddLedger<AddLedger.ProjectForm>('post', '/project/save', toAddLedgerProjectPayload(params))
}
export const saveAddLedgerEquipment = (params: AddLedger.EquipmentForm) => {
return requestAddLedger<AddLedger.EquipmentForm>('post', '/equipment/save', toAddLedgerEquipmentPayload(params))
}
export const saveAddLedgerLine = (params: AddLedger.LineForm) => {
return requestAddLedger<AddLedger.LineForm>('post', '/line/save', toAddLedgerLinePayload(params))
}
export const getAvailableLineNos = (params: AddLedger.AvailableLineNoParams) => {
return requestAddLedger<number[]>('get', '/line/availableLineNos', params)
}
export const deleteAddLedgerNode = (params: AddLedger.DeleteNodeParams) => {
return requestAddLedger<boolean>('delete', '/node', params)
}

View File

@@ -0,0 +1,110 @@
export namespace AddLedger {
export type NodeLevel = 0 | 1 | 2 | 3
export interface LedgerTreeNode {
id?: string
Id?: string
pid?: string
Pid?: string
pids?: string
Pids?: string
parentId?: string
parentIds?: string
name?: string
Name?: string
level?: NodeLevel
Level?: NodeLevel
state?: number
State?: number
children?: LedgerTreeNode[]
}
export interface NormalizedTreeNode {
id: string
pid: string
pids: string
name: string
level: NodeLevel
children: NormalizedTreeNode[]
raw: LedgerTreeNode
}
export interface DetailParams {
id: string
level: NodeLevel
}
export interface EngineeringForm {
id?: string
name: string
province?: string
city?: string
description?: string
}
export interface ProjectForm {
id?: string
engineeringId?: string
parentId?: string
name: string
area?: string
description?: string
}
export interface EquipmentForm {
id?: string
engineeringId?: string
projectId?: string
parentId?: string
name: string
ndid: string
mac: string
dev_type?: string
dev_model: string
dev_access_method?: string
node_id?: string
node_process?: string
upgrade?: number
}
export interface LineForm {
id?: string
line_id?: string
deviceId?: string
parentId?: string
name: string
line_no?: number
conType?: number
vol_grade?: number
position?: string
ct_ratio?: number
ct2_ratio?: number
pt_ratio?: number
pt2_ratio?: number
short_circuit_capacity?: number
basic_capacity?: number
protocol_capacity?: number
dev_capacity?: number
monitor_obj?: string
is_govern?: number
monitor_user?: string
is_important?: number
}
export type NodeDetail = EngineeringForm | ProjectForm | EquipmentForm | LineForm
export interface AvailableLineNoParams {
deviceId: string
lineId?: string
}
export interface DeleteNodeParams {
id: string
level: NodeLevel
}
export interface SelectOption<T = string | number> {
label: string
value: T
}
}

View File

@@ -49,6 +49,7 @@ const emit = defineEmits<{
}>() }>()
let chart: echarts.ECharts | any = null let chart: echarts.ECharts | any = null
let isPanPointerDown = false let isPanPointerDown = false
let currentGroup: string | undefined
const getChartViewportRoot = () => chart?.getZr()?.painter?.getViewportRoot?.() as HTMLElement | undefined const getChartViewportRoot = () => chart?.getZr()?.painter?.getViewportRoot?.() as HTMLElement | undefined
@@ -199,18 +200,18 @@ const resizeHandler = () => {
chart.getZr().painter.getViewportRoot().style.display = '' chart.getZr().painter.getViewportRoot().style.display = ''
}) })
} }
const initChart = () => { const updateChartGroup = () => {
if (!props.isInterVal && !props.pieInterVal) { if (!chart || props.group === currentGroup) return
unbindPanCursorEvents()
chart?.dispose() currentGroup = props.group
} chart.group = props.group || undefined
// chart?.dispose()
chart = echarts.init(chartRef.value as HTMLDivElement)
if (props.group) { if (props.group) {
chart.group = props.group
echarts.connect(props.group) echarts.connect(props.group)
} }
}
const buildChartOptions = () => {
const options = { const options = {
title: { title: {
left: 'center', left: 'center',
@@ -295,7 +296,10 @@ const initChart = () => {
// console.log(options.series,"获取x轴"); // console.log(options.series,"获取x轴");
handlerBar(options) handlerBar(options)
chart.setOption(options, true) return options
}
const bindChartEvents = () => {
chart.off('datazoom') chart.off('datazoom')
chart.on('datazoom', (params: any) => { chart.on('datazoom', (params: any) => {
const zoomPayload = Array.isArray(params.batch) ? params.batch[0] : params const zoomPayload = Array.isArray(params.batch) ? params.batch[0] : params
@@ -309,16 +313,51 @@ const initChart = () => {
end end
}) })
}) })
bindPanCursorEvents()
}
const renderChart = (isInitial = false) => {
if (!chartRef.value) return
if (!chart) {
chart = echarts.init(chartRef.value)
updateChartGroup()
bindChartEvents()
} else {
updateChartGroup()
}
const options = buildChartOptions()
chart.setOption(options, {
notMerge: isInitial,
lazyUpdate: !isInitial
})
chart.dispatchAction({ chart.dispatchAction({
type: 'takeGlobalCursor', type: 'takeGlobalCursor',
key: 'dataZoomSelect', key: 'dataZoomSelect',
dataZoomSelectActive: props.options?.activeTool === 'box-zoom' dataZoomSelectActive: props.options?.activeTool === 'box-zoom'
}) })
bindPanCursorEvents() if (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark') {
resetChartCursor()
}
if (isInitial) {
setTimeout(() => { setTimeout(() => {
chart.resize() chart.resize()
}, 0) }, 0)
}
}
const initChart = () => {
if (!props.isInterVal && !props.pieInterVal) {
unbindPanCursorEvents()
chart?.dispose()
chart = null
currentGroup = undefined
}
renderChart(true)
} }
const handlerBar = (options: any) => { const handlerBar = (options: any) => {
if (Array.isArray(options.series)) { if (Array.isArray(options.series)) {
@@ -450,9 +489,10 @@ onBeforeUnmount(() => {
chart?.dispose() chart?.dispose()
}) })
watch( watch(
() => props.options, () => [props.options, props.group],
() => { () => {
initChart() // 缩放按钮只更新坐标和缩放配置,复用实例可避免大数据趋势图反复销毁重建。
renderChart()
} }
) )
</script> </script>

View File

@@ -67,6 +67,14 @@ export const staticRouter: RouteRecordRaw[] = [
title: '模拟数据' title: '模拟数据'
} }
}, },
{
path: '/tools/addLedger',
name: 'toolAddLedger',
component: () => import('@/views/tools/addLedger/index.vue'),
meta: {
title: '数据台账'
}
},
{ {
path: '/403', path: '/403',
name: '403', name: '403',

View File

@@ -0,0 +1,119 @@
<template>
<section class="card ledger-form-card">
<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>
<el-button type="primary" :icon="Check" :loading="saving" @click="emit('save')">保存工程</el-button>
<el-button type="danger" plain :icon="Delete" :disabled="!localForm.id" @click="emit('delete')">
删除工程
</el-button>
</div>
</div>
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="96px" class="ledger-form form-two">
<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-input v-model="localForm.province" maxlength="40" clearable placeholder="请输入省份" />
</el-form-item>
<el-form-item label="市" prop="city">
<el-input v-model="localForm.city" maxlength="40" clearable placeholder="请输入城市" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="localForm.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入工程描述"
/>
</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 type { FormInstance, FormRules } from 'element-plus'
import type { AddLedger } from '@/api/tools/addLedger/interface'
defineOptions({
name: 'EngineeringForm'
})
const props = defineProps<{
form: AddLedger.EngineeringForm
saving: boolean
}>()
const emit = defineEmits<{
'update:form': [form: AddLedger.EngineeringForm]
save: []
delete: []
'add-project': []
}>()
const formRef = ref<FormInstance>()
const syncingFromProp = ref(false)
const localForm = reactive<AddLedger.EngineeringForm>({
id: '',
name: '',
province: '',
city: '',
description: ''
})
const syncLocalForm = (form: AddLedger.EngineeringForm) => {
localForm.id = form.id || ''
localForm.name = form.name || ''
localForm.province = form.province || ''
localForm.city = form.city || ''
localForm.description = form.description || ''
}
watch(
() => props.form,
value => {
syncingFromProp.value = true
syncLocalForm(value)
},
{ deep: true, immediate: true }
)
watch(
localForm,
value => {
if (syncingFromProp.value) {
syncingFromProp.value = false
return
}
emit('update:form', { ...value })
},
{ deep: true }
)
const formRules: FormRules<AddLedger.EngineeringForm> = {
name: [{ required: true, message: '请输入工程名称', trigger: 'blur' }]
}
const validateForm = async () => {
const result = await formRef.value?.validate().catch(() => false)
return Boolean(result)
}
defineExpose({
validateForm
})
</script>
<style scoped lang="scss">
@use './ledgerForm.scss';
</style>

View File

@@ -0,0 +1,152 @@
<template>
<section class="card ledger-form-card">
<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>
<el-button type="primary" :icon="Check" :loading="saving" @click="emit('save')">保存设备</el-button>
<el-button type="danger" plain :icon="Delete" :disabled="!localForm.id" @click="emit('delete')">
删除设备
</el-button>
</div>
</div>
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="126px" class="ledger-form form-two">
<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-input v-model="localForm.ndid" maxlength="64" clearable placeholder="请输入网络设备 ID" />
</el-form-item>
<el-form-item label="装置 MAC 地址" prop="mac">
<el-input v-model="localForm.mac" maxlength="64" clearable placeholder="请输入装置 MAC 地址" />
</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-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-select>
</el-form-item>
<el-form-item 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-input v-model="localForm.node_id" maxlength="64" clearable placeholder="请输入前置服务器 IP" />
</el-form-item>
<el-form-item 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>
</el-form>
</section>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { Check, CirclePlus, Delete } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { AddLedger } from '@/api/tools/addLedger/interface'
defineOptions({
name: 'EquipmentForm'
})
const props = defineProps<{
form: AddLedger.EquipmentForm
saving: boolean
deviceTypeOptions: AddLedger.SelectOption[]
deviceModelOptions: AddLedger.SelectOption[]
}>()
const emit = defineEmits<{
'update:form': [form: AddLedger.EquipmentForm]
save: []
delete: []
'add-line': []
}>()
const formRef = ref<FormInstance>()
const syncingFromProp = ref(false)
const localForm = reactive<AddLedger.EquipmentForm>({
id: '',
engineeringId: '',
projectId: '',
parentId: '',
name: '',
ndid: '',
mac: '',
dev_type: '',
dev_model: '',
dev_access_method: '',
node_id: '',
node_process: '',
upgrade: 0
})
const syncLocalForm = (form: AddLedger.EquipmentForm) => {
localForm.id = form.id || ''
localForm.engineeringId = form.engineeringId || ''
localForm.projectId = form.projectId || ''
localForm.parentId = form.parentId || ''
localForm.name = form.name || ''
localForm.ndid = form.ndid || ''
localForm.mac = form.mac || ''
localForm.dev_type = form.dev_type || ''
localForm.dev_model = form.dev_model || ''
localForm.dev_access_method = form.dev_access_method || ''
localForm.node_id = form.node_id || ''
localForm.node_process = form.node_process || ''
localForm.upgrade = form.upgrade ?? 0
}
watch(
() => props.form,
value => {
syncingFromProp.value = true
syncLocalForm(value)
},
{ deep: true, immediate: true }
)
watch(
localForm,
value => {
if (syncingFromProp.value) {
syncingFromProp.value = false
return
}
emit('update:form', { ...value })
},
{ deep: true }
)
const formRules: FormRules<AddLedger.EquipmentForm> = {
name: [{ required: true, message: '请输入装置名称', trigger: 'blur' }],
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)
return Boolean(result)
}
defineExpose({
validateForm
})
</script>
<style scoped lang="scss">
@use './ledgerForm.scss';
</style>

View File

@@ -0,0 +1,175 @@
<template>
<section class="card ledger-tree-card">
<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>
</div>
</div>
<el-input v-model="keyword" class="tree-search" clearable placeholder="搜索工程、项目、设备、监测点" />
<el-scrollbar class="tree-scrollbar">
<el-tree
ref="treeRef"
:data="treeData"
node-key="id"
highlight-current
default-expand-all
:expand-on-click-node="false"
:filter-node-method="filterNode"
:props="{ label: 'name', children: 'children' }"
@node-click="handleNodeClick"
>
<template #default="{ data }">
<div class="ledger-tree-node">
<el-tag size="small" effect="plain" :type="resolveTagType(data.level)">
{{ resolveLevelName(data.level) }}
</el-tag>
<span class="node-name">{{ data.name }}</span>
</div>
</template>
</el-tree>
</el-scrollbar>
</section>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { Plus, Refresh } from '@element-plus/icons-vue'
import type { TreeInstance } from 'element-plus'
import type { AddLedger } from '@/api/tools/addLedger/interface'
defineOptions({
name: 'LedgerTreePanel'
})
const props = defineProps<{
treeData: AddLedger.NormalizedTreeNode[]
selectedId: string
loading: boolean
}>()
const emit = defineEmits<{
select: [node: AddLedger.NormalizedTreeNode]
refresh: []
'add-engineering': []
}>()
const treeRef = ref<TreeInstance>()
const keyword = ref('')
const levelNames: Record<AddLedger.NodeLevel, string> = {
0: '工程',
1: '项目',
2: '设备',
3: '测点'
}
const resolveTagType = (value: unknown) => {
const level = normalizeLevel(value)
const tagTypes: Record<AddLedger.NodeLevel, 'primary' | 'success' | 'warning' | 'info'> = {
0: 'primary',
1: 'success',
2: 'warning',
3: 'info'
}
return tagTypes[level]
}
const normalizeLevel = (value: unknown): AddLedger.NodeLevel => {
const level = Number(value)
if (level === 0 || level === 1 || level === 2 || level === 3) {
return level
}
return 0
}
const resolveLevelName = (value: unknown) => {
return levelNames[normalizeLevel(value)]
}
const filterNode = (value: string, data: unknown) => {
if (!value) return true
const node = data as Partial<AddLedger.NormalizedTreeNode>
return String(node.name || '')
.toLowerCase()
.includes(value.trim().toLowerCase())
}
const handleNodeClick = (node: AddLedger.NormalizedTreeNode) => {
emit('select', node)
}
watch(keyword, value => {
treeRef.value?.filter(value)
})
watch(
() => props.selectedId,
async value => {
await nextTick()
treeRef.value?.setCurrentKey(value || undefined)
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.ledger-tree-card {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
min-height: 0;
padding: 16px;
}
.ledger-tree-header {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.header-actions :deep(.el-button) {
margin-left: 0;
}
.tree-search {
flex: none;
}
.tree-scrollbar {
flex: 1;
min-height: 0;
}
.ledger-tree-node {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,254 @@
<template>
<section class="card ledger-form-card">
<div class="form-header">
<div>
<div class="section-title">监测点配置</div>
<div class="section-description">维护线路号接线方式电压等级和 CT/PT 参数</div>
</div>
<div 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>
</div>
</div>
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="146px" class="ledger-form form-two">
<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-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-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-select>
</el-form-item>
<el-form-item 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>
<el-form-item label="CT 二次额定值" prop="ct2_ratio">
<el-input-number v-model="localForm.ct2_ratio" :min="0" :precision="3" 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" />
</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>
<el-form-item label="最小短路容量(MVA)" prop="short_circuit_capacity">
<el-input-number
v-model="localForm.short_circuit_capacity"
:min="0"
:precision="3"
controls-position="right"
/>
</el-form-item>
<el-form-item label="基准短路容量(MVA)" prop="basic_capacity">
<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-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-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>
<el-form-item 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-switch
v-model="localForm.is_important"
:active-value="1"
:inactive-value="0"
active-text=""
inactive-text=""
/>
</el-form-item>
</el-form>
</section>
</template>
<script setup lang="ts">
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'
defineOptions({
name: 'LineForm'
})
const props = defineProps<{
form: AddLedger.LineForm
saving: boolean
lineNoOptions: AddLedger.SelectOption<number>[]
}>()
const emit = defineEmits<{
'update:form': [form: AddLedger.LineForm]
save: []
delete: []
}>()
const formRef = ref<FormInstance>()
const syncingFromProp = ref(false)
const localForm = reactive<AddLedger.LineForm>({
id: '',
line_id: '',
deviceId: '',
parentId: '',
name: '',
line_no: undefined,
conType: undefined,
vol_grade: undefined,
position: '',
ct_ratio: undefined,
ct2_ratio: undefined,
pt_ratio: undefined,
pt2_ratio: undefined,
short_circuit_capacity: undefined,
basic_capacity: undefined,
protocol_capacity: undefined,
dev_capacity: undefined,
monitor_obj: '',
is_govern: 0,
monitor_user: '',
is_important: 0
})
const conTypeOptions: AddLedger.SelectOption<number>[] = [
{ label: '星型接线', value: 0 },
{ label: '角型接线', value: 1 },
{ label: 'V 型接线', value: 2 }
]
const voltageOptions: AddLedger.SelectOption<number>[] = [
{ label: '0.38kV', value: 0.38 },
{ label: '10kV', value: 10 },
{ label: '35kV', value: 35 },
{ label: '110kV', value: 110 },
{ label: '220kV', value: 220 },
{ label: '500kV', value: 500 }
]
const lineId = computed(() => localForm.line_id || localForm.id || '')
const syncLocalForm = (form: AddLedger.LineForm) => {
localForm.id = form.id || ''
localForm.line_id = form.line_id || ''
localForm.deviceId = form.deviceId || ''
localForm.parentId = form.parentId || ''
localForm.name = form.name || ''
localForm.line_no = form.line_no
localForm.conType = form.conType
localForm.vol_grade = form.vol_grade
localForm.position = form.position || ''
localForm.ct_ratio = form.ct_ratio
localForm.ct2_ratio = form.ct2_ratio
localForm.pt_ratio = form.pt_ratio
localForm.pt2_ratio = form.pt2_ratio
localForm.short_circuit_capacity = form.short_circuit_capacity
localForm.basic_capacity = form.basic_capacity
localForm.protocol_capacity = form.protocol_capacity
localForm.dev_capacity = form.dev_capacity
localForm.monitor_obj = form.monitor_obj || ''
localForm.is_govern = form.is_govern ?? 0
localForm.monitor_user = form.monitor_user || ''
localForm.is_important = form.is_important ?? 0
}
watch(
() => props.form,
value => {
syncingFromProp.value = true
syncLocalForm(value)
},
{ deep: true, immediate: true }
)
watch(
localForm,
value => {
if (syncingFromProp.value) {
syncingFromProp.value = false
return
}
emit('update:form', { ...value })
},
{ deep: true }
)
const requiredNumberRule = (message: string) => ({
validator: (_rule: unknown, value: number | undefined, callback: (error?: Error) => void) => {
if (value === undefined || value === null) {
callback(new Error(message))
return
}
callback()
},
trigger: 'change'
})
const nonNegativeRule = (message: string) => ({
validator: (_rule: unknown, value: number | undefined, callback: (error?: Error) => void) => {
if (value === undefined || value === null) {
callback()
return
}
if (Number(value) < 0) {
callback(new Error(message))
return
}
callback()
},
trigger: 'change'
})
const formRules: FormRules<AddLedger.LineForm> = {
name: [{ required: true, message: '请输入监测点名', trigger: 'blur' }],
line_no: [requiredNumberRule('请选择设备线路')],
conType: [requiredNumberRule('请选择接线方式')],
vol_grade: [requiredNumberRule('请选择电压等级')],
ct_ratio: [requiredNumberRule('请输入 CT 一次额定值'), nonNegativeRule('CT 一次额定值不能小于 0')],
ct2_ratio: [requiredNumberRule('请输入 CT 二次额定值'), nonNegativeRule('CT 二次额定值不能小于 0')],
pt_ratio: [requiredNumberRule('请输入 PT 一次额定值'), nonNegativeRule('PT 一次额定值不能小于 0')],
pt2_ratio: [requiredNumberRule('请输入 PT 二次额定值'), nonNegativeRule('PT 二次额定值不能小于 0')],
short_circuit_capacity: [nonNegativeRule('最小短路容量不能小于 0')],
basic_capacity: [nonNegativeRule('基准短路容量不能小于 0')],
protocol_capacity: [nonNegativeRule('用户协议容量不能小于 0')],
dev_capacity: [nonNegativeRule('供电设备容量不能小于 0')]
}
const validateForm = async () => {
const result = await formRef.value?.validate().catch(() => false)
return Boolean(result)
}
defineExpose({
validateForm
})
</script>
<style scoped lang="scss">
@use './ledgerForm.scss';
</style>

View File

@@ -0,0 +1,118 @@
<template>
<section class="card ledger-form-card">
<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>
<el-button type="primary" :icon="Check" :loading="saving" @click="emit('save')">保存项目</el-button>
<el-button type="danger" plain :icon="Delete" :disabled="!localForm.id" @click="emit('delete')">
删除项目
</el-button>
</div>
</div>
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="96px" class="ledger-form form-two">
<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-input v-model="localForm.area" maxlength="80" clearable placeholder="请输入相对位置" />
</el-form-item>
<el-form-item label="项目描述" prop="description">
<el-input
v-model="localForm.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入项目描述"
/>
</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 type { FormInstance, FormRules } from 'element-plus'
import type { AddLedger } from '@/api/tools/addLedger/interface'
defineOptions({
name: 'ProjectForm'
})
const props = defineProps<{
form: AddLedger.ProjectForm
saving: boolean
}>()
const emit = defineEmits<{
'update:form': [form: AddLedger.ProjectForm]
save: []
delete: []
'add-equipment': []
}>()
const formRef = ref<FormInstance>()
const syncingFromProp = ref(false)
const localForm = reactive<AddLedger.ProjectForm>({
id: '',
engineeringId: '',
parentId: '',
name: '',
area: '',
description: ''
})
const syncLocalForm = (form: AddLedger.ProjectForm) => {
localForm.id = form.id || ''
localForm.engineeringId = form.engineeringId || ''
localForm.parentId = form.parentId || ''
localForm.name = form.name || ''
localForm.area = form.area || ''
localForm.description = form.description || ''
}
watch(
() => props.form,
value => {
syncingFromProp.value = true
syncLocalForm(value)
},
{ deep: true, immediate: true }
)
watch(
localForm,
value => {
if (syncingFromProp.value) {
syncingFromProp.value = false
return
}
emit('update:form', { ...value })
},
{ deep: true }
)
const formRules: FormRules<AddLedger.ProjectForm> = {
name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }]
}
const validateForm = async () => {
const result = await formRef.value?.validate().catch(() => false)
return Boolean(result)
}
defineExpose({
validateForm
})
</script>
<style scoped lang="scss">
@use './ledgerForm.scss';
</style>

View File

@@ -0,0 +1,61 @@
.ledger-form-card {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
padding: 16px;
}
.form-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 12px;
}
.section-title {
font-size: 15px;
font-weight: 600;
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;
justify-content: flex-end;
}
.form-actions :deep(.el-button) {
margin-left: 0;
}
.ledger-form {
flex: 1;
min-height: 0;
overflow: auto;
}
.ledger-form :deep(.el-input-number) {
width: 100%;
}
.ledger-form :deep(.el-textarea) {
width: 100%;
}
@media (max-width: 900px) {
.ledger-form.form-two :deep(.el-form-item) {
width: 98%;
}
}

View File

@@ -0,0 +1,718 @@
<template>
<div class="table-box add-ledger-page">
<div class="add-ledger-layout">
<LedgerTreePanel
:tree-data="treeData"
:selected-id="selectedNode?.id || ''"
:loading="loading.tree"
@select="handleSelectNode"
@refresh="loadTree"
@add-engineering="handleAddEngineering"
/>
<section class="add-ledger-main">
<el-alert
v-if="loading.detail"
class="detail-alert"
type="info"
:closable="false"
show-icon
title="正在加载节点详情"
/>
<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"
: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"
@delete="handleDeleteNode"
/>
<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>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CirclePlus } from '@element-plus/icons-vue'
import {
deleteAddLedgerNode,
getAddLedgerDetail,
getAddLedgerTree,
getAvailableLineNos,
saveAddLedgerEngineering,
saveAddLedgerEquipment,
saveAddLedgerLine,
saveAddLedgerProject
} from '@/api/tools/addLedger'
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'
defineOptions({
name: 'AddLedgerView'
})
type FormExpose = {
validateForm: () => Promise<boolean>
}
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 loading = reactive({
tree: false,
detail: false,
saving: false,
lineNos: false
})
const engineeringFormRef = ref<FormExpose>()
const projectFormRef = ref<FormExpose>()
const equipmentFormRef = ref<FormExpose>()
const lineFormRef = ref<FormExpose>()
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 fallbackDeviceTypeOptions: AddLedger.SelectOption[] = [
{ label: '直连设备', value: 'direct_device' },
{ label: '网关', value: 'gateway' },
{ label: '装置', value: 'device' }
]
const fallbackDeviceModelOptions: AddLedger.SelectOption[] = [
{ label: 'PQS588', value: 'pqs588' },
{ label: 'PQS680', value: 'pqs680' }
]
const deviceTypeOptions = computed(() => resolveDictOptions('ledger_device_type', fallbackDeviceTypeOptions))
const deviceModelOptions = computed(() => resolveDictOptions('ledger_device_model', fallbackDeviceModelOptions))
function createEmptyEngineeringForm(): AddLedger.EngineeringForm {
return {
id: '',
name: '',
province: '',
city: '',
description: ''
}
}
function createEmptyProjectForm(parentEngineeringId = ''): AddLedger.ProjectForm {
return {
id: '',
engineeringId: parentEngineeringId,
parentId: parentEngineeringId,
name: '',
area: '',
description: ''
}
}
function createEmptyEquipmentForm(parentProjectId = '', parentEngineeringId = ''): AddLedger.EquipmentForm {
return {
id: '',
engineeringId: parentEngineeringId,
projectId: parentProjectId,
parentId: parentProjectId,
name: '',
ndid: '',
mac: '',
dev_type: '',
dev_model: '',
dev_access_method: '',
node_id: '',
node_process: '',
upgrade: 0
}
}
function createEmptyLineForm(parentDeviceId = ''): AddLedger.LineForm {
return {
id: '',
line_id: generateGuidText(),
deviceId: parentDeviceId,
parentId: parentDeviceId,
name: '',
line_no: undefined,
conType: undefined,
vol_grade: undefined,
position: '',
ct_ratio: undefined,
ct2_ratio: undefined,
pt_ratio: undefined,
pt2_ratio: undefined,
short_circuit_capacity: undefined,
basic_capacity: undefined,
protocol_capacity: undefined,
dev_capacity: undefined,
monitor_obj: '',
is_govern: 0,
monitor_user: '',
is_important: 0
}
}
const resolveDictOptions = (code: string, fallback: AddLedger.SelectOption[]) => {
const dictData = dictStore.getDictData(code)
if (!dictData.length) return fallback
return dictData.map(item => ({
label: item.name,
value: item.id
}))
}
const resolveString = (data: Record<string, unknown>, ...keys: string[]) => {
for (const key of keys) {
const value = data[key]
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
const resolveNumber = (data: Record<string, unknown>, ...keys: string[]) => {
for (const key of keys) {
const value = data[key]
if (value === null || value === undefined || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
const normalizeLevel = (value: unknown): AddLedger.NodeLevel => {
const level = Number(value)
if (level === 0 || level === 1 || level === 2 || level === 3) {
return level
}
return 0
}
const normalizeTreeNode = (node: AddLedger.LedgerTreeNode): AddLedger.NormalizedTreeNode => {
const rawNode = node as Record<string, unknown>
const id = resolveString(rawNode, 'id', 'Id')
const children = Array.isArray(node.children) ? node.children.map(normalizeTreeNode).filter(item => item.id) : []
return {
id,
pid: resolveString(rawNode, 'parentId', 'pid', 'Pid'),
pids: resolveString(rawNode, 'parentIds', 'pids', 'Pids'),
name: resolveString(rawNode, 'name', 'Name') || id || '--',
level: normalizeLevel(rawNode.level ?? rawNode.Level),
children,
raw: node
}
}
const findNodePath = (
nodes: AddLedger.NormalizedTreeNode[],
id: string,
path: AddLedger.NormalizedTreeNode[] = []
): AddLedger.NormalizedTreeNode[] => {
for (const node of nodes) {
const nextPath = [...path, node]
if (node.id === id) return nextPath
const matchedPath = findNodePath(node.children, id, nextPath)
if (matchedPath.length) return matchedPath
}
return []
}
const getCurrentPath = () => {
if (!selectedNode.value?.id) return []
return findNodePath(treeData.value, selectedNode.value.id)
}
const resolveContext = () => {
const path = getCurrentPath()
return {
engineeringId: path.find(item => item.level === 0)?.id || '',
projectId: path.find(item => item.level === 1)?.id || '',
deviceId: path.find(item => item.level === 2)?.id || ''
}
}
function generateGuidText() {
return window.crypto.randomUUID().replace(/-/g, '')
}
const normalizeEngineeringDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode
): AddLedger.EngineeringForm => {
const data = (detail || {}) as Record<string, unknown>
return {
id: resolveString(data, 'id', 'engineeringId') || node?.id || '',
name: resolveString(data, 'name') || node?.name || '',
province: resolveString(data, 'province'),
city: resolveString(data, 'city'),
description: resolveString(data, 'description')
}
}
const normalizeProjectDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode
): AddLedger.ProjectForm => {
const data = (detail || {}) as Record<string, unknown>
const context = resolveContext()
return {
id: resolveString(data, 'id', 'projectId') || node?.id || '',
engineeringId: resolveString(data, 'engineeringId', 'associated_engineering') || context.engineeringId,
parentId: context.engineeringId,
name: resolveString(data, 'name') || node?.name || '',
area: resolveString(data, 'area'),
description: resolveString(data, 'description')
}
}
const normalizeEquipmentDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode
): AddLedger.EquipmentForm => {
const data = (detail || {}) as Record<string, unknown>
const context = resolveContext()
return {
id: resolveString(data, 'id', 'equipmentId') || node?.id || '',
engineeringId: resolveString(data, 'engineeringId', 'associated_engineering') || context.engineeringId,
projectId: resolveString(data, 'projectId', 'associated_project') || context.projectId,
parentId: context.projectId,
name: resolveString(data, 'name') || node?.name || '',
ndid: resolveString(data, 'ndid'),
mac: resolveString(data, 'mac'),
dev_type: resolveString(data, 'dev_type', 'devType'),
dev_model: resolveString(data, 'dev_model', 'devModel'),
dev_access_method: resolveString(data, 'dev_access_method', 'devAccessMethod'),
node_id: resolveString(data, 'node_id', 'nodeId'),
node_process: resolveString(data, 'node_process', 'nodeProcess'),
upgrade: resolveNumber(data, 'upgrade') ?? 0
}
}
const normalizeLineDetail = (detail: AddLedger.NodeDetail | null, node?: AddLedger.NormalizedTreeNode): AddLedger.LineForm => {
const data = (detail || {}) as Record<string, unknown>
const context = resolveContext()
const lineId = resolveString(data, 'line_id', 'lineId', 'id') || node?.id || generateGuidText()
return {
id: resolveString(data, 'id') || lineId,
line_id: lineId,
deviceId: resolveString(data, 'deviceId', 'device_id') || context.deviceId,
parentId: context.deviceId,
name: resolveString(data, 'name') || node?.name || '',
line_no: resolveNumber(data, 'line_no', 'lineNo'),
conType: resolveNumber(data, 'conType'),
vol_grade: resolveNumber(data, 'vol_grade', 'volGrade'),
position: resolveString(data, 'position'),
ct_ratio: resolveNumber(data, 'ct_ratio', 'ctRatio'),
ct2_ratio: resolveNumber(data, 'ct2_ratio', 'ct2Ratio'),
pt_ratio: resolveNumber(data, 'pt_ratio', 'ptRatio'),
pt2_ratio: resolveNumber(data, 'pt2_ratio', 'pt2Ratio'),
short_circuit_capacity: resolveNumber(data, 'short_circuit_capacity', 'shortCircuitCapacity'),
basic_capacity: resolveNumber(data, 'basic_capacity', 'basicCapacity'),
protocol_capacity: resolveNumber(data, 'protocol_capacity', 'protocolCapacity'),
dev_capacity: resolveNumber(data, 'dev_capacity', 'devCapacity'),
monitor_obj: resolveString(data, 'monitor_obj', 'monitorObj'),
is_govern: resolveNumber(data, 'is_govern', 'isGovern') ?? 0,
monitor_user: resolveString(data, 'monitor_user', 'monitorUser'),
is_important: resolveNumber(data, 'is_important', 'isImportant') ?? 0
}
}
const buildLineNoOptions = (lineNos: number[], currentLineNo?: number) => {
const values = new Set(lineNos.filter(item => Number.isInteger(item) && item >= 1 && item <= 20))
if (currentLineNo && currentLineNo >= 1 && currentLineNo <= 20) {
values.add(currentLineNo)
}
return Array.from(values)
.sort((left, right) => left - right)
.map(item => ({ label: `${item} 号线路`, value: item }))
}
const loadAvailableLineNoOptions = async (deviceId: string, lineId = '', currentLineNo?: number) => {
if (!deviceId) {
lineNoOptions.value = []
return
}
loading.lineNos = true
try {
const response = await getAvailableLineNos({ deviceId, lineId })
const availableNos = Array.isArray(response.data) ? response.data.map(item => Number(item)) : []
lineNoOptions.value = buildLineNoOptions(availableNos, currentLineNo)
} finally {
loading.lineNos = false
}
}
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) : []
treeData.value = nextTree
if (selectedNode.value?.id) {
const nextPath = findNodePath(nextTree, selectedNode.value.id)
selectedNode.value = nextPath[nextPath.length - 1] || null
}
} finally {
loading.tree = false
}
}
const loadNodeDetail = async (node: AddLedger.NormalizedTreeNode) => {
selectedNode.value = node
activeLevel.value = node.level
loading.detail = true
try {
const response = await getAddLedgerDetail({ id: node.id, level: node.level })
const detail = response.data || null
if (node.level === 0) {
engineeringForm.value = normalizeEngineeringDetail(detail, node)
return
}
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)
} finally {
loading.detail = false
}
}
const handleSelectNode = (node: AddLedger.NormalizedTreeNode) => {
void loadNodeDetail(node)
}
const handleAddEngineering = () => {
selectedNode.value = null
activeLevel.value = 0
engineeringForm.value = createEmptyEngineeringForm()
}
const handleAddProject = () => {
const context = resolveContext()
const engineeringId = activeLevel.value === 0 ? engineeringForm.value.id || selectedNode.value?.id || '' : context.engineeringId
if (!engineeringId) {
ElMessage.warning('请先选择或保存父级工程')
return
}
selectedNode.value = null
activeLevel.value = 1
projectForm.value = createEmptyProjectForm(engineeringId)
}
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
if (!projectId) {
ElMessage.warning('请先选择或保存父级项目')
return
}
selectedNode.value = null
activeLevel.value = 2
equipmentForm.value = createEmptyEquipmentForm(projectId, engineeringId)
}
const handleAddLine = async () => {
const context = resolveContext()
const deviceId = activeLevel.value === 2 ? equipmentForm.value.id || selectedNode.value?.id || '' : context.deviceId
if (!deviceId) {
ElMessage.warning('请先选择或保存父级设备')
return
}
await loadAvailableLineNoOptions(deviceId)
if (!lineNoOptions.value.length) {
ElMessage.warning('当前设备 1-20 号线路已全部占用,无法继续新增测点')
return
}
selectedNode.value = null
activeLevel.value = 3
lineForm.value = createEmptyLineForm(deviceId)
}
const validateForm = async (formRef: FormExpose | undefined) => {
const isValid = await formRef?.validateForm()
return Boolean(isValid)
}
const refreshAfterSave = async (id?: string) => {
await loadTree()
if (!id) return
const path = findNodePath(treeData.value, id)
const node = path[path.length - 1]
if (node) {
await loadNodeDetail(node)
}
}
const handleSaveEngineering = async () => {
if (!(await validateForm(engineeringFormRef.value))) return
loading.saving = true
try {
const response = await saveAddLedgerEngineering(engineeringForm.value)
const savedId = response.data?.id || engineeringForm.value.id
ElMessage.success('工程保存成功')
await refreshAfterSave(savedId)
} finally {
loading.saving = false
}
}
const handleSaveProject = async () => {
if (!(await validateForm(projectFormRef.value))) return
if (!projectForm.value.parentId && !projectForm.value.engineeringId) {
ElMessage.warning('缺少父级工程,无法保存项目')
return
}
loading.saving = true
try {
const response = await saveAddLedgerProject(projectForm.value)
const savedId = response.data?.id || projectForm.value.id
ElMessage.success('项目保存成功')
await refreshAfterSave(savedId)
} finally {
loading.saving = false
}
}
const handleSaveEquipment = async () => {
if (!(await validateForm(equipmentFormRef.value))) return
if (!equipmentForm.value.parentId && !equipmentForm.value.projectId) {
ElMessage.warning('缺少父级项目,无法保存设备')
return
}
loading.saving = true
try {
const response = await saveAddLedgerEquipment(equipmentForm.value)
const savedId = response.data?.id || equipmentForm.value.id
ElMessage.success('设备保存成功')
await refreshAfterSave(savedId)
} finally {
loading.saving = false
}
}
const handleSaveLine = async () => {
if (!(await validateForm(lineFormRef.value))) return
if (!lineForm.value.parentId && !lineForm.value.deviceId) {
ElMessage.warning('缺少父级设备,无法保存测点')
return
}
if (!lineForm.value.line_id) {
// 新增测点必须带 32 位 line_id后端仍负责最终唯一性校验。
lineForm.value.line_id = generateGuidText()
}
loading.saving = true
try {
const response = await saveAddLedgerLine(lineForm.value)
const savedId = response.data?.line_id || response.data?.id || lineForm.value.line_id || lineForm.value.id
ElMessage.success('测点保存成功')
await refreshAfterSave(savedId)
} finally {
loading.saving = false
}
}
const resolveActiveNodeId = () => {
if (activeLevel.value === 0) return engineeringForm.value.id
if (activeLevel.value === 1) return projectForm.value.id
if (activeLevel.value === 2) return equipmentForm.value.id
if (activeLevel.value === 3) return lineForm.value.line_id || lineForm.value.id
return ''
}
const resolveDeleteMessage = () => {
if (activeLevel.value === 0) return '确认删除当前工程?删除后会同时删除所有下级项目、设备和监测点。'
if (activeLevel.value === 1) return '确认删除当前项目?删除后会同时删除所有下级设备和监测点。'
if (activeLevel.value === 2) return '确认删除当前设备?删除后会同时删除所有下级监测点。'
return '确认删除当前监测点?'
}
const handleDeleteNode = async () => {
if (activeLevel.value === null) return
const id = resolveActiveNodeId()
if (!id) {
ElMessage.warning('当前节点尚未保存,无法删除')
return
}
try {
await ElMessageBox.confirm(resolveDeleteMessage(), '删除确认', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消'
})
} catch {
return
}
loading.saving = true
try {
await deleteAddLedgerNode({ id, level: activeLevel.value })
ElMessage.success('删除成功')
selectedNode.value = null
activeLevel.value = null
await loadTree()
} finally {
loading.saving = false
}
}
onMounted(async () => {
await loadTree()
})
</script>
<style scoped lang="scss">
.add-ledger-page {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
}
.add-ledger-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 12px;
width: 100%;
min-height: 0;
}
.add-ledger-main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
gap: 10px;
}
.detail-alert {
flex: none;
}
.add-ledger-empty {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
gap: 12px;
color: var(--el-text-color-regular);
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text {
font-size: 13px;
color: var(--el-text-color-secondary);
}
@media (max-width: 1100px) {
.add-ledger-layout {
grid-template-columns: 1fr;
grid-template-rows: minmax(260px, 0.45fr) minmax(0, 1fr);
}
}
</style>

View File

@@ -23,6 +23,11 @@
<div class="tool-name">addData</div> <div class="tool-name">addData</div>
<div class="tool-text">进入 addData 页面壳子后续在此扩展补录数据能力和业务交互</div> <div class="tool-text">进入 addData 页面壳子后续在此扩展补录数据能力和业务交互</div>
</button> </button>
<button class="tool-item" type="button" @click="handleNavigate('/tools/addLedger')">
<div class="tool-name">数据台账</div>
<div class="tool-text">维护工程项目设备和监测点的四级台账配置关系</div>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -197,6 +197,9 @@
<el-form-item label="end"> <el-form-item label="end">
<el-input v-model="row.end" /> <el-input v-model="row.end" />
</el-form-item> </el-form-item>
<el-form-item label="icdcout">
<el-input :model-value="row.icdcout" readonly />
</el-form-item>
</el-form> </el-form>
</div> </div>
</article> </article>
@@ -241,8 +244,10 @@ interface SequenceConfigRow {
parentDesc: string parentDesc: string
desc: string desc: string
name: string name: string
baseflag: string
start: string start: string
end: string end: string
icdcout: string
startValueType: string startValueType: string
endValueType: string endValueType: string
} }
@@ -317,8 +322,8 @@ const filteredSequenceRows = computed(() => {
if (!keyword) return sequenceConfigRows.value if (!keyword) return sequenceConfigRows.value
return sequenceConfigRows.value.filter(row => return sequenceConfigRows.value.filter(row =>
[row.topKey, row.topDesc, row.desc, row.parentDesc, row.name, row.pathText, row.start, row.end].some(value => [row.topKey, row.topDesc, row.desc, row.parentDesc, row.name, row.baseflag, row.pathText, row.start, row.end].some(
value.toLowerCase().includes(keyword) value => value.toLowerCase().includes(keyword)
) )
) )
}) })
@@ -368,12 +373,6 @@ const toDisplayText = (value: unknown, fallback: string) => {
return fallback return fallback
} }
const isZeroValue = (value: unknown) => {
if (typeof value === 'number') return value === 0
if (typeof value === 'string') return Number(value.trim()) === 0
return false
}
const getPathText = (path: JsonPath) => (path.length ? path.map(item => String(item)).join(' / ') : '$') const getPathText = (path: JsonPath) => (path.length ? path.map(item => String(item)).join(' / ') : '$')
const getTopKey = (path: JsonPath) => (path.length ? String(path[0]) : '$') const getTopKey = (path: JsonPath) => (path.length ? String(path[0]) : '$')
@@ -386,6 +385,13 @@ const resolveTopDesc = (source: unknown, path: JsonPath) => {
return isRecord(topValue) ? toDisplayText(topValue.desc, '') : '' return isRecord(topValue) ? toDisplayText(topValue.desc, '') : ''
} }
const isConfigurableBaseflag = (value: unknown) => {
if (typeof value === 'number') return value === 1 || value === 2
if (typeof value !== 'string') return false
return ['1', '2'].includes(value.trim())
}
const collectSequenceRows = (source: unknown, path: JsonPath = [], parentDesc = '', rootSource = source): SequenceConfigRow[] => { const collectSequenceRows = (source: unknown, path: JsonPath = [], parentDesc = '', rootSource = source): SequenceConfigRow[] => {
if (Array.isArray(source)) { if (Array.isArray(source)) {
return source.flatMap((item, index) => collectSequenceRows(item, [...path, index], parentDesc, rootSource)) return source.flatMap((item, index) => collectSequenceRows(item, [...path, index], parentDesc, rootSource))
@@ -402,7 +408,7 @@ const collectSequenceRows = (source: unknown, path: JsonPath = [], parentDesc =
const hasEnd = Object.prototype.hasOwnProperty.call(source, 'end') const hasEnd = Object.prototype.hasOwnProperty.call(source, 'end')
if (!hasStart || !hasEnd) return childrenRows if (!hasStart || !hasEnd) return childrenRows
if (isZeroValue(source.start) && isZeroValue(source.end)) return childrenRows if (!isConfigurableBaseflag(source.baseflag)) return childrenRows
return [ return [
{ {
@@ -414,8 +420,10 @@ const collectSequenceRows = (source: unknown, path: JsonPath = [], parentDesc =
parentDesc: parentDesc || '未配置上层描述', parentDesc: parentDesc || '未配置上层描述',
desc: toDisplayText(source.desc, '未命名序列'), desc: toDisplayText(source.desc, '未命名序列'),
name: toDisplayText(source.name, '未配置 name'), name: toDisplayText(source.name, '未配置 name'),
baseflag: toDisplayText(source.baseflag, ''),
start: source.start === undefined || source.start === null ? '' : String(source.start), start: source.start === undefined || source.start === null ? '' : String(source.start),
end: source.end === undefined || source.end === null ? '' : String(source.end), end: source.end === undefined || source.end === null ? '' : String(source.end),
icdcout: toDisplayText(source.icdcout, ''),
startValueType: typeof source.start, startValueType: typeof source.start,
endValueType: typeof source.end endValueType: typeof source.end
}, },
@@ -829,7 +837,7 @@ const confirmSequenceConfig = () => {
.sequence-config-item { .sequence-config-item {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 300px; grid-template-columns: minmax(0, 1fr) 420px;
gap: 16px; gap: 16px;
padding: 8px 0; padding: 8px 0;
border: 1px solid #dbe3f0; border: 1px solid #dbe3f0;
@@ -872,7 +880,7 @@ const confirmSequenceConfig = () => {
.sequence-config-form { .sequence-config-form {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }