我叫洪圣文

This commit is contained in:
2026-05-15 16:36:50 +08:00
parent b6006e0dfe
commit 6687cf0339
36 changed files with 2201 additions and 271 deletions

View File

@@ -32,11 +32,13 @@ export namespace EventList {
eventId: string
measurementPointId?: string
eventType?: string
eventTypeName?: string
equipmentName?: string
engineeringName?: string
projectName?: string
startTime?: string
lineName?: string
event_describe?: string
eventDescribe?: string
eventDescription?: string
eventDesc?: string

View File

@@ -0,0 +1,18 @@
import http from '@/api'
import type { SteadyDataView } from './interface'
export const getSteadyTrendLedgerTree = (params?: { keyword?: string }) => {
return http.get<SteadyDataView.SteadyLedgerNode[]>('/steady/data-view/ledger-tree', params, { loading: false })
}
export const getSteadyTrendIndicatorTree = () => {
return http.get<SteadyDataView.SteadyIndicatorNode[]>('/steady/data-view/indicator-tree', {}, { loading: false })
}
export const querySteadyTrend = (params: SteadyDataView.SteadyTrendQueryParams) => {
return http.post<SteadyDataView.SteadyTrendQueryResult>('/steady/data-view/trend/query', params)
}
export const querySteadyTrendDay = (params: SteadyDataView.SteadyTrendQueryParams) => {
return http.post<SteadyDataView.SteadyTrendQueryResult>('/steady/data-view/trend/day', params)
}

View File

@@ -0,0 +1,78 @@
export namespace SteadyDataView {
export interface SteadyLedgerNode {
id: string
parentId?: string
name: string
level: 0 | 1 | 2 | 3
sort?: number
deviceCount?: number
lineCount?: number
selectable?: boolean
children?: SteadyLedgerNode[]
}
export interface SteadyIndicatorSeriesField {
field: string
name: string
}
export interface SteadyIndicatorNode {
id?: string
treeKey?: string
indicatorCode?: string
name: string
groupCode?: string
tableName?: string
baseFields?: string[]
phaseCodes?: string[]
seriesFields?: SteadyIndicatorSeriesField[]
supportStats?: SteadyTrendStatType[]
harmonic?: boolean
harmonicOrderStart?: number | null
harmonicOrderEnd?: number | null
unit?: string
children?: SteadyIndicatorNode[]
}
export type SteadyTrendStatType = 'AVG' | 'MAX' | 'MIN' | 'CP95'
export interface SteadyTrendQueryParams {
lineIds: string[]
indicatorCodes: string[]
statTypes: SteadyTrendStatType[]
phases: string[]
timeStart: string
timeEnd: string
bucket?: string
qualityFlag?: number
harmonicOrders?: number[]
}
export interface SteadyTrendPoint {
time: string
value: number | null
}
export interface SteadyTrendSeries {
seriesKey: string
lineId: string
lineName?: string
indicatorCode: string
indicatorName?: string
seriesName?: string
phase?: string
statType?: SteadyTrendStatType
unit?: string
points: SteadyTrendPoint[]
}
export interface SteadyTrendQueryResult {
sampled?: boolean
bucket?: string
sourcePointCount?: number
displayPointCount?: number
loadableDays?: string[]
series: SteadyTrendSeries[]
}
}

View File

@@ -0,0 +1,8 @@
export const DICT_CODES = {
USER_STATE: 'state',
EVENT_TYPE: 'event_type',
LEDGER_DEVICE_TYPE: 'ledger_device_type',
LEDGER_DEVICE_MODEL: 'Ex-factory_Dev_Type'
} as const
export type DictCode = (typeof DICT_CODES)[keyof typeof DICT_CODES]

View File

@@ -10,6 +10,15 @@ import { createHomeMenuMap, getHomeValidPaths, HOME_EXCLUDED_PATHS } from '@/uti
const WHITE_LIST_SET = new Set(ROUTER_WHITE_LIST)
const mode = import.meta.env.VITE_ROUTER_MODE
const ROUTER_PERF_DEBUG = import.meta.env.DEV
const perfNow = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())
const logRouterPerf = (label: string, start: number, extra?: Record<string, unknown>) => {
if (!ROUTER_PERF_DEBUG) return
const duration = Math.round((perfNow() - start) * 100) / 100
console.info(`[router-perf] ${label}`, { duration, ...extra })
}
let routePerfStart = 0
const routerMode = {
hash: () => createWebHashHistory(),
@@ -26,10 +35,17 @@ const router = createRouter({
const syncHomeStateWithMenus = () => {
const authStore = useAuthStore()
const homeStore = useHomeStore()
homeStore.syncWithMenus(getHomeValidPaths(authStore.flatMenuListGet))
const start = perfNow()
const flatMenuList = authStore.flatMenuListGet
homeStore.syncWithMenus(getHomeValidPaths(flatMenuList))
logRouterPerf('sync-home-state', start, {
menuCount: flatMenuList.length
})
}
router.beforeEach(async (to, from, next) => {
const guardStart = perfNow()
routePerfStart = guardStart
const userStore = useUserStore()
const authStore = useAuthStore()
@@ -53,8 +69,18 @@ router.beforeEach(async (to, from, next) => {
if (!authStore.authMenuListGet.length) {
try {
const initStart = perfNow()
await initDynamicRouter()
logRouterPerf('init-dynamic-router', initStart)
const syncStart = perfNow()
syncHomeStateWithMenus()
logRouterPerf('first-sync-home-state', syncStart)
logRouterPerf('before-each-first-init', guardStart, {
path: to.path,
from: from.path
})
return next({ ...to, replace: true })
} catch (error) {
console.error('Dynamic router initialization failed', error)
@@ -64,12 +90,23 @@ router.beforeEach(async (to, from, next) => {
}
syncHomeStateWithMenus()
logRouterPerf('before-each-menu-sync', guardStart, {
path: to.path,
from: from.path,
hasActivateInfo: authStore.activateInfoLoadedGet
})
await authStore.setRouteName(to.name as string)
if (!authStore.activateInfoLoadedGet) {
const activateStart = perfNow()
await authStore.setActivateInfo()
logRouterPerf('load-activate-info', activateStart)
}
logRouterPerf('before-each-total', guardStart, {
path: to.path,
from: from.path
})
next()
})
@@ -89,8 +126,15 @@ router.onError(error => {
})
})
router.afterEach(to => {
router.afterEach((to, from) => {
NProgress.done()
const afterEachStart = perfNow()
if (routePerfStart) {
logRouterPerf('navigation-total', routePerfStart, {
path: to.path,
from: from.path
})
}
if (HOME_EXCLUDED_PATHS.has(to.path)) return
@@ -101,6 +145,10 @@ router.afterEach(to => {
if (!menuMap[to.path]) return
homeStore.addRecentVisited(to.path)
logRouterPerf('after-each-home-recent', afterEachStart, {
path: to.path,
menuCount: Object.keys(menuMap).length
})
})
export default router

View File

@@ -8,10 +8,20 @@ import { useAuthStore } from '@/stores/modules/auth'
const modules = import.meta.glob('@/views/**/*.vue')
const VIEWS_ALIAS_PREFIX = '@/views'
const VIEWS_SRC_PREFIX = '/src/views'
const ROUTER_PERF_DEBUG = import.meta.env.DEV
const perfNow = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())
const logRouterPerf = (label: string, start: number, extra?: Record<string, unknown>) => {
if (!ROUTER_PERF_DEBUG) return
const duration = Math.round((perfNow() - start) * 100) / 100
console.info(`[router-perf] ${label}`, { duration, ...extra })
}
const COMPONENT_PATH_ALIASES: Record<string, string> = {
// 后端菜单沿用 event-list 模块名时,前端实际页面目录为 eventList。
'/event/event-list': '/event/eventList',
'/event/event-list/index': '/event/eventList/index'
'/event/event-list/index': '/event/eventList/index',
// 后端菜单可能使用短横线模块名,前端页面目录统一为 steadyDataView。
'/steady/steady-data-view': '/steady/steadyDataView',
'/steady/steady-data-view/index': '/steady/steadyDataView/index'
}
const STATIC_ROUTE_NAMES = new Set([
'layout',
@@ -23,6 +33,7 @@ const STATIC_ROUTE_NAMES = new Set([
'toolAddData',
'toolAddLedger',
'eventList',
'steadyDataView',
'systemMonitor',
'diskMonitor',
'403',
@@ -111,13 +122,25 @@ export const initDynamicRouter = async () => {
if (isInitializing) return Promise.reject(new Error('Dynamic router initialization in progress'))
isInitializing = true
const initStart = perfNow()
const userStore = useUserStore()
const authStore = useAuthStore()
const unresolvedRoutes: Array<{ name?: string; path?: string; component?: string; candidates: string[] }> = []
try {
const menuStart = perfNow()
await authStore.getAuthMenuList()
logRouterPerf('dynamic-router-load-menus-api', menuStart)
const flattenMenuStart = perfNow()
const flatMenuList = authStore.flatMenuListGet
logRouterPerf('dynamic-router-flatten-menus', flattenMenuStart, {
menuCount: flatMenuList.length
})
const buttonStart = perfNow()
await authStore.getAuthButtonList()
logRouterPerf('dynamic-router-load-buttons', buttonStart)
if (!authStore.authMenuListGet.length) {
ElNotification({
@@ -133,9 +156,13 @@ export const initDynamicRouter = async () => {
return Promise.reject('No permission')
}
const clearStart = perfNow()
clearDynamicRoutes()
logRouterPerf('dynamic-router-clear-routes', clearStart)
for (const item of authStore.flatMenuListGet) {
const addRoutesStart = perfNow()
let addedRouteCount = 0
for (const item of flatMenuList) {
if (item.children) delete item.children
if (item.name && STATIC_ROUTE_NAMES.has(String(item.name))) {
continue
@@ -167,10 +194,16 @@ export const initDynamicRouter = async () => {
} else {
router.addRoute('layout', routeItem)
}
addedRouteCount += 1
} else {
console.warn('Invalid route item:', item)
}
}
logRouterPerf('dynamic-router-add-routes', addRoutesStart, {
menuCount: flatMenuList.length,
addedRouteCount,
unresolvedRouteCount: unresolvedRoutes.length
})
if (unresolvedRoutes.length) {
console.error('[dynamic-router] unresolved route components', unresolvedRoutes)
@@ -182,6 +215,9 @@ export const initDynamicRouter = async () => {
await router.replace(LOGIN_URL)
return Promise.reject(error)
} finally {
logRouterPerf('dynamic-router-total', initStart, {
unresolvedRouteCount: unresolvedRoutes.length
})
isInitializing = false
}
}

View File

@@ -99,6 +99,24 @@ export const staticRouter: RouteRecordRaw[] = [
title: '事件列表'
}
},
{
path: '/steadyDataView/index',
name: 'steadyDataView',
alias: [
'/steadyDataView',
'/steadydataView',
'/steadydataView/index',
'/steady/steadyDataView',
'/steady/steadyDataView/index',
'/steady/steady-data-view',
'/steady/steady-data-view/index'
],
component: () => import('@/views/steady/steadyDataView/index.vue'),
meta: {
cacheName: 'SteadyDataView',
title: '稳态数据'
}
},
{
path: '/403',
name: '403',

View File

@@ -137,6 +137,13 @@ function normalizeBusinessMenu(menu: any): any {
menu.component = '@/views/event/eventList/index.vue'
}
if (isSteadyDataViewMenu(menu)) {
// 后端菜单可能存在 steady-data-view 等历史写法,前端统一收敛到静态 steadyDataView 页面入口。
menu.path = '/steadyDataView/index'
menu.name = 'steadyDataView'
menu.component = '@/views/steady/steadyDataView/index.vue'
}
return menu
}
@@ -153,8 +160,22 @@ function isEventListMenu(menu: any): boolean {
return title.includes('事件列表') && (title.includes('暂降') || title.includes('暂态'))
}
function isSteadyDataViewMenu(menu: any): boolean {
const normalizedName = String(menu?.name ?? '').toLowerCase().replace(/[-_]/g, '')
const normalizedPath = String(menu?.path ?? '').toLowerCase().replace(/[-_]/g, '')
const normalizedComponent = String(menu?.component ?? '').toLowerCase().replace(/[-_]/g, '')
const title = String(menu?.meta?.title ?? menu?.title ?? '')
if (normalizedName === 'steadydataview') return true
if (normalizedPath.includes('steadydataview')) return true
if (normalizedComponent.includes('steadydataview')) return true
return title.includes('稳态数据')
}
export function resolveBusinessMenuPath(menu: Menu.MenuOptions): string {
return isEventListMenu(menu) ? '/eventList/index' : menu.path
if (isEventListMenu(menu)) return '/eventList/index'
return isSteadyDataViewMenu(menu) ? '/steadyDataView/index' : menu.path
}
function normalizeActivateInfo(rawData: unknown): Activate.ActivationCodePlaintext {

View File

@@ -38,6 +38,7 @@
import { type ProTableInstance, type ColumnProps } from '@/components/ProTable/interface'
import { CirclePlus, Delete, EditPen} from '@element-plus/icons-vue'
import { useDictStore } from '@/stores/modules/dict'
import { DICT_CODES } from '@/constants/dictCodes'
import {getUserList, deleteUser,getRoleList} from '@/api/user/user'
import { onMounted, reactive, ref } from 'vue'
import { type Role } from '@/api/user/interface/role'
@@ -128,7 +129,7 @@
prop: 'state',
label: '状态',
minWidth: 100,
enum: dictStore.getDictData('state'),
enum: dictStore.getDictData(DICT_CODES.USER_STATE),
fieldNames: { label: 'label', value: 'code' },
render: (scope: { row: { state: any } }) => {
const { tagType, tagText } = getTagTypeAndText(scope.row.state);

View File

@@ -0,0 +1,87 @@
/* eslint-env node */
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import ts from 'typescript'
const currentDir = path.resolve('src/views/components/TimePeriodSearch')
const componentPath = path.join(currentDir, 'index.vue')
const modulePath = path.join(currentDir, 'timePeriod.ts')
if (!fs.existsSync(componentPath)) {
throw new Error('TimePeriodSearch/index.vue must provide the shared views time search component')
}
if (!fs.existsSync(modulePath)) {
throw new Error('TimePeriodSearch/timePeriod.ts must provide shared time period helpers')
}
const componentSource = fs.readFileSync(componentPath, 'utf8')
const helperSource = fs.readFileSync(modulePath, 'utf8')
const transpiled = ts.transpileModule(helperSource, {
compilerOptions: {
module: ts.ModuleKind.ES2020,
target: ts.ScriptTarget.ES2020
}
}).outputText
const tempDir = path.resolve('node_modules/.cache/time-period-contract')
fs.mkdirSync(tempDir, { recursive: true })
const tempModulePath = path.join(tempDir, 'timePeriod.mjs')
fs.writeFileSync(tempModulePath, transpiled, 'utf8')
const {
buildTimePeriodRange,
formatTimePeriodDateTime,
getTimePeriodPickerFormat,
getTimePeriodPickerType,
resolveTimePeriodUnitLabel,
shiftTimePeriod
} = await import(pathToFileURL(tempModulePath).href)
assert.deepEqual(buildTimePeriodRange('day', new Date(2026, 4, 13)), [
'2026-05-13 00:00:00.000',
'2026-05-13 23:59:59.999'
])
assert.deepEqual(buildTimePeriodRange('month', new Date(2026, 4, 13)), [
'2026-05-01 00:00:00.000',
'2026-05-31 23:59:59.999'
])
assert.deepEqual(buildTimePeriodRange('year', new Date(2026, 4, 13)), [
'2026-01-01 00:00:00.000',
'2026-12-31 23:59:59.999'
])
assert.deepEqual(buildTimePeriodRange('month', shiftTimePeriod('month', new Date(2026, 4, 13), -1)), [
'2026-04-01 00:00:00.000',
'2026-04-30 23:59:59.999'
])
assert.equal(formatTimePeriodDateTime(new Date(2026, 4, 13, 8, 9, 10, 11)), '2026-05-13 08:09:10.011')
assert.equal(getTimePeriodPickerType('day'), 'date')
assert.equal(getTimePeriodPickerFormat('month'), 'YYYY-MM')
assert.equal(resolveTimePeriodUnitLabel('year'), '年')
const componentExpectations = [
['component renders unit selector', /time-period-search__unit[\s\S]*timePeriodUnitOptions/],
['component renders previous period button', /ArrowLeft[\s\S]*上一个/],
['component renders current period button', /Clock[\s\S]*当前/],
['component renders next period button', /ArrowRight[\s\S]*下一个/],
['component renders date picker by selected unit', /getTimePeriodPickerType\(props\.unit\)/],
['component uses fixed eventList-compatible picker width', /time-period-search__picker[\s\S]*width:\s*112px;[\s\S]*flex:\s*0 0 112px;/]
]
const failures = componentExpectations.filter(([, pattern]) => !pattern.test(componentSource))
if (failures.length) {
console.error('TimePeriodSearch contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('TimePeriodSearch contract passed')

View File

@@ -0,0 +1,111 @@
<template>
<div class="time-period-search">
<el-select class="time-period-search__unit" :model-value="unit" @update:model-value="handleUnitChange">
<el-option v-for="item in timePeriodUnitOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button
class="time-period-search__button"
:icon="ArrowLeft"
:title="`上一个${unitLabel}`"
@click="shiftPeriod(-1)"
/>
<el-date-picker
class="time-period-search__picker"
:model-value="baseDate"
:type="getTimePeriodPickerType(props.unit)"
:format="getTimePeriodPickerFormat(props.unit)"
:clearable="false"
:editable="false"
:placeholder="`选择${unitLabel}`"
@update:model-value="handleDateChange"
/>
<el-button
class="time-period-search__button"
:icon="ArrowRight"
:title="`下一个${unitLabel}`"
@click="shiftPeriod(1)"
/>
<el-button
class="time-period-search__button"
:icon="Clock"
:title="`当前${unitLabel}`"
@click="setCurrentPeriod"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowLeft, ArrowRight, Clock } from '@element-plus/icons-vue'
import {
getTimePeriodPickerFormat,
getTimePeriodPickerType,
resolveTimePeriodUnitLabel,
shiftTimePeriod,
timePeriodUnitOptions,
type TimePeriodUnit
} from './timePeriod'
defineOptions({
name: 'TimePeriodSearch'
})
const props = defineProps<{
unit: TimePeriodUnit
modelValue: Date | string | number
}>()
const emit = defineEmits<{
'update:unit': [value: TimePeriodUnit]
'update:modelValue': [value: Date]
}>()
const baseDate = computed(() => new Date(props.modelValue))
const unitLabel = computed(() => resolveTimePeriodUnitLabel(props.unit))
const handleUnitChange = (value: TimePeriodUnit) => {
emit('update:unit', value)
}
const handleDateChange = (value: Date | string | number | null) => {
if (!value) return
emit('update:modelValue', new Date(value))
}
const shiftPeriod = (offset: number) => {
emit('update:modelValue', shiftTimePeriod(props.unit, baseDate.value, offset))
}
const setCurrentPeriod = () => {
emit('update:modelValue', new Date())
}
</script>
<style scoped lang="scss">
.time-period-search {
display: flex;
width: 100%;
align-items: center;
gap: 4px;
}
.time-period-search__unit {
width: 56px;
flex: 0 0 56px;
}
.time-period-search__picker {
width: 112px;
flex: 0 0 112px;
}
.time-period-search__button {
width: 28px;
flex: 0 0 28px;
padding: 8px 6px;
}
</style>

View File

@@ -0,0 +1,85 @@
export type TimePeriodUnit = 'day' | 'month' | 'year'
export const timePeriodUnitOptions: { label: string; value: TimePeriodUnit }[] = [
{ label: '日', value: 'day' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' }
]
const datePickerTypeMap: Record<TimePeriodUnit, 'date' | 'month' | 'year'> = {
day: 'date',
month: 'month',
year: 'year'
}
const datePickerFormatMap: Record<TimePeriodUnit, string> = {
day: 'YYYY-MM-DD',
month: 'YYYY-MM',
year: 'YYYY'
}
const padTimeValue = (value: number, length = 2) => String(value).padStart(length, '0')
export const formatTimePeriodDateTime = (date: Date) => {
const year = date.getFullYear()
const month = padTimeValue(date.getMonth() + 1)
const day = padTimeValue(date.getDate())
const hour = padTimeValue(date.getHours())
const minute = padTimeValue(date.getMinutes())
const second = padTimeValue(date.getSeconds())
const millisecond = padTimeValue(date.getMilliseconds(), 3)
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`
}
export const getTimePeriodPickerType = (unit: TimePeriodUnit) => datePickerTypeMap[unit]
export const getTimePeriodPickerFormat = (unit: TimePeriodUnit) => datePickerFormatMap[unit]
export const resolveTimePeriodUnitLabel = (unit: TimePeriodUnit) => {
return timePeriodUnitOptions.find(item => item.value === unit)?.label ?? ''
}
export const buildTimePeriodRange = (unit: TimePeriodUnit, date: Date): string[] => {
const year = date.getFullYear()
const month = date.getMonth()
const day = date.getDate()
if (unit === 'day') {
return [
formatTimePeriodDateTime(new Date(year, month, day, 0, 0, 0, 0)),
formatTimePeriodDateTime(new Date(year, month, day, 23, 59, 59, 999))
]
}
if (unit === 'year') {
return [
formatTimePeriodDateTime(new Date(year, 0, 1, 0, 0, 0, 0)),
formatTimePeriodDateTime(new Date(year, 11, 31, 23, 59, 59, 999))
]
}
return [
formatTimePeriodDateTime(new Date(year, month, 1, 0, 0, 0, 0)),
formatTimePeriodDateTime(new Date(year, month + 1, 0, 23, 59, 59, 999))
]
}
export const shiftTimePeriod = (unit: TimePeriodUnit, date: Date, offset: number) => {
const nextDate = new Date(date)
if (unit === 'day') {
nextDate.setDate(nextDate.getDate() + offset)
return nextDate
}
if (unit === 'year') {
nextDate.setFullYear(nextDate.getFullYear() + offset)
return nextDate
}
// 月份切换以 1 日为锚点,避免 31 日切到短月份时发生日期溢出。
nextDate.setDate(1)
nextDate.setMonth(nextDate.getMonth() + offset)
return nextDate
}

View File

@@ -23,16 +23,27 @@ fs.mkdirSync(tempDir, { recursive: true })
const tempModulePath = path.join(tempDir, 'display.mjs')
fs.writeFileSync(tempModulePath, transpiled, 'utf8')
const { resolveEventDescription } = await import(pathToFileURL(tempModulePath).href)
const { resolveEventDescription, resolveEventTypeName } = await import(pathToFileURL(tempModulePath).href)
assert.equal(resolveEventDescription({ eventDescribe: '电压暂降' }), '电压暂降')
assert.equal(resolveEventDescription({ eventDescription: '描述字段' }), '描述字段')
assert.equal(resolveEventDescription({ eventDesc: '简写描述' }), '简写描述')
assert.equal(resolveEventDescription({ description: '通用描述' }), '通用描述')
assert.equal(resolveEventDescription({ describe: 'describe 字段' }), 'describe 字段')
assert.equal(resolveEventDescription({ remark: '备注描述' }), '备注描述')
assert.equal(resolveEventDescription({ eventDescribe: '', eventType: 'VOLTAGE_SAG' }), 'VOLTAGE_SAG')
assert.equal(resolveEventDescription({ event_describe: '电压暂降' }), '电压暂降')
assert.equal(resolveEventDescription({ event_describe: '' }), '--')
assert.equal(resolveEventDescription({ eventDescribe: '驼峰描述' }), '--')
assert.equal(resolveEventDescription({ eventDescription: '描述字段' }), '--')
assert.equal(resolveEventDescription({ eventType: 'VOLTAGE_SAG' }), '--')
assert.equal(resolveEventDescription({}), '--')
assert.equal(resolveEventDescription(null), '--')
const eventTypeOptions = [
{ id: 'c5ce588cb76fba90c4510000000000000', name: '电压暂降', code: 'VOLTAGE_SAG' },
{ id: 'a26e588cb76fba90c4510000000000000', name: '电压暂升', code: 'VOLTAGE_SWELL', value: 'SWELL' }
]
assert.equal(resolveEventTypeName({ eventTypeName: '后端名称', eventType: 'VOLTAGE_SAG' }, eventTypeOptions), '后端名称')
assert.equal(resolveEventTypeName({ eventType: 'c5ce588cb76fba90c4510000000000000' }, eventTypeOptions), '电压暂降')
assert.equal(resolveEventTypeName({ eventType: 'VOLTAGE_SAG' }, eventTypeOptions), '电压暂降')
assert.equal(resolveEventTypeName({ eventType: 'SWELL' }, eventTypeOptions), '电压暂升')
assert.equal(resolveEventTypeName({ eventType: 'VOLTAGE_UNKNOWN' }, eventTypeOptions), 'VOLTAGE_UNKNOWN')
assert.equal(resolveEventTypeName({ eventType: '' }, eventTypeOptions), '/')
assert.equal(resolveEventTypeName(null, eventTypeOptions), '/')
console.log('eventList display contract passed')

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import ts from 'typescript'
const modulePath = path.resolve('src/views/event/eventList/utils/queryParams.ts')
if (!fs.existsSync(modulePath)) {
throw new Error('eventList query params helpers must be extracted to utils/queryParams.ts')
}
const source = fs.readFileSync(modulePath, 'utf8')
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ES2020,
target: ts.ScriptTarget.ES2020
}
}).outputText
const tempDir = path.resolve('node_modules/.cache/event-list-contract')
fs.mkdirSync(tempDir, { recursive: true })
const tempModulePath = path.join(tempDir, 'queryParams.mjs')
fs.writeFileSync(tempModulePath, transpiled, 'utf8')
const { buildEventQueryParams } = await import(pathToFileURL(tempModulePath).href)
const params = buildEventQueryParams({
pageNum: 2,
pageSize: 20,
eventType: 'VOLTAGE_SAG'
})
assert.equal(params.eventType, 'VOLTAGE_SAG')
assert.equal(Object.hasOwn(params, 'eventTypeCode'), false)
console.log('eventList query params contract passed')

View File

@@ -12,11 +12,9 @@ const expectations = [
['event time table column keeps occurrence time label', /prop:\s*'startTime',\s*label:\s*'发生时刻'/],
['event time search label is shortened to time', /search:\s*\{\s*label:\s*'时间',\s*key:\s*'startTimeRange'/],
['event time search field only takes one grid column', /key:\s*'startTimeRange',\s*span:\s*1,/],
['event time picker uses expanded fixed width', /\.event-time-search__picker[\s\S]*width:\s*112px;\s*flex:\s*0 0 112px;/],
['event time search uses compact spacing', /\.event-time-search[\s\S]*gap:\s*4px;/],
['event time search imports current time icon', /import \{[^}]*Clock[^}]*\} from '@element-plus\/icons-vue'/],
['event time search defines current period handler', /const handleCurrentEventPeriod = \(\) => \{[\s\S]*eventTimeBaseDate\.value = new Date\(\)[\s\S]*syncEventTimeRange\(\)/],
['event time search renders current period button', /icon:\s*Clock,[\s\S]*title:\s*`当前\$\{unitLabel\}`,[\s\S]*onClick:\s*handleCurrentEventPeriod/]
['event time search imports shared TimePeriodSearch component', /import TimePeriodSearch from '@\/views\/components\/TimePeriodSearch\/index\.vue'/],
['event time search imports shared period helpers', /from '@\/views\/components\/TimePeriodSearch\/timePeriod'/],
['event time search renders shared TimePeriodSearch', /h\(TimePeriodSearch,[\s\S]*unit:\s*eventTimeUnit\.value,[\s\S]*modelValue:\s*eventTimeBaseDate\.value/]
]
const failures = expectations.filter(([, pattern]) => !pattern.test(source))

View File

@@ -4,14 +4,26 @@ import path from 'node:path'
import { pathToFileURL } from 'node:url'
import ts from 'typescript'
const modulePath = path.resolve('src/views/event/eventList/eventTimeRange.ts')
const sharedModulePath = path.resolve('src/views/components/TimePeriodSearch/timePeriod.ts')
const eventModulePath = path.resolve('src/views/event/eventList/eventTimeRange.ts')
if (!fs.existsSync(modulePath)) {
throw new Error('eventTimeRange.ts must provide event list time range helpers')
if (!fs.existsSync(sharedModulePath)) {
throw new Error('TimePeriodSearch/timePeriod.ts must provide shared time range helpers')
}
const source = fs.readFileSync(modulePath, 'utf8')
const transpiled = ts.transpileModule(source, {
if (!fs.existsSync(eventModulePath)) {
throw new Error('eventTimeRange.ts must provide event occurrence time formatting')
}
const sharedSource = fs.readFileSync(sharedModulePath, 'utf8')
const eventSource = fs.readFileSync(eventModulePath, 'utf8')
const sharedTranspiled = ts.transpileModule(sharedSource, {
compilerOptions: {
module: ts.ModuleKind.ES2020,
target: ts.ScriptTarget.ES2020
}
}).outputText
const eventTranspiled = ts.transpileModule(eventSource, {
compilerOptions: {
module: ts.ModuleKind.ES2020,
target: ts.ScriptTarget.ES2020
@@ -20,34 +32,37 @@ const transpiled = ts.transpileModule(source, {
const tempDir = path.resolve('node_modules/.cache/event-list-contract')
fs.mkdirSync(tempDir, { recursive: true })
const tempModulePath = path.join(tempDir, 'eventTimeRange.mjs')
fs.writeFileSync(tempModulePath, transpiled, 'utf8')
const tempSharedModulePath = path.join(tempDir, 'timePeriod.mjs')
const tempEventModulePath = path.join(tempDir, 'eventTimeRange.mjs')
fs.writeFileSync(tempSharedModulePath, sharedTranspiled, 'utf8')
fs.writeFileSync(tempEventModulePath, eventTranspiled, 'utf8')
const { buildEventTimeRange, formatEventOccurrenceTime, shiftEventPeriod } = await import(
pathToFileURL(tempModulePath).href
const { buildTimePeriodRange, shiftTimePeriod } = await import(
pathToFileURL(tempSharedModulePath).href
)
const { formatEventOccurrenceTime } = await import(pathToFileURL(tempEventModulePath).href)
assert.deepEqual(buildEventTimeRange('day', new Date(2026, 4, 13)), [
assert.deepEqual(buildTimePeriodRange('day', new Date(2026, 4, 13)), [
'2026-05-13 00:00:00.000',
'2026-05-13 23:59:59.999'
])
assert.deepEqual(buildEventTimeRange('month', new Date(2026, 4, 13)), [
assert.deepEqual(buildTimePeriodRange('month', new Date(2026, 4, 13)), [
'2026-05-01 00:00:00.000',
'2026-05-31 23:59:59.999'
])
assert.deepEqual(buildEventTimeRange('year', new Date(2026, 4, 13)), [
assert.deepEqual(buildTimePeriodRange('year', new Date(2026, 4, 13)), [
'2026-01-01 00:00:00.000',
'2026-12-31 23:59:59.999'
])
assert.deepEqual(buildEventTimeRange('month', shiftEventPeriod('month', new Date(2026, 4, 13), -1)), [
assert.deepEqual(buildTimePeriodRange('month', shiftTimePeriod('month', new Date(2026, 4, 13), -1)), [
'2026-04-01 00:00:00.000',
'2026-04-30 23:59:59.999'
])
assert.deepEqual(buildEventTimeRange('year', shiftEventPeriod('year', new Date(2026, 4, 13), 1)), [
assert.deepEqual(buildTimePeriodRange('year', shiftTimePeriod('year', new Date(2026, 4, 13), 1)), [
'2027-01-01 00:00:00.000',
'2027-12-31 23:59:59.999'
])

View File

@@ -10,15 +10,28 @@ const source = fs.readFileSync(pageFile, 'utf8')
const querySource = fs.readFileSync(queryFile, 'utf8')
const expectations = [
['table shows occurrence time before event type', /label:\s*'发生时刻'[\s\S]*label:\s*'事件类型'/],
['table shows monitor point before duration', /label:\s*'监测点名称'[\s\S]*label:\s*'持续时间\(s\)'/],
['table shows waveform status before event description', /label:\s*'波形文件状态'[\s\S]*label:\s*'事件描述'/],
['event location stays in visible table columns', /label:\s*'事件发生位置'/],
[
'table keeps requested visible event column order',
/label:\s*'发生时刻'[\s\S]*label:\s*'监测点名称'[\s\S]*label:\s*'持续时间\(s\)'[\s\S]*label:\s*'暂降\/暂升幅值\(%\)'[\s\S]*label:\s*'事件类型'[\s\S]*label:\s*'相别'/
],
[
'event type column displays name but searches by eventType code',
/prop:\s*'eventTypeName'[\s\S]*label:\s*'事件类型'[\s\S]*enum:\s*eventTypeOptions[\s\S]*fieldNames:\s*\{\s*label:\s*'name',\s*value:\s*'id'\s*\}[\s\S]*isFilterEnum:\s*false[\s\S]*search:\s*\{[\s\S]*key:\s*'eventType'[\s\S]*el:\s*'select'/
],
[
'event description defaults hidden in table columns',
/prop:\s*'event_describe'[\s\S]*label:\s*'事件描述'[\s\S]*isShow:\s*false/
],
['event location defaults hidden in table columns', /prop:\s*'sagsource'[\s\S]*label:\s*'事件发生位置'[\s\S]*isShow:\s*false/],
['waveform status defaults hidden in table columns', /prop:\s*'fileFlag'[\s\S]*label:\s*'波形文件状态'[\s\S]*isShow:\s*false/],
['monitor point is rendered as a clickable link', /prop:\s*'lineName'[\s\S]*type:\s*'primary'[\s\S]*link:\s*true[\s\S]*handleViewMeasurementPoint\(row\)/],
['measurement point dialog is present', /measurementPointDialogVisible[\s\S]*title="监测点信息"/],
['operation switches between view waveform and supplement waveform', /Number\(row\.fileFlag\)\s*===\s*1[\s\S]*查看波形[\s\S]*波形补招/],
['waveform status search uses custom render instead of select', /renderFileFlagSearch[\s\S]*prop:\s*'fileFlag'[\s\S]*search:\s*\{[\s\S]*render:\s*renderFileFlagSearch/],
['event description is not a search field', /prop:\s*'eventDescribe'[\s\S]*label:\s*'事件描述'[\s\S]*search:/.test(source) === false],
[
'event description is not a search field',
/prop:\s*'event_describe'[\s\S]*label:\s*'事件描述'[\s\S]*search:[\s\S]*prop:\s*'sagsource'/.test(source) === false
],
['ledger names are searched through one keyword field', /ledgerKeyword[\s\S]*label:\s*'台账关键字'/],
['query params fan out ledger keyword to ledger name fields', /ledgerKeyword[\s\S]*engineeringName[\s\S]*projectName[\s\S]*equipmentName[\s\S]*lineName/.test(querySource)]
]

View File

@@ -1,37 +1,3 @@
export type EventTimeUnit = 'day' | 'month' | 'year'
export const eventTimeUnitOptions: { label: string; value: EventTimeUnit }[] = [
{ label: '日', value: 'day' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' }
]
const datePickerTypeMap: Record<EventTimeUnit, 'date' | 'month' | 'year'> = {
day: 'date',
month: 'month',
year: 'year'
}
const datePickerFormatMap: Record<EventTimeUnit, string> = {
day: 'YYYY-MM-DD',
month: 'YYYY-MM',
year: 'YYYY'
}
const padTimeValue = (value: number, length = 2) => String(value).padStart(length, '0')
const formatEventTime = (date: Date) => {
const year = date.getFullYear()
const month = padTimeValue(date.getMonth() + 1)
const day = padTimeValue(date.getDate())
const hour = padTimeValue(date.getHours())
const minute = padTimeValue(date.getMinutes())
const second = padTimeValue(date.getSeconds())
const millisecond = padTimeValue(date.getMilliseconds(), 3)
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`
}
export const formatEventOccurrenceTime = (value: unknown) => {
if (value === null || value === undefined) return '--'
@@ -45,55 +11,3 @@ export const formatEventOccurrenceTime = (value: unknown) => {
const fractionalSecond = matched[3]
return `${matched[1]} ${matched[2]}${fractionalSecond ? `.${fractionalSecond}` : ''}`
}
export const getEventDatePickerType = (unit: EventTimeUnit) => datePickerTypeMap[unit]
export const getEventDatePickerFormat = (unit: EventTimeUnit) => datePickerFormatMap[unit]
export const resolveEventTimeUnitLabel = (unit: EventTimeUnit) => {
return eventTimeUnitOptions.find(item => item.value === unit)?.label ?? ''
}
export const buildEventTimeRange = (unit: EventTimeUnit, date: Date): string[] => {
const year = date.getFullYear()
const month = date.getMonth()
const day = date.getDate()
if (unit === 'day') {
return [
formatEventTime(new Date(year, month, day, 0, 0, 0, 0)),
formatEventTime(new Date(year, month, day, 23, 59, 59, 999))
]
}
if (unit === 'year') {
return [
formatEventTime(new Date(year, 0, 1, 0, 0, 0, 0)),
formatEventTime(new Date(year, 11, 31, 23, 59, 59, 999))
]
}
return [
formatEventTime(new Date(year, month, 1, 0, 0, 0, 0)),
formatEventTime(new Date(year, month + 1, 0, 23, 59, 59, 999))
]
}
export const shiftEventPeriod = (unit: EventTimeUnit, date: Date, offset: number) => {
const nextDate = new Date(date)
if (unit === 'day') {
nextDate.setDate(nextDate.getDate() + offset)
return nextDate
}
if (unit === 'year') {
nextDate.setFullYear(nextDate.getFullYear() + offset)
return nextDate
}
// 月份切换以 1 日为锚点,避免 31 日切到短月份时发生日期溢出。
nextDate.setDate(1)
nextDate.setMonth(nextDate.getMonth() + offset)
return nextDate
}

View File

@@ -46,25 +46,20 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElDatePicker, ElOption, ElRadioButton, ElRadioGroup, ElSelect } from 'element-plus'
import { ArrowLeft, ArrowRight, Clock, Download, RefreshRight, View } from '@element-plus/icons-vue'
import { ElButton, ElRadioButton, ElRadioGroup } from 'element-plus'
import { Download, RefreshRight, View } from '@element-plus/icons-vue'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import { exportTransientEvents, getTransientEventDetail, getTransientEventPage } from '@/api/event/eventList'
import type { EventList } from '@/api/event/eventList/interface'
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
import {
buildEventTimeRange,
eventTimeUnitOptions,
formatEventOccurrenceTime,
getEventDatePickerFormat,
getEventDatePickerType,
resolveEventTimeUnitLabel,
shiftEventPeriod,
type EventTimeUnit
} from './eventTimeRange'
import { useDictStore } from '@/stores/modules/dict'
import { DICT_CODES } from '@/constants/dictCodes'
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
import { formatEventOccurrenceTime } from './eventTimeRange'
import { buildEventQueryParams, type EventSearchParams } from './utils/queryParams'
import { resolveEventDescription } from './utils/display'
import { resolveEventDescription, resolveEventTypeName } from './utils/display'
import {
fileFlagOptions,
phaseOptions,
@@ -77,12 +72,14 @@ defineOptions({
const proTable = ref<ProTableInstance>()
const router = useRouter()
const dictStore = useDictStore()
const measurementPointDialogVisible = ref(false)
const measurementPointLoading = ref(false)
const measurementPointData = ref<EventList.TransientEventRecord | null>(null)
const eventTimeUnit = ref<EventTimeUnit>('month')
const eventTimeUnit = ref<TimePeriodUnit>('month')
const eventTimeBaseDate = ref(new Date())
const defaultStartTimeRange = buildEventTimeRange(eventTimeUnit.value, eventTimeBaseDate.value)
const defaultStartTimeRange = buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
const eventTypeOptions = computed(() => dictStore.getDictData(DICT_CODES.EVENT_TYPE))
const measurementPointItems: { label: string; prop: keyof EventList.TransientEventRecord }[] = [
{ label: '工程名称', prop: 'engineeringName' },
{ label: '项目名称', prop: 'projectName' },
@@ -92,7 +89,7 @@ const measurementPointItems: { label: string; prop: keyof EventList.TransientEve
]
const syncEventTimeRange = () => {
const timeRange = buildEventTimeRange(eventTimeUnit.value, eventTimeBaseDate.value)
const timeRange = buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
const searchParam = proTable.value?.searchParam as EventSearchParams | undefined
if (searchParam) {
@@ -102,7 +99,7 @@ const syncEventTimeRange = () => {
return timeRange
}
const handleEventTimeUnitChange = (value: EventTimeUnit) => {
const handleEventTimeUnitChange = (value: TimePeriodUnit) => {
eventTimeUnit.value = value
syncEventTimeRange()
}
@@ -113,72 +110,20 @@ const handleEventTimeDateChange = (value: Date | string | number | null) => {
syncEventTimeRange()
}
const handleShiftEventPeriod = (offset: number) => {
eventTimeBaseDate.value = shiftEventPeriod(eventTimeUnit.value, eventTimeBaseDate.value, offset)
syncEventTimeRange()
}
const handleCurrentEventPeriod = () => {
eventTimeBaseDate.value = new Date()
syncEventTimeRange()
}
const handleSearchReset = () => {
eventTimeUnit.value = 'month'
eventTimeBaseDate.value = new Date()
syncEventTimeRange()
}
const renderEventTimeSearch = () => {
const unitLabel = resolveEventTimeUnitLabel(eventTimeUnit.value)
return h('div', { class: 'event-time-search' }, [
h(
ElSelect,
{
class: 'event-time-search__unit',
modelValue: eventTimeUnit.value,
'onUpdate:modelValue': handleEventTimeUnitChange
},
() =>
eventTimeUnitOptions.map(option =>
h(ElOption, {
key: option.value,
label: option.label,
value: option.value
})
)
),
h(ElButton, {
class: 'event-time-search__button',
icon: ArrowLeft,
title: `上一个${unitLabel}`,
onClick: () => handleShiftEventPeriod(-1)
}),
h(ElDatePicker, {
class: 'event-time-search__picker',
modelValue: eventTimeBaseDate.value,
type: getEventDatePickerType(eventTimeUnit.value),
format: getEventDatePickerFormat(eventTimeUnit.value),
clearable: false,
editable: false,
placeholder: `选择${unitLabel}`,
'onUpdate:modelValue': handleEventTimeDateChange
}),
h(ElButton, {
class: 'event-time-search__button',
icon: ArrowRight,
title: `下一个${unitLabel}`,
onClick: () => handleShiftEventPeriod(1)
}),
h(ElButton, {
class: 'event-time-search__button',
icon: Clock,
title: `当前${unitLabel}`,
onClick: handleCurrentEventPeriod
})
])
}
const renderEventTimeSearch = () =>
h(TimePeriodSearch, {
class: 'event-time-search',
unit: eventTimeUnit.value,
modelValue: eventTimeBaseDate.value,
'onUpdate:unit': handleEventTimeUnitChange,
'onUpdate:modelValue': handleEventTimeDateChange
})
const renderFileFlagSearch = ({ searchParam }: { searchParam: EventSearchParams }) => {
return h(
@@ -214,23 +159,6 @@ const columns = reactive<ColumnProps<EventList.TransientEventRecord>[]>([
render: renderEventTimeSearch
}
},
{
prop: 'eventType',
label: '事件类型',
minWidth: 160,
search: {
el: 'input'
}
},
{
prop: 'phase',
label: '相别',
minWidth: 90,
enum: phaseOptions,
search: {
el: 'select'
}
},
{
prop: 'ledgerKeyword',
label: '台账关键字',
@@ -261,22 +189,46 @@ const columns = reactive<ColumnProps<EventList.TransientEventRecord>[]>([
},
{ prop: 'duration', label: '持续时间(s)', minWidth: 130 },
{ prop: 'featureAmplitude', label: '暂降/暂升幅值(%)', minWidth: 160 },
{
prop: 'eventTypeName',
label: '事件类型',
minWidth: 160,
enum: eventTypeOptions,
fieldNames: { label: 'name', value: 'id' },
isFilterEnum: false,
render: ({ row }) => resolveEventTypeName(row, eventTypeOptions.value),
search: {
key: 'eventType',
el: 'select'
}
},
{
prop: 'phase',
label: '相别',
minWidth: 90,
enum: phaseOptions,
search: {
el: 'select'
}
},
{
prop: 'event_describe',
label: '事件描述',
minWidth: 180,
isShow: false,
render: ({ row }) => resolveEventDescription(row)
},
{ prop: 'sagsource', label: '事件发生位置', minWidth: 140, isShow: false },
{
prop: 'fileFlag',
label: '波形文件状态',
minWidth: 130,
isShow: false,
enum: fileFlagOptions,
search: {
render: renderFileFlagSearch
}
},
{
prop: 'eventDescribe',
label: '事件描述',
minWidth: 180,
render: ({ row }) => resolveEventDescription(row)
},
{ prop: 'sagsource', label: '事件发生位置', minWidth: 140 },
{ prop: 'operation', label: '操作', fixed: 'right', width: 130 }
])
@@ -345,29 +297,6 @@ const handleExport = () => {
word-break: break-all;
}
.event-list-page :deep(.event-time-search) {
display: flex;
width: 100%;
align-items: center;
gap: 4px;
}
.event-list-page :deep(.event-time-search__unit) {
width: 56px;
flex: 0 0 56px;
}
.event-list-page :deep(.event-time-search__picker) {
width: 112px;
flex: 0 0 112px;
}
.event-list-page :deep(.event-time-search__button) {
width: 28px;
flex: 0 0 28px;
padding: 8px 6px;
}
.event-list-page :deep(.event-file-flag-search) {
width: 100%;
}

View File

@@ -12,13 +12,13 @@ export type DetailItem = {
export const detailItems: DetailItem[] = [
{ label: '事件 ID', prop: 'eventId' },
{ label: '监测点 ID', prop: 'measurementPointId' },
{ label: '事件类型', prop: 'eventType' },
{ label: '事件类型', prop: 'eventTypeName' },
{ label: '工程名称', prop: 'engineeringName' },
{ label: '项目名称', prop: 'projectName' },
{ label: '设备名称', prop: 'equipmentName' },
{ label: '监测点名称', prop: 'lineName' },
{ label: '发生时刻', prop: 'startTime', formatter: formatEventOccurrenceTime },
{ label: '事件描述', prop: 'eventDescribe', formatter: (_value, row) => resolveEventDescription(row) },
{ label: '事件描述', prop: 'event_describe', formatter: (_value, row) => resolveEventDescription(row) },
{ label: '事件发生位置', prop: 'sagsource' },
{ label: '相别', prop: 'phase' },
{ label: '持续时间(s)', prop: 'duration' },

View File

@@ -1,6 +1,12 @@
import type { EventList } from '@/api/event/eventList/interface'
type EventRecordLike = Partial<EventList.TransientEventRecord> | null | undefined
type EventTypeOptionLike = {
id?: string
name?: string
code?: string
value?: string
}
const resolveOptionalText = (value: unknown) => {
if (value === null || value === undefined) return ''
@@ -11,14 +17,21 @@ export const resolveEventDescription = (row: EventRecordLike) => {
if (!row) return '--'
const record = row as Record<string, unknown>
const description =
resolveOptionalText(record.eventDescribe) ||
resolveOptionalText(record.eventDescription) ||
resolveOptionalText(record.eventDesc) ||
resolveOptionalText(record.description) ||
resolveOptionalText(record.describe) ||
resolveOptionalText(record.remark) ||
resolveOptionalText(record.eventType)
const description = resolveOptionalText(record.event_describe)
return description || '--'
}
export const resolveEventTypeName = (row: EventRecordLike, eventTypeOptions: EventTypeOptionLike[] = []) => {
if (!row) return '/'
const record = row as Record<string, unknown>
const eventTypeName = resolveOptionalText(record.eventTypeName)
if (eventTypeName) return eventTypeName
const eventType = resolveOptionalText(record.eventType)
if (!eventType) return '/'
const option = eventTypeOptions.find(item => item.id === eventType || item.code === eventType || item.value === eventType)
return option?.name || eventType
}

View File

@@ -0,0 +1,239 @@
# steady-DataView API 调试文档
## 1. 基础信息
- 模块:`steady/steady-DataView`
- 控制器:`SteadyDataViewController`
- 接口前缀:`/steady/data-view`
- 本地默认地址:`http://localhost:18192`
- Content-Type`application/json`
- 认证:除登录和 Swagger 资源外,请求需要携带登录后的 `Authorization` 头。
## 2. 分页查询稳态数据
- 路径:`POST /steady/data-view/page`
- 返回:`HttpResult<Page<SteadyDataViewVO>>`
- 默认表:`data_v`
- 默认时间范围:当前月 1 日 `00:00:00` 到当前时间
- 默认排序:`TIMEID DESC, LINEID ASC, PHASIC_TYPE ASC`
请求示例:
```json
{
"pageNum": 1,
"pageSize": 10,
"tableName": "data_v",
"timeStart": "2026-05-01 00:00:00",
"timeEnd": "2026-05-09 23:59:59",
"phasicType": "A",
"qualityFlag": 1,
"lineIds": [
"line-001"
],
"engineeringName": "示例工程",
"projectName": "示例项目",
"equipmentName": "示例设备",
"lineName": "示例测点"
}
```
`tableName` 只允许 `tools/add-data` 已注册的 13 张 `data_*` 表;台账关键字会先通过 `add-ledger` 转换为监测点 ID再查询稳态数据表。
## 3. 查询稳态数据详情
- 路径:`POST /steady/data-view/detail`
- 返回:`HttpResult<SteadyDataViewVO>`
请求示例:
```json
{
"tableName": "data_v",
"lineId": "line-001",
"timeId": "2026-05-09 10:20:30",
"phasicType": "A"
}
```
详情使用 `LINEID + TIMEID + PHASIC_TYPE` 定位单条数据。
## 4. 查询稳态数据模板
- 路径:`GET /steady/data-view/templates`
- 返回:`HttpResult<List<SteadyDataViewTemplateVO>>`
模板来自 `tools/add-data` 的前端展示模板,返回参数名称、表名、相别和当前表可展示值字段。
## 5. 查询趋势台账树
- 路径:`GET /steady/data-view/ledger-tree`
- 返回:`HttpResult<List<SteadyDataViewLedgerNodeVO>>`
- 查询参数:`keyword`,可选,按台账节点名称搜索并保留父级路径。
节点字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 台账节点 ID |
| `parentId` | 父节点 ID |
| `name` | 节点名称 |
| `level` | 层级0 工程1 项目2 设备3 监测点 |
| `deviceCount` | 当前节点下有效设备数 |
| `lineCount` | 当前节点下有效监测点数 |
| `selectable` | 是否可直接选择 |
| `children` | 子节点 |
## 6. 查询趋势指标树
- 路径:`GET /steady/data-view/indicator-tree`
- 返回:`HttpResult<List<SteadyDataViewIndicatorNodeVO>>`
当前指标目录覆盖:
- 电压趋势:`V_RMS``V_LINE_RMS`
- 电流趋势:`I_RMS`
- 频率趋势:`FREQ`
- 谐波趋势:`V_THD``I_THD``V_HARMONIC``I_HARMONIC``V_HARMONIC_RATE``I_HARMONIC_RATE``I_INTER_HARMONIC``P_HARMONIC_POWER``Q_HARMONIC_POWER``S_HARMONIC_POWER`
- 闪变趋势:`FLUC``PST``PLT`
叶子节点会返回 `tableName``phaseCodes``seriesFields``supportStats``harmonicOrderStart``harmonicOrderEnd``unit`,前端按这些字段驱动相别、统计类型和谐波次数选择。
## 7. 查询趋势数据
- 路径:`POST /steady/data-view/trend/query`
- 返回:`HttpResult<SteadyTrendQueryVO>`
请求示例:
```json
{
"lineIds": ["line-001"],
"indicatorCodes": ["V_RMS"],
"statTypes": ["AVG", "MAX", "MIN", "CP95"],
"phases": ["A", "B", "C"],
"timeStart": "2026-05-01 00:00:00",
"timeEnd": "2026-05-01 23:59:59",
"bucket": "10m",
"qualityFlag": 1
}
```
返回示例:
```json
{
"sampled": true,
"bucket": "10m",
"sourcePointCount": 144,
"displayPointCount": 144,
"loadableDays": ["2026-05-01"],
"series": [
{
"seriesKey": "line-001|V_RMS|A|AVG|RMS",
"lineId": "line-001",
"lineName": "进线一",
"indicatorCode": "V_RMS",
"indicatorName": "相电压有效值",
"seriesName": "相电压有效值",
"phase": "A",
"statType": "AVG",
"unit": "V",
"points": [
{
"time": "2026-05-01 00:00:00",
"value": 220.1
}
]
}
]
}
```
谐波请求必须指定 `harmonicOrders`,最多 6 个:
```json
{
"lineIds": ["line-001"],
"indicatorCodes": ["V_HARMONIC"],
"statTypes": ["MAX"],
"phases": ["A"],
"harmonicOrders": [3, 5, 7],
"timeStart": "2026-05-01 00:00:00",
"timeEnd": "2026-05-01 23:59:59",
"bucket": "10m",
"qualityFlag": 1
}
```
## 8. 按天查询趋势数据
- 路径:`POST /steady/data-view/trend/day`
- 返回:`HttpResult<SteadyTrendQueryVO>`
请求体与 `/trend/query` 一致。前端切换日期或加载某一天数据时,将 `timeStart``timeEnd` 控制在当天范围即可。
## 9. 查询趋势统计摘要
- 路径:`POST /steady/data-view/trend/summary`
- 返回:`HttpResult<SteadyTrendSummaryVO>`
请求体与 `/trend/query` 一致。后端按当前查询范围返回每条曲线的 `max``avg``min``cp95`
## 10. InfluxDB 配置
配置项前缀:`steady.influxdb`
```yaml
steady:
influxdb:
url: http://192.168.1.103:18086
database: pqsbase
username: admin
password: ${STEADY_INFLUXDB_PASSWORD:}
ssl: false
connect-timeout-ms: 5000
read-timeout-ms: 30000
```
接口按 InfluxDB 1.x InfluxQL `/query` 方式访问。代码不会提交明文密码;本地密码请通过环境变量或本地覆盖配置提供。
## 11. 返回字段说明
| 字段 | 说明 |
| --- | --- |
| `tableName` | 数据表名 |
| `lineId` | 监测点 ID |
| `timeId` | 数据时间 |
| `phasicType` | 相别 |
| `qualityFlag` | 质量标识 |
| `equipmentName` | 设备名称,台账缺失时为 `-` |
| `engineeringName` | 工程名称,台账缺失时为 `-` |
| `projectName` | 项目名称,台账缺失时为 `-` |
| `lineName` | 监测点名称,台账缺失时为 `-` |
| `values` | 动态指标字段,字段名与目标 `data_*` 表保持一致 |
## 12. 常见错误场景
| 场景 | 后端提示 |
| --- | --- |
| 表名不在 add-data 注册表范围内 | `稳态数据表不支持xxx` |
| 开始时间大于结束时间 | `开始时间不能大于结束时间` |
| 时间格式无法解析 | `时间格式不正确,仅支持 yyyy-MM-dd HH:mm:ss` |
| 相别不为 `A/B/C/T` | `相别只能是 A、B、C、T` |
| 质量标识不为 `0/1` | `质量标识只能是 0 或 1` |
| `lineIds` 超过 1000 个 | `监测点 ID 查询数量不能超过 1000 个` |
| 台账关键字匹配监测点超过 1000 个 | `台账检索匹配监测点过多,请缩小查询条件` |
| 趋势监测点为空 | `监测点 ID 不能为空` |
| 趋势指标为空 | `指标不能为空` |
| 多监测点同时多指标查询 | `多监测点查询时只能选择 1 个指标` |
| 趋势曲线超过 24 条 | `趋势曲线数量不能超过 24 条,请缩小监测点、指标、相别或统计类型范围` |
| 谐波指标未传次数 | `谐波次数不能为空` |
| 谐波次数超过 6 个 | `谐波次数最多选择 6 个` |
| InfluxDB 未配置地址 | `InfluxDB 地址未配置` |
## 13. 当前限制
- 当前仅提供分页、详情和模板查询,未提供动态 Excel 导出。
- 趋势接口已提供后端结构和 InfluxQL 查询封装,未做真实 InfluxDB 联调。
- `sourcePointCount` 当前与实际返回点数一致,未额外发 InfluxDB `count` 查询。

View File

@@ -0,0 +1,34 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const srcRoot = path.resolve(currentDir, '../../..')
const staticRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/staticRouter.ts'), 'utf8')
const dynamicRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/dynamicRouter.ts'), 'utf8')
const authStore = fs.readFileSync(path.join(srcRoot, 'stores/modules/auth.ts'), 'utf8')
const expectations = [
['static router registers /steadyDataView/index', /path:\s*'\/steadyDataView\/index'/],
['static route name is steadyDataView', /name:\s*'steadyDataView'/],
['static router imports steadyDataView page', /@\/views\/steady\/steadyDataView\/index\.vue/],
['dynamic router aliases steady-data-view to steadyDataView', /\/steady\/steady-data-view[\s\S]*\/steady\/steadyDataView/],
['dynamic router keeps steadyDataView static route from being overwritten', /STATIC_ROUTE_NAMES[\s\S]*'steadyDataView'/],
['auth normalizes backend steady menu to static steadyDataView entry', /isSteadyDataViewMenu[\s\S]*menu\.path\s*=\s*'\/steadyDataView\/index'/],
['business menu path resolver handles steadyDataView', /isSteadyDataViewMenu\(menu\)\s*\?\s*'\/steadyDataView\/index'/]
]
const sourceByExpectation = [staticRouter, staticRouter, staticRouter, dynamicRouter, dynamicRouter, authStore, authStore]
const failures = expectations.filter(([, pattern], index) => !pattern.test(sourceByExpectation[index]))
if (failures.length) {
console.error('steadyDataView route contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView route contract passed')

View File

@@ -0,0 +1,107 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pageFile = path.join(currentDir, 'index.vue')
const apiFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/index.ts')
const interfaceFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/interface/index.ts')
const componentDir = path.join(currentDir, 'components')
const utilsDir = path.join(currentDir, 'utils')
const read = file => fs.readFileSync(file, 'utf8')
const pageSource = read(pageFile)
const apiSource = read(apiFile)
const interfaceSource = read(interfaceFile)
const componentSource = fs.existsSync(componentDir)
? fs
.readdirSync(componentDir)
.filter(file => file.endsWith('.vue'))
.map(file => read(path.join(componentDir, file)))
.join('\n')
: ''
const utilitySource = fs.existsSync(utilsDir)
? fs
.readdirSync(utilsDir)
.filter(file => file.endsWith('.ts'))
.map(file => read(path.join(utilsDir, file)))
.join('\n')
: ''
const expectations = [
['page imports ledger tree panel', /SteadyLedgerTree/],
['page imports indicator tree panel', /SteadyIndicatorTree/],
['page imports trend toolbar', /SteadyTrendToolbar/],
['page imports trend chart panel', /SteadyTrendChartPanel/],
['page does not import trend summary panel', /SteadyTrendSummaryPanel/],
['page does not import data table panel', /SteadyDataTablePanel/],
['page renders floating indicator panel', /indicator-floating-panel/],
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
['API exposes trend query endpoint', /\/steady\/data-view\/trend\/query/],
['API exposes trend day endpoint', /\/steady\/data-view\/trend\/day/],
['API does not expose trend summary endpoint', /\/steady\/data-view\/trend\/summary/],
['interfaces define trend query params', /interface\s+SteadyTrendQueryParams/],
['interfaces define trend series', /interface\s+SteadyTrendSeries/],
['interfaces do not define trend summary', /interface\s+SteadyTrendSummary/],
['components render ledger checkbox tree', /show-checkbox[\s\S]*@check/],
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
['components reuse LineChart', /<LineChart/],
['toolbar uses shared time period search', /TimePeriodSearch/],
['toolbar labels phase options descriptively', /resolvePhaseLabel/],
['toolbar labels bucket options descriptively', /bucketOptions[\s\S]*1分钟[\s\S]*1小时/],
['toolbar labels quality options descriptively', /仅有效数据[\s\S]*仅无效数据/],
['utilities collect selected line ids', /export const collectSelectedLineIds/],
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*6/],
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
['utilities build chart options', /export const buildSteadyTrendChartOptions/]
]
const sourceByExpectation = [
pageSource,
pageSource,
pageSource,
pageSource,
pageSource,
pageSource,
pageSource,
pageSource,
apiSource,
apiSource,
apiSource,
apiSource,
apiSource,
interfaceSource,
interfaceSource,
interfaceSource,
componentSource,
componentSource,
componentSource,
componentSource,
componentSource,
componentSource,
componentSource,
utilitySource,
utilitySource,
utilitySource,
utilitySource,
utilitySource
]
const failures = expectations.filter(([name, pattern], index) => {
const matched = pattern.test(sourceByExpectation[index])
return name.includes('does not') || name.includes('do not') ? matched : !matched
})
if (failures.length) {
console.error('steadyDataView trend contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView trend contract passed')

View File

@@ -0,0 +1,59 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pageFile = path.join(currentDir, 'index.vue')
const apiFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/index.ts')
const interfaceFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/interface/index.ts')
const source = fs.readFileSync(pageFile, 'utf8')
const apiSource = fs.readFileSync(apiFile, 'utf8')
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
const forbiddenPatterns = [
['data detail tab is removed', /数据明细|name="detail"|SteadyDataTablePanel/, source],
['detail ProTable is removed', /buildSteadyDataQueryParams|SteadyDataSearchParams/, source],
['trend summary panel is removed', /SteadyTrendSummaryPanel|trendSummary|loading\.summary/, source],
[
'page detail API is removed',
/getSteadyDataPage|getSteadyDataDetail|getSteadyDataTemplates|\/steady\/data-view\/page|\/steady\/data-view\/detail|\/steady\/data-view\/templates/,
apiSource
],
['trend summary API is removed', /getSteadyTrendSummary|\/steady\/data-view\/trend\/summary/, apiSource],
[
'page detail types are removed',
/PageResult|SteadyDataPageParams|SteadyDataDetailParams|SteadyDataTemplate|SteadyDataRecord/,
interfaceSource
],
[
'trend summary types are removed',
/SteadyTrendSummary|SteadyTrendSummaryItem/,
interfaceSource
]
]
const requiredPatterns = [
['page defines SteadyDataView component name', /name:\s*'SteadyDataView'/, source],
['page keeps trend chart panel', /SteadyTrendChartPanel/, source],
['page keeps right floating indicator panel', /indicator-floating-panel/, source],
['indicator panel defaults expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/, source],
['indicator panel supports collapsed state', /is-collapsed/, source],
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource]
]
const failures = [
...forbiddenPatterns.filter(([, pattern, target]) => pattern.test(target)),
...requiredPatterns.filter(([, pattern, target]) => !pattern.test(target))
]
if (failures.length) {
console.error('steadyDataView visible contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView visible contract passed')

View File

@@ -0,0 +1,117 @@
<template>
<section class="card steady-tree-card indicator-tree">
<div class="panel-header">
<span class="panel-title">稳态指标</span>
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
</div>
<el-scrollbar class="tree-scrollbar">
<el-tree
ref="treeRef"
class="indicator-tree"
:data="normalizedTreeData"
node-key="treeKey"
show-checkbox
default-expand-all
:expand-on-click-node="false"
:props="{ label: 'name', children: 'children' }"
@check="handleCheck"
>
<template #default="{ data }">
<div class="tree-node">
<span class="node-name">{{ data.name }}</span>
<el-tag v-if="data.unit" size="small" effect="plain">{{ data.unit }}</el-tag>
</div>
</template>
</el-tree>
</el-scrollbar>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import type { TreeInstance } from 'element-plus'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { collectLeafIndicators } from '../utils/selectionRules'
defineOptions({
name: 'SteadyIndicatorTree'
})
const props = defineProps<{
treeData: SteadyDataView.SteadyIndicatorNode[]
loading: boolean
}>()
const emit = defineEmits<{
refresh: []
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
}>()
const treeRef = ref<TreeInstance>()
const normalizedTreeData = computed(() => {
const normalize = (nodes: SteadyDataView.SteadyIndicatorNode[], parentKey = ''): SteadyDataView.SteadyIndicatorNode[] => {
return nodes.map((node, index) => {
const treeKey = node.id || node.indicatorCode || `${parentKey}${node.groupCode || node.name || 'node'}-${index}`
return {
...node,
treeKey,
children: node.children?.length ? normalize(node.children, `${treeKey}-`) : undefined
} as SteadyDataView.SteadyIndicatorNode
})
}
return normalize(props.treeData)
})
const handleCheck = () => {
const checkedNodes = (treeRef.value?.getCheckedNodes(false, true) || []) as SteadyDataView.SteadyIndicatorNode[]
emit('change', collectLeafIndicators(checkedNodes))
}
</script>
<style scoped lang="scss">
.steady-tree-card {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
padding: 12px;
}
.panel-header,
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.tree-scrollbar {
flex: 1;
min-height: 0;
}
.tree-node {
width: 100%;
min-width: 0;
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.indicator-tree :deep(.el-tree-node__content) {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<section class="card steady-tree-card">
<div class="panel-header">
<span class="panel-title">台账监测点</span>
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
</div>
<el-input
:model-value="keyword"
clearable
placeholder="搜索工程、项目、设备、监测点"
@update:model-value="handleKeywordChange"
/>
<el-scrollbar class="tree-scrollbar">
<el-tree
ref="treeRef"
class="ledger-tree"
:data="treeData"
node-key="id"
show-checkbox
default-expand-all
:expand-on-click-node="false"
:props="{ label: 'name', children: 'children' }"
@check="handleCheck"
>
<template #default="{ data }">
<div class="tree-node">
<span class="node-name">{{ data.name }}</span>
<span class="node-count">
<template v-if="Number(data.deviceCount) || Number(data.lineCount)">
{{ Number(data.deviceCount || 0) }} / {{ Number(data.lineCount || 0) }}
</template>
</span>
</div>
</template>
</el-tree>
</el-scrollbar>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import type { TreeInstance } from 'element-plus'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
defineOptions({
name: 'SteadyLedgerTree'
})
defineProps<{
treeData: SteadyDataView.SteadyLedgerNode[]
loading: boolean
keyword: string
}>()
const emit = defineEmits<{
refresh: []
search: [value: string]
change: [nodes: SteadyDataView.SteadyLedgerNode[]]
}>()
const treeRef = ref<TreeInstance>()
const handleKeywordChange = (value: string) => {
emit('search', value)
}
const handleCheck = () => {
emit('change', (treeRef.value?.getCheckedNodes(false, true) || []) as SteadyDataView.SteadyLedgerNode[])
}
</script>
<style scoped lang="scss">
.steady-tree-card {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
padding: 12px;
}
.panel-header,
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.tree-scrollbar {
flex: 1;
min-height: 0;
}
.tree-node {
width: 100%;
min-width: 0;
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-count {
flex: none;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.ledger-tree :deep(.el-tree-node__content) {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<section class="card trend-chart-panel" v-loading="loading">
<div class="panel-header">
<span class="panel-title">趋势图</span>
<span class="panel-meta">
<template v-if="trendResult">
{{ trendResult.bucket || '-' }} / {{ trendResult.displayPointCount || 0 }}
</template>
</span>
</div>
<div v-if="hasSeries" class="chart-body">
<LineChart :options="chartOptions" />
</div>
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LineChart from '@/components/echarts/line/index.vue'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { buildSteadyTrendChartOptions } from '../utils/trendOptions'
defineOptions({
name: 'SteadyTrendChartPanel'
})
const props = defineProps<{
trendResult: SteadyDataView.SteadyTrendQueryResult | null
loading: boolean
}>()
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResult?.series || []))
</script>
<style scoped lang="scss">
.trend-chart-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 12px;
}
.panel-header {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
}
.panel-meta {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.chart-body {
flex: 1;
min-height: 0;
}
.chart-empty {
flex: 1;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<section class="card trend-toolbar">
<TimePeriodSearch
class="trend-toolbar__time"
:unit="modelValue.timeUnit"
:model-value="modelValue.timeBaseDate"
@update:unit="handleTimeUnitChange"
@update:model-value="handleTimeBaseDateChange"
/>
<el-select
:model-value="modelValue.phases"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择相别"
@update:model-value="updateField('phases', $event)"
>
<el-option v-for="item in phaseOptions" :key="item" :label="resolvePhaseLabel(item)" :value="item" />
</el-select>
<el-select
:model-value="modelValue.statTypes"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择统计类型"
@update:model-value="updateField('statTypes', $event)"
>
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
</el-select>
<el-select
:model-value="modelValue.bucket"
placeholder="选择时间粒度"
@update:model-value="updateField('bucket', $event)"
>
<el-option v-for="item in bucketOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select
:model-value="modelValue.qualityFlag"
clearable
placeholder="选择数据质量"
@update:model-value="updateField('qualityFlag', $event)"
>
<el-option label="仅有效数据" :value="1" />
<el-option label="仅无效数据" :value="0" />
</el-select>
<el-select
v-if="showHarmonicOrders"
:model-value="modelValue.harmonicOrders"
class="harmonic-select"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择谐波次数"
@update:model-value="updateField('harmonicOrders', $event)"
>
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
</el-select>
<div class="toolbar-actions">
<el-button type="primary" :loading="loading" @click="emit('query')">查询</el-button>
<el-button @click="emit('reset')">重置</el-button>
</div>
</section>
</template>
<script setup lang="ts">
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
import type { SteadyTrendFormState } from '../utils/trendPayload'
defineOptions({
name: 'SteadyTrendToolbar'
})
const props = defineProps<{
modelValue: SteadyTrendFormState
phaseOptions: string[]
statOptions: SteadyDataView.SteadyTrendStatType[]
showHarmonicOrders: boolean
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: SteadyTrendFormState]
query: []
reset: []
}>()
const bucketOptions = [
{ label: '1分钟', value: '1m' },
{ label: '5分钟', value: '5m' },
{ label: '10分钟', value: '10m' },
{ label: '30分钟', value: '30m' },
{ label: '1小时', value: '1h' }
]
const harmonicOrderOptions = Array.from({ length: 49 }, (_item, index) => index + 2)
const statLabelMap: Record<SteadyDataView.SteadyTrendStatType, string> = {
AVG: '平均值',
MAX: '最大值',
MIN: '最小值',
CP95: '95%概率大值'
}
const phaseLabelMap: Record<string, string> = {
A: 'A相',
B: 'B相',
C: 'C相',
T: '总相'
}
const resolvePhaseLabel = (phase: string) => phaseLabelMap[phase] || `${phase}`
const updateField = <K extends keyof SteadyTrendFormState>(field: K, value: SteadyTrendFormState[K]) => {
emit('update:modelValue', {
...props.modelValue,
[field]: value
})
}
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
emit('update:modelValue', {
...props.modelValue,
timeUnit: unit,
timeBaseDate: baseDate,
timeRange: buildTimePeriodRange(unit, baseDate)
})
}
const handleTimeUnitChange = (value: TimePeriodUnit) => {
updateTimeRange(value, props.modelValue.timeBaseDate)
}
const handleTimeBaseDateChange = (value: Date) => {
updateTimeRange(props.modelValue.timeUnit, value)
}
</script>
<style scoped lang="scss">
.trend-toolbar {
display: grid;
grid-template-columns: minmax(260px, 1.3fr) repeat(4, minmax(132px, 0.7fr)) auto;
gap: 10px;
align-items: center;
padding: 12px;
}
.trend-toolbar__time {
min-width: 260px;
}
.harmonic-select {
grid-column: span 2;
}
.toolbar-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 1280px) {
.trend-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar-actions {
justify-content: flex-start;
}
}
</style>

View File

@@ -0,0 +1,321 @@
<template>
<div class="table-box steady-data-view-page">
<div class="steady-trend-layout">
<aside class="selector-column">
<SteadyLedgerTree
:key="selectorResetKey"
:tree-data="ledgerTree"
:loading="loading.ledger"
:keyword="ledgerKeyword"
@refresh="loadLedgerTree"
@search="handleLedgerSearch"
@change="handleLedgerChange"
/>
</aside>
<main class="trend-main">
<SteadyTrendToolbar
v-model="trendForm"
:phase-options="phaseOptions"
:stat-options="statOptions"
:show-harmonic-orders="showHarmonicOrders"
:loading="loading.trend"
@query="handleQueryTrend"
@reset="resetTrendState"
/>
<div class="trend-content">
<SteadyTrendChartPanel :trend-result="trendResult" :loading="loading.trend" />
<aside class="indicator-floating-panel" :class="{ 'is-collapsed': indicatorPanelCollapsed }">
<el-button
class="indicator-toggle"
:icon="indicatorPanelCollapsed ? ArrowLeft : ArrowRight"
circle
@click="indicatorPanelCollapsed = !indicatorPanelCollapsed"
/>
<button
v-if="indicatorPanelCollapsed"
class="indicator-collapsed-trigger"
type="button"
@click="indicatorPanelCollapsed = false"
>
稳态指标
</button>
<div v-show="!indicatorPanelCollapsed" class="indicator-panel-body">
<SteadyIndicatorTree
:key="selectorResetKey"
:tree-data="indicatorTree"
:loading="loading.indicator"
@refresh="loadIndicatorTree"
@change="handleIndicatorChange"
/>
</div>
</aside>
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import { getSteadyTrendIndicatorTree, getSteadyTrendLedgerTree, querySteadyTrend } from '@/api/steady/steadyDataView'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import SteadyIndicatorTree from './components/SteadyIndicatorTree.vue'
import SteadyLedgerTree from './components/SteadyLedgerTree.vue'
import SteadyTrendChartPanel from './components/SteadyTrendChartPanel.vue'
import SteadyTrendToolbar from './components/SteadyTrendToolbar.vue'
import {
collectSelectedLineIds,
hasHarmonicIndicator,
resolveAvailablePhases,
resolveAvailableStats,
validateTrendSelection
} from './utils/selectionRules'
import { buildSteadyTrendQueryPayload, defaultTrendFormState } from './utils/trendPayload'
defineOptions({
name: 'SteadyDataView'
})
const ledgerTree = ref<SteadyDataView.SteadyLedgerNode[]>([])
const indicatorTree = ref<SteadyDataView.SteadyIndicatorNode[]>([])
const selectedLedgerNodes = ref<SteadyDataView.SteadyLedgerNode[]>([])
const selectedIndicators = ref<SteadyDataView.SteadyIndicatorNode[]>([])
const trendResult = ref<SteadyDataView.SteadyTrendQueryResult | null>(null)
const trendForm = ref(defaultTrendFormState())
const ledgerKeyword = ref('')
const indicatorPanelCollapsed = ref(false)
const selectorResetKey = ref(0)
const loading = reactive({
ledger: false,
indicator: false,
trend: false
})
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
const showHarmonicOrders = computed(() => hasHarmonicIndicator(selectedIndicators.value))
const phaseOptions = computed(() => {
const phases = resolveAvailablePhases(selectedIndicators.value)
return phases.length ? phases : ['A', 'B', 'C', 'T']
})
const statOptions = computed<SteadyDataView.SteadyTrendStatType[]>(() => {
const stats = resolveAvailableStats(selectedIndicators.value)
return stats.length ? stats : ['AVG', 'MAX', 'MIN', 'CP95']
})
const unwrapData = <T,>(response: { data: T } | T): T => {
if (response && typeof response === 'object' && 'data' in response) {
return (response as { data: T }).data
}
return response as T
}
const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
loading.ledger = true
try {
const response = await getSteadyTrendLedgerTree(keyword ? { keyword } : undefined)
ledgerTree.value = unwrapData(response) || []
} finally {
loading.ledger = false
}
}
const loadIndicatorTree = async () => {
loading.indicator = true
try {
const response = await getSteadyTrendIndicatorTree()
indicatorTree.value = unwrapData(response) || []
} finally {
loading.indicator = false
}
}
let ledgerSearchTimer: ReturnType<typeof setTimeout> | null = null
const handleLedgerSearch = (value: string) => {
ledgerKeyword.value = value
if (ledgerSearchTimer) clearTimeout(ledgerSearchTimer)
ledgerSearchTimer = setTimeout(() => loadLedgerTree(value), 300)
}
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
selectedLedgerNodes.value = nodes
}
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
selectedIndicators.value = nodes
}
const resetTrendState = () => {
trendForm.value = defaultTrendFormState()
selectedLedgerNodes.value = []
selectedIndicators.value = []
trendResult.value = null
selectorResetKey.value += 1
}
const handleQueryTrend = async () => {
const selectionError = validateTrendSelection({
lineIds: lineIds.value,
indicators: selectedIndicators.value,
phases: trendForm.value.phases,
statTypes: trendForm.value.statTypes,
harmonicOrders: trendForm.value.harmonicOrders
})
if (selectionError) {
ElMessage.warning(selectionError)
return
}
if (!trendForm.value.timeRange[0] || !trendForm.value.timeRange[1]) {
ElMessage.warning('请选择趋势时间范围')
return
}
const payload = buildSteadyTrendQueryPayload(lineIds.value, selectedIndicators.value, trendForm.value)
loading.trend = true
try {
// 趋势查询只驱动主图,右侧稳态指标作为筛选面板独立加载,避免额外摘要请求拖慢页面响应。
const trendResponse = await querySteadyTrend(payload)
trendResult.value = unwrapData(trendResponse)
} finally {
loading.trend = false
}
}
watch(
selectedIndicators,
() => {
const availablePhases = phaseOptions.value
const availableStats = statOptions.value
trendForm.value = {
...trendForm.value,
phases: trendForm.value.phases.filter(phase => availablePhases.includes(phase)),
statTypes: trendForm.value.statTypes.filter(stat => availableStats.includes(stat)),
harmonicOrders: showHarmonicOrders.value ? trendForm.value.harmonicOrders : []
}
if (!trendForm.value.phases.length) {
trendForm.value.phases = availablePhases.slice(0, Math.min(3, availablePhases.length))
}
if (!trendForm.value.statTypes.length) {
trendForm.value.statTypes = availableStats.slice(0, 1)
}
},
{ deep: true }
)
onMounted(() => {
loadLedgerTree()
loadIndicatorTree()
})
</script>
<style scoped lang="scss">
.steady-data-view-page {
min-height: 0;
overflow: hidden;
}
.steady-trend-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
}
.selector-column {
display: grid;
min-height: 0;
}
.trend-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
min-width: 0;
min-height: 0;
}
.trend-content {
position: relative;
min-width: 0;
min-height: 0;
}
.trend-content :deep(.trend-chart-panel) {
height: 100%;
}
.indicator-floating-panel {
position: absolute;
top: 12px;
right: 12px;
bottom: 12px;
z-index: 2;
width: 360px;
transition: width 0.2s ease;
}
.indicator-floating-panel.is-collapsed {
width: 44px;
}
.indicator-toggle {
position: absolute;
top: 12px;
left: -18px;
z-index: 3;
}
.indicator-panel-body {
width: 100%;
height: 100%;
}
.indicator-panel-body :deep(.steady-tree-card) {
height: 100%;
box-shadow: var(--el-box-shadow-light);
}
.indicator-collapsed-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 8px 0;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
background: var(--el-bg-color);
box-shadow: var(--el-box-shadow-light);
color: var(--el-text-color-primary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
writing-mode: vertical-rl;
}
.indicator-collapsed-trigger:hover {
border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
}
@media (max-width: 1360px) {
.steady-trend-layout {
grid-template-columns: 280px minmax(0, 1fr);
}
.indicator-floating-panel {
width: 320px;
}
}
</style>

View File

@@ -0,0 +1,118 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
export const MAX_TREND_SERIES_COUNT = 24
export const MAX_HARMONIC_ORDER_COUNT = 6
export const collectSelectedLineIds = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
const lineIds = new Set<string>()
const collect = (node: SteadyDataView.SteadyLedgerNode) => {
if (node.level === 3 || node.selectable) {
lineIds.add(node.id)
}
node.children?.forEach(collect)
}
nodes.forEach(collect)
return Array.from(lineIds)
}
export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
const indicators: SteadyDataView.SteadyIndicatorNode[] = []
const collect = (node: SteadyDataView.SteadyIndicatorNode) => {
if (node.children?.length) {
node.children.forEach(collect)
return
}
if (node.indicatorCode) {
indicators.push(node)
}
}
nodes.forEach(collect)
return indicators
}
export const hasHarmonicIndicator = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
return indicators.some(item => item.harmonic || Boolean(item.harmonicOrderStart || item.harmonicOrderEnd))
}
export const resolveAvailablePhases = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
const phaseSet = new Set<string>()
indicators.forEach(indicator => {
indicator.phaseCodes?.forEach(phase => phaseSet.add(phase))
})
return Array.from(phaseSet)
}
export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
const statSet = new Set<SteadyDataView.SteadyTrendStatType>()
indicators.forEach(indicator => {
indicator.supportStats?.forEach(stat => statSet.add(stat))
})
return Array.from(statSet)
}
export const estimateTrendSeriesCount = (
lineIds: string[],
indicators: SteadyDataView.SteadyIndicatorNode[],
phases: string[],
statTypes: SteadyDataView.SteadyTrendStatType[],
harmonicOrders: number[]
) => {
const harmonicMultiplier = hasHarmonicIndicator(indicators) ? Math.max(harmonicOrders.length, 1) : 1
return indicators.reduce((count, indicator) => {
const indicatorPhases = indicator.phaseCodes?.length ? indicator.phaseCodes : phases
const selectedPhaseCount = indicatorPhases.filter(phase => phases.includes(phase)).length || indicatorPhases.length || 1
const fieldCount = Math.max(indicator.seriesFields?.length || indicator.baseFields?.length || 1, 1)
return count + lineIds.length * selectedPhaseCount * Math.max(statTypes.length, 1) * fieldCount * harmonicMultiplier
}, 0)
}
export const validateHarmonicOrders = (
indicators: SteadyDataView.SteadyIndicatorNode[],
harmonicOrders: number[]
) => {
if (!hasHarmonicIndicator(indicators)) return ''
if (!harmonicOrders.length) return '谐波指标必须选择谐波次数'
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return '谐波次数最多选择 6 个'
return ''
}
export const validateTrendSelection = (params: {
lineIds: string[]
indicators: SteadyDataView.SteadyIndicatorNode[]
phases: string[]
statTypes: SteadyDataView.SteadyTrendStatType[]
harmonicOrders: number[]
}) => {
const { lineIds, indicators, phases, statTypes, harmonicOrders } = params
if (!lineIds.length) return '请选择监测点'
if (!indicators.length) return '请选择趋势指标'
if (lineIds.length > 1 && indicators.length > 1) return '多监测点查询时只能选择 1 个指标'
if (!statTypes.length) return '请选择统计类型'
if (!phases.length) return '请选择相别'
const harmonicError = validateHarmonicOrders(indicators, harmonicOrders)
if (harmonicError) return harmonicError
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, phases, statTypes, harmonicOrders)
if (seriesCount > MAX_TREND_SERIES_COUNT) {
return '趋势曲线数量不能超过 24 条,请缩小监测点、指标、相别或统计类型范围'
}
return ''
}

View File

@@ -0,0 +1,72 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
const trendColors = [
'var(--el-color-primary)',
'#00a870',
'#e6a23c',
'#f56c6c',
'#626aef',
'#909399',
'#14b8a6',
'#f97316'
]
const formatSeriesName = (series: SteadyDataView.SteadyTrendSeries) => {
return [series.lineName, series.indicatorName || series.seriesName, series.phase, series.statType]
.filter(Boolean)
.join(' / ')
}
export const buildSteadyTrendChartOptions = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
const timeLabels = Array.from(
new Set(seriesList.flatMap(series => (series.points || []).map(point => point.time)))
).sort()
return {
title: {
text: ''
},
tooltip: {
trigger: 'axis'
},
legend: {
type: 'scroll',
top: 0,
right: 12
},
grid: {
top: 48,
left: 64,
right: 28,
bottom: 48,
containLabel: false
},
xAxis: {
type: 'category',
data: timeLabels,
boundaryGap: false
},
yAxis: {
type: 'value',
scale: true,
axisLabel: {
formatter: '{value}'
}
},
color: trendColors,
series: seriesList.map(series => {
const pointMap = new Map((series.points || []).map(point => [point.time, point.value]))
return {
name: formatSeriesName(series),
type: 'line',
showSymbol: false,
connectNulls: false,
data: timeLabels.map(time => pointMap.get(time) ?? null),
lineStyle: {
width: 1.2
}
}
})
}
}

View File

@@ -0,0 +1,46 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
export interface SteadyTrendFormState {
timeRange: string[]
timeUnit: TimePeriodUnit
timeBaseDate: Date
phases: string[]
statTypes: SteadyDataView.SteadyTrendStatType[]
bucket: string
qualityFlag?: number
harmonicOrders: number[]
}
export const defaultTrendFormState = (): SteadyTrendFormState => {
const baseDate = new Date()
return {
timeRange: buildTimePeriodRange('month', baseDate),
timeUnit: 'month',
timeBaseDate: baseDate,
phases: ['A', 'B', 'C'],
statTypes: ['AVG'],
bucket: '10m',
qualityFlag: 1,
harmonicOrders: []
}
}
export const buildSteadyTrendQueryPayload = (
lineIds: string[],
indicators: SteadyDataView.SteadyIndicatorNode[],
formState: SteadyTrendFormState
): SteadyDataView.SteadyTrendQueryParams => {
return {
lineIds,
indicatorCodes: indicators.map(item => item.indicatorCode).filter(Boolean) as string[],
statTypes: formState.statTypes,
phases: formState.phases,
timeStart: formState.timeRange[0] || '',
timeEnd: formState.timeRange[1] || '',
bucket: formState.bucket,
qualityFlag: formState.qualityFlag,
harmonicOrders: formState.harmonicOrders.length ? formState.harmonicOrders : undefined
}
}

View File

@@ -26,8 +26,8 @@
<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">
<el-input v-model="localForm.mac" maxlength="64" clearable placeholder="请输入装置 MAC 地址" />
<el-form-item label="装置网络参数" prop="mac">
<el-input v-model="localForm.mac" maxlength="64" clearable placeholder="请输入装置网络参数" />
</el-form-item>
<el-form-item label="装置类型" prop="dev_type">
<el-select v-model="localForm.dev_type" clearable placeholder="请选择装置类型">
@@ -156,7 +156,7 @@ const isSimpleMode = computed(() => props.mode === 'simple')
const formRules = computed<FormRules<AddLedger.EquipmentForm>>(() => ({
name: [{ required: true, message: '请输入装置名称', trigger: 'blur' }],
...(isSimpleMode.value ? {} : { ndid: [{ required: true, message: '请输入网络设备 ID', trigger: 'blur' }] }),
mac: [{ required: true, message: '请输入装置 MAC 地址', trigger: 'blur' }],
mac: [{ required: true, message: '请输入装置网络参数', trigger: 'blur' }],
dev_model: [{ required: true, message: '请选择装置型号', trigger: 'change' }]
}))

View File

@@ -90,6 +90,7 @@ import {
} from '@/api/tools/addLedger'
import type { AddLedger } from '@/api/tools/addLedger/interface'
import { useDictStore } from '@/stores/modules/dict'
import { DICT_CODES } from '@/constants/dictCodes'
import LedgerTreePanel from './components/LedgerTreePanel.vue'
import LedgerContextPanel from './components/LedgerContextPanel.vue'
import {
@@ -126,7 +127,7 @@ type LedgerContextItem<T> = {
draft?: boolean
}
type LedgerDictCode = 'ledger_device_type' | 'ledger_device_model'
type LedgerDictCode = typeof DICT_CODES.LEDGER_DEVICE_TYPE | typeof DICT_CODES.LEDGER_DEVICE_MODEL
const dictStore = useDictStore()
const treeData = ref<AddLedger.NormalizedTreeNode[]>([])
@@ -169,8 +170,8 @@ const activeTabIds = reactive({
line: ''
})
const ledgerDictOptions = reactive<Record<LedgerDictCode, AddLedger.SelectOption[]>>({
ledger_device_type: [],
ledger_device_model: []
[DICT_CODES.LEDGER_DEVICE_TYPE]: [],
[DICT_CODES.LEDGER_DEVICE_MODEL]: []
})
let detailRequestSeq = 0
@@ -186,14 +187,14 @@ const fallbackDeviceModelOptions: AddLedger.SelectOption[] = [
{ label: 'PQS680', value: 'pqs680' }
]
const deviceTypeOptions = computed(() => resolveDictOptions('ledger_device_type', fallbackDeviceTypeOptions))
const deviceModelOptions = computed(() => resolveDictOptions('ledger_device_model', fallbackDeviceModelOptions))
const deviceTypeOptions = computed(() => resolveDictOptions(DICT_CODES.LEDGER_DEVICE_TYPE, fallbackDeviceTypeOptions))
const deviceModelOptions = computed(() => resolveDictOptions(DICT_CODES.LEDGER_DEVICE_MODEL, fallbackDeviceModelOptions))
const emptyStateText = computed(() =>
treeData.value.length === 0 ? '台账树为空,请先新增一个工程。' : '从左侧台账树选择工程、项目、设备或监测点。'
)
const resolveFallbackDictOptions = (code: LedgerDictCode) =>
code === 'ledger_device_type' ? fallbackDeviceTypeOptions : fallbackDeviceModelOptions
code === DICT_CODES.LEDGER_DEVICE_TYPE ? fallbackDeviceTypeOptions : fallbackDeviceModelOptions
const resolveCachedDictOptions = (code: LedgerDictCode) => {
const dictData = dictStore.getDictData(code)
@@ -236,8 +237,8 @@ const ensureLedgerDictOptionById = async (code: LedgerDictCode, value: string) =
const ensureEquipmentDictOptions = async (form: AddLedger.EquipmentForm) => {
await Promise.all([
ensureLedgerDictOptionById('ledger_device_type', form.dev_type || ''),
ensureLedgerDictOptionById('ledger_device_model', form.dev_model || '')
ensureLedgerDictOptionById(DICT_CODES.LEDGER_DEVICE_TYPE, form.dev_type || ''),
ensureLedgerDictOptionById(DICT_CODES.LEDGER_DEVICE_MODEL, form.dev_model || '')
])
}
@@ -252,8 +253,8 @@ const loadLedgerDictOptionsByCode = (code: LedgerDictCode) => {
}
const loadLedgerDictOptions = async () => {
loadLedgerDictOptionsByCode('ledger_device_type')
loadLedgerDictOptionsByCode('ledger_device_model')
loadLedgerDictOptionsByCode(DICT_CODES.LEDGER_DEVICE_TYPE)
loadLedgerDictOptionsByCode(DICT_CODES.LEDGER_DEVICE_MODEL)
}
const getCurrentPath = () => {