From 6687cf03392c6b3327017f05c75e896130006e51 Mon Sep 17 00:00:00 2001 From: yexb <553699424@qq.com> Date: Fri, 15 May 2026 16:36:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=91=E5=8F=AB=E6=B4=AA=E5=9C=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + .../api/event/eventList/interface/index.ts | 2 + .../src/api/steady/steadyDataView/index.ts | 18 + .../steady/steadyDataView/interface/index.ts | 78 +++++ frontend/src/constants/dictCodes.ts | 8 + frontend/src/routers/index.ts | 52 ++- frontend/src/routers/modules/dynamicRouter.ts | 40 ++- frontend/src/routers/modules/staticRouter.ts | 18 + frontend/src/stores/modules/auth.ts | 23 +- frontend/src/views/authority/user/index.vue | 3 +- .../check-time-period-contract.mjs | 87 +++++ .../components/TimePeriodSearch/index.vue | 111 ++++++ .../components/TimePeriodSearch/timePeriod.ts | 85 +++++ .../eventList/check-display-contract.mjs | 27 +- .../eventList/check-query-params-contract.mjs | 37 ++ .../check-search-layout-contract.mjs | 8 +- .../eventList/check-time-range-contract.mjs | 43 ++- .../eventList/check-visible-contract.mjs | 23 +- .../views/event/eventList/eventTimeRange.ts | 86 ----- frontend/src/views/event/eventList/index.vue | 177 +++------- .../event/eventList/utils/detailItems.ts | 4 +- .../views/event/eventList/utils/display.ts | 29 +- .../views/steady/steadyDataView/API_DEBUG.md | 239 +++++++++++++ .../steadyDataView/check-route-contract.mjs | 34 ++ .../steadyDataView/check-trend-contract.mjs | 107 ++++++ .../steadyDataView/check-visible-contract.mjs | 59 ++++ .../components/SteadyIndicatorTree.vue | 117 +++++++ .../components/SteadyLedgerTree.vue | 123 +++++++ .../components/SteadyTrendChartPanel.vue | 74 ++++ .../components/SteadyTrendToolbar.vue | 175 ++++++++++ .../src/views/steady/steadyDataView/index.vue | 321 ++++++++++++++++++ .../steadyDataView/utils/selectionRules.ts | 118 +++++++ .../steadyDataView/utils/trendOptions.ts | 72 ++++ .../steadyDataView/utils/trendPayload.ts | 46 +++ .../addLedger/components/EquipmentForm.vue | 6 +- frontend/src/views/tools/addLedger/index.vue | 21 +- 36 files changed, 2201 insertions(+), 271 deletions(-) create mode 100644 frontend/src/api/steady/steadyDataView/index.ts create mode 100644 frontend/src/api/steady/steadyDataView/interface/index.ts create mode 100644 frontend/src/constants/dictCodes.ts create mode 100644 frontend/src/views/components/TimePeriodSearch/check-time-period-contract.mjs create mode 100644 frontend/src/views/components/TimePeriodSearch/index.vue create mode 100644 frontend/src/views/components/TimePeriodSearch/timePeriod.ts create mode 100644 frontend/src/views/event/eventList/check-query-params-contract.mjs create mode 100644 frontend/src/views/steady/steadyDataView/API_DEBUG.md create mode 100644 frontend/src/views/steady/steadyDataView/check-route-contract.mjs create mode 100644 frontend/src/views/steady/steadyDataView/check-trend-contract.mjs create mode 100644 frontend/src/views/steady/steadyDataView/check-visible-contract.mjs create mode 100644 frontend/src/views/steady/steadyDataView/components/SteadyIndicatorTree.vue create mode 100644 frontend/src/views/steady/steadyDataView/components/SteadyLedgerTree.vue create mode 100644 frontend/src/views/steady/steadyDataView/components/SteadyTrendChartPanel.vue create mode 100644 frontend/src/views/steady/steadyDataView/components/SteadyTrendToolbar.vue create mode 100644 frontend/src/views/steady/steadyDataView/index.vue create mode 100644 frontend/src/views/steady/steadyDataView/utils/selectionRules.ts create mode 100644 frontend/src/views/steady/steadyDataView/utils/trendOptions.ts create mode 100644 frontend/src/views/steady/steadyDataView/utils/trendPayload.ts diff --git a/AGENTS.md b/AGENTS.md index 768c494..77bee55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ - 页面边距约定:业务页面根节点默认跟随布局主内容区 `el-main` 的 `15px` 边距,不再额外叠加页面级外边距;如需特殊边距,必须先有明确的视觉参照页面或业务原因。 - 表格样式约定:业务表格优先复用仓库现有 `table-main`、`card`、`table-header` 结构,参照 `dictdata` 页面;表格卡片内部默认不再额外堆叠页面标题、说明文案或自定义装饰区,表头左侧用于主操作、次操作和危险批量操作,右侧用于刷新、列设置、搜索等工具按钮;不要在单页里重复自定义表格卡片边框、表头按钮布局和表头配色,除非有明确视觉参照或业务原因。 - 按钮样式约定:业务页面按钮参照 `dictdata` 页面;表头主操作使用 `type="primary"`,表头次操作使用 `type="primary" plain`,危险批量操作使用 `type="danger" plain`,表格行内操作统一使用 `link` 风格并优先保持 `primary` 语义与图标一致性;弹窗底部保持“取消”默认按钮、“主确认”使用 `primary`,同级辅助执行按钮使用 `primary plain`。 +- 数据字典常量约定:凡是使用后端数据字典类型 code,必须统一维护在 `frontend/src/constants/dictCodes.ts`;页面、组件、store、hook 和工具函数中不得直接硬编码字典 code 字符串,新增字典类型时先补充常量再引用。 - 先定义验证方式:执行方案里要写清楚“改哪里、怎么判断改对了”;默认通过代码路径、配置一致性、界面影响范围和启动链路检查进行验证。 - 除非用户明确要求,否则不执行 `npm run build`、`npm run build-w`、`electron-builder`、加密打包或其他重型构建命令;通常只做静态检查、必要的代码阅读和轻量验证。 diff --git a/frontend/src/api/event/eventList/interface/index.ts b/frontend/src/api/event/eventList/interface/index.ts index e47eaec..68d2c89 100644 --- a/frontend/src/api/event/eventList/interface/index.ts +++ b/frontend/src/api/event/eventList/interface/index.ts @@ -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 diff --git a/frontend/src/api/steady/steadyDataView/index.ts b/frontend/src/api/steady/steadyDataView/index.ts new file mode 100644 index 0000000..22d10fe --- /dev/null +++ b/frontend/src/api/steady/steadyDataView/index.ts @@ -0,0 +1,18 @@ +import http from '@/api' +import type { SteadyDataView } from './interface' + +export const getSteadyTrendLedgerTree = (params?: { keyword?: string }) => { + return http.get('/steady/data-view/ledger-tree', params, { loading: false }) +} + +export const getSteadyTrendIndicatorTree = () => { + return http.get('/steady/data-view/indicator-tree', {}, { loading: false }) +} + +export const querySteadyTrend = (params: SteadyDataView.SteadyTrendQueryParams) => { + return http.post('/steady/data-view/trend/query', params) +} + +export const querySteadyTrendDay = (params: SteadyDataView.SteadyTrendQueryParams) => { + return http.post('/steady/data-view/trend/day', params) +} diff --git a/frontend/src/api/steady/steadyDataView/interface/index.ts b/frontend/src/api/steady/steadyDataView/interface/index.ts new file mode 100644 index 0000000..68f1d60 --- /dev/null +++ b/frontend/src/api/steady/steadyDataView/interface/index.ts @@ -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[] + } + +} diff --git a/frontend/src/constants/dictCodes.ts b/frontend/src/constants/dictCodes.ts new file mode 100644 index 0000000..7ed9f4e --- /dev/null +++ b/frontend/src/constants/dictCodes.ts @@ -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] diff --git a/frontend/src/routers/index.ts b/frontend/src/routers/index.ts index a127ef8..577648d 100644 --- a/frontend/src/routers/index.ts +++ b/frontend/src/routers/index.ts @@ -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) => { + 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 diff --git a/frontend/src/routers/modules/dynamicRouter.ts b/frontend/src/routers/modules/dynamicRouter.ts index 36bf96b..e30a7a8 100644 --- a/frontend/src/routers/modules/dynamicRouter.ts +++ b/frontend/src/routers/modules/dynamicRouter.ts @@ -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) => { + 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 = { // 后端菜单沿用 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 } } diff --git a/frontend/src/routers/modules/staticRouter.ts b/frontend/src/routers/modules/staticRouter.ts index 3ff3d25..f7b6017 100644 --- a/frontend/src/routers/modules/staticRouter.ts +++ b/frontend/src/routers/modules/staticRouter.ts @@ -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', diff --git a/frontend/src/stores/modules/auth.ts b/frontend/src/stores/modules/auth.ts index 2e83fce..596b48e 100644 --- a/frontend/src/stores/modules/auth.ts +++ b/frontend/src/stores/modules/auth.ts @@ -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 { diff --git a/frontend/src/views/authority/user/index.vue b/frontend/src/views/authority/user/index.vue index d0b7240..8947d78 100644 --- a/frontend/src/views/authority/user/index.vue +++ b/frontend/src/views/authority/user/index.vue @@ -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); diff --git a/frontend/src/views/components/TimePeriodSearch/check-time-period-contract.mjs b/frontend/src/views/components/TimePeriodSearch/check-time-period-contract.mjs new file mode 100644 index 0000000..8e8441a --- /dev/null +++ b/frontend/src/views/components/TimePeriodSearch/check-time-period-contract.mjs @@ -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') diff --git a/frontend/src/views/components/TimePeriodSearch/index.vue b/frontend/src/views/components/TimePeriodSearch/index.vue new file mode 100644 index 0000000..2283df5 --- /dev/null +++ b/frontend/src/views/components/TimePeriodSearch/index.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend/src/views/components/TimePeriodSearch/timePeriod.ts b/frontend/src/views/components/TimePeriodSearch/timePeriod.ts new file mode 100644 index 0000000..12250a2 --- /dev/null +++ b/frontend/src/views/components/TimePeriodSearch/timePeriod.ts @@ -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 = { + day: 'date', + month: 'month', + year: 'year' +} + +const datePickerFormatMap: Record = { + 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 +} diff --git a/frontend/src/views/event/eventList/check-display-contract.mjs b/frontend/src/views/event/eventList/check-display-contract.mjs index e716b03..ba6926f 100644 --- a/frontend/src/views/event/eventList/check-display-contract.mjs +++ b/frontend/src/views/event/eventList/check-display-contract.mjs @@ -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') diff --git a/frontend/src/views/event/eventList/check-query-params-contract.mjs b/frontend/src/views/event/eventList/check-query-params-contract.mjs new file mode 100644 index 0000000..23c89ab --- /dev/null +++ b/frontend/src/views/event/eventList/check-query-params-contract.mjs @@ -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') diff --git a/frontend/src/views/event/eventList/check-search-layout-contract.mjs b/frontend/src/views/event/eventList/check-search-layout-contract.mjs index 9452cb9..b436d68 100644 --- a/frontend/src/views/event/eventList/check-search-layout-contract.mjs +++ b/frontend/src/views/event/eventList/check-search-layout-contract.mjs @@ -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)) diff --git a/frontend/src/views/event/eventList/check-time-range-contract.mjs b/frontend/src/views/event/eventList/check-time-range-contract.mjs index 1dfb740..0daeef6 100644 --- a/frontend/src/views/event/eventList/check-time-range-contract.mjs +++ b/frontend/src/views/event/eventList/check-time-range-contract.mjs @@ -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' ]) diff --git a/frontend/src/views/event/eventList/check-visible-contract.mjs b/frontend/src/views/event/eventList/check-visible-contract.mjs index 93bc60f..a61b208 100644 --- a/frontend/src/views/event/eventList/check-visible-contract.mjs +++ b/frontend/src/views/event/eventList/check-visible-contract.mjs @@ -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)] ] diff --git a/frontend/src/views/event/eventList/eventTimeRange.ts b/frontend/src/views/event/eventList/eventTimeRange.ts index cde6f06..60cb9af 100644 --- a/frontend/src/views/event/eventList/eventTimeRange.ts +++ b/frontend/src/views/event/eventList/eventTimeRange.ts @@ -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 = { - day: 'date', - month: 'month', - year: 'year' -} - -const datePickerFormatMap: Record = { - 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 -} diff --git a/frontend/src/views/event/eventList/index.vue b/frontend/src/views/event/eventList/index.vue index e2d183a..69e77b3 100644 --- a/frontend/src/views/event/eventList/index.vue +++ b/frontend/src/views/event/eventList/index.vue @@ -46,25 +46,20 @@ + + diff --git a/frontend/src/views/steady/steadyDataView/components/SteadyLedgerTree.vue b/frontend/src/views/steady/steadyDataView/components/SteadyLedgerTree.vue new file mode 100644 index 0000000..4769570 --- /dev/null +++ b/frontend/src/views/steady/steadyDataView/components/SteadyLedgerTree.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/src/views/steady/steadyDataView/components/SteadyTrendChartPanel.vue b/frontend/src/views/steady/steadyDataView/components/SteadyTrendChartPanel.vue new file mode 100644 index 0000000..e8bf9c1 --- /dev/null +++ b/frontend/src/views/steady/steadyDataView/components/SteadyTrendChartPanel.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/views/steady/steadyDataView/components/SteadyTrendToolbar.vue b/frontend/src/views/steady/steadyDataView/components/SteadyTrendToolbar.vue new file mode 100644 index 0000000..334335b --- /dev/null +++ b/frontend/src/views/steady/steadyDataView/components/SteadyTrendToolbar.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/frontend/src/views/steady/steadyDataView/index.vue b/frontend/src/views/steady/steadyDataView/index.vue new file mode 100644 index 0000000..10d4e41 --- /dev/null +++ b/frontend/src/views/steady/steadyDataView/index.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/frontend/src/views/steady/steadyDataView/utils/selectionRules.ts b/frontend/src/views/steady/steadyDataView/utils/selectionRules.ts new file mode 100644 index 0000000..090c5c3 --- /dev/null +++ b/frontend/src/views/steady/steadyDataView/utils/selectionRules.ts @@ -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() + + 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() + + indicators.forEach(indicator => { + indicator.phaseCodes?.forEach(phase => phaseSet.add(phase)) + }) + + return Array.from(phaseSet) +} + +export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicatorNode[]) => { + const statSet = new Set() + + 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 '' +} diff --git a/frontend/src/views/steady/steadyDataView/utils/trendOptions.ts b/frontend/src/views/steady/steadyDataView/utils/trendOptions.ts new file mode 100644 index 0000000..f582f3b --- /dev/null +++ b/frontend/src/views/steady/steadyDataView/utils/trendOptions.ts @@ -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 + } + } + }) + } +} diff --git a/frontend/src/views/steady/steadyDataView/utils/trendPayload.ts b/frontend/src/views/steady/steadyDataView/utils/trendPayload.ts new file mode 100644 index 0000000..e7d5634 --- /dev/null +++ b/frontend/src/views/steady/steadyDataView/utils/trendPayload.ts @@ -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 + } +} diff --git a/frontend/src/views/tools/addLedger/components/EquipmentForm.vue b/frontend/src/views/tools/addLedger/components/EquipmentForm.vue index 6211284..c0ca40f 100644 --- a/frontend/src/views/tools/addLedger/components/EquipmentForm.vue +++ b/frontend/src/views/tools/addLedger/components/EquipmentForm.vue @@ -26,8 +26,8 @@ - - + + @@ -156,7 +156,7 @@ const isSimpleMode = computed(() => props.mode === 'simple') const formRules = computed>(() => ({ 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' }] })) diff --git a/frontend/src/views/tools/addLedger/index.vue b/frontend/src/views/tools/addLedger/index.vue index f1bbd2d..3917635 100644 --- a/frontend/src/views/tools/addLedger/index.vue +++ b/frontend/src/views/tools/addLedger/index.vue @@ -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 = { 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([]) @@ -169,8 +170,8 @@ const activeTabIds = reactive({ line: '' }) const ledgerDictOptions = reactive>({ - 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 = () => {