我叫洪圣文
This commit is contained in:
@@ -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
|
||||
|
||||
18
frontend/src/api/steady/steadyDataView/index.ts
Normal file
18
frontend/src/api/steady/steadyDataView/index.ts
Normal 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)
|
||||
}
|
||||
78
frontend/src/api/steady/steadyDataView/interface/index.ts
Normal file
78
frontend/src/api/steady/steadyDataView/interface/index.ts
Normal 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[]
|
||||
}
|
||||
|
||||
}
|
||||
8
frontend/src/constants/dictCodes.ts
Normal file
8
frontend/src/constants/dictCodes.ts
Normal 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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
111
frontend/src/views/components/TimePeriodSearch/index.vue
Normal file
111
frontend/src/views/components/TimePeriodSearch/index.vue
Normal 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>
|
||||
85
frontend/src/views/components/TimePeriodSearch/timePeriod.ts
Normal file
85
frontend/src/views/components/TimePeriodSearch/timePeriod.ts
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
@@ -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))
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -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)]
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
239
frontend/src/views/steady/steadyDataView/API_DEBUG.md
Normal file
239
frontend/src/views/steady/steadyDataView/API_DEBUG.md
Normal 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` 查询。
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
321
frontend/src/views/steady/steadyDataView/index.vue
Normal file
321
frontend/src/views/steady/steadyDataView/index.vue
Normal 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>
|
||||
118
frontend/src/views/steady/steadyDataView/utils/selectionRules.ts
Normal file
118
frontend/src/views/steady/steadyDataView/utils/selectionRules.ts
Normal 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 ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' }]
|
||||
}))
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user