diff --git a/AGENTS.md b/AGENTS.md index 2e5eb16..54aef68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,8 @@ PR 应包含: - 必须显示纵坐标最大值和最小值,图表配置中应显式保留 `showMaxLabel` 与 `showMinLabel`。 - 纵坐标刻度值采用均分方式生成,不再使用会改变刻度间隔的“友好刻度”取整逻辑。 -- 纵坐标最大值和最小值基于图形内真实最大值、最小值按 `1.2` 倍扩展;正数下边界使用 `0.8` 倍向下留白,负数上边界使用 `0.8` 倍向上留白,避免正数最小值或负数最大值被扩展到数据内侧。 +- 纵坐标最大值和最小值基于图形内真实最大值、最小值向外留白;当边界值绝对值 `> 1` 时,外扩边界使用 `1.05` 倍,内收边界使用 `0.95` 倍;当边界值绝对值 `<= 1` 时,继续使用 `1.15` / `0.85`,避免小数趋势图范围过窄或坐标轴退化。正数下边界使用内收倍数向下留白,负数上边界使用内收倍数向上留白,避免正数最小值或负数最大值被扩展到数据内侧。 +- 当数据整体绝对值 `> 1` 且真实波动范围较窄时,纵坐标可启用紧凑刻度候选:以 `1.015` / `0.985` 作为最小可接受留白边界,允许增加少量均分段数并使用更小的可读步长,优先减少上下空白;紧凑候选的额外分段惩罚应低于普通候选,避免 `200-240` 这类大空白方案因为分段更少而胜出。例如 220V 附近窄幅波动不应被可读步长放大到 `200-240`,更合理时可收敛到类似 `205-235` 的范围。 - 当数据同时包含正负值且正负幅值接近时,纵坐标最大值和最小值应尽量对称,按较大绝对值向外取整后取 `±同一边界`,例如最大值 `178`、最小值 `-177` 时显示为 `180` 与 `-180`。 - 当最大值、最小值相同或数据接近 `0` 时,需要补充兜底范围,避免坐标轴退化为一条线;小于 `1` 的小数范围按实际小数精度保留,不强制取整。 - 当纵坐标区间较小且均分后出现冗长小数时,应优先使用 `1`、`2`、`2.5`、`5` 等可读步长归一化刻度;必要时可少量增加分段,但必须继续保证刻度均分、最大最小值显示、真实数据完整落在坐标范围内。 @@ -94,6 +95,7 @@ PR 应包含: - 多图不得让 ECharts 按各自纵坐标标签宽度自动改变绘图区起点;应使用统一的 `grid.left`,并显式配置 `grid.containLabel: false` 或等效方案,避免 `150`、`2`、`-100` 等标签宽度差异导致曲线区域错位。 - 纵坐标标签宽度预留应按同组图中最长标签统一评估,必要时增加统一的左侧 `grid.left`,不能为单张图单独调整左边距。 - 横坐标首尾标签、单位文字或底部留白只能影响底部显示空间,不应改变绘图区左边界;调整 `grid.bottom`、`axisLabel.margin`、`nameGap` 时,需要同步检查多图 x=0 起点是否仍然对齐。 +- `waveform` 与 `steady` 多图联动时必须保留鼠标悬停竖线,趋势图 tooltip 应使用 `axisPointer.type: 'line'`,同一联动组图表应通过 ECharts `group` 同步 tooltip / axisPointer;T1/T2 等固定标记线属于 `markLine`,不得与悬停联动竖线混淆。 - 验证多图趋势图时,至少检查单通道拆分图和全部通道列表图两种场景;判断标准是多张图左侧坐标轴竖线形成同一条垂直线,底部横坐标标签不遮挡、不贴线。 @@ -103,5 +105,5 @@ PR 应包含: - 当前可见点数按横向缩放范围计算:`ceil(seriesDataLength * ((dataZoom.end - dataZoom.start) / 100))`。 - 初始全量展示时,点数越多线越细;横向放大后可见点数减少,线宽可逐步变粗;横向缩小或重置后线宽恢复到对应细线档位。 - Y 轴缩放、测量模式、峰值显示不改变主线线宽,避免状态切换造成额外视觉跳动。 -- 主线最大线宽不得超过 `1.6`。 -- 线宽分档统一为:`>= 200000` 使用 `0.35`,`100000 - 199999` 使用 `0.45`,`50000 - 99999` 使用 `0.55`,`20000 - 49999` 使用 `0.65`,`10000 - 19999` 使用 `0.75`,`5000 - 9999` 使用 `0.9`,`2000 - 4999` 使用 `1`,`800 - 1999` 使用 `1.2`,`200 - 799` 使用 `1.4`,`< 200` 使用 `1.6`。 +- 主线最大线宽不得超过 `1.3`。 +- 线宽分档统一为:`>= 200000` 使用 `0.35`,`100000 - 199999` 使用 `0.45`,`50000 - 99999` 使用 `0.55`,`20000 - 49999` 使用 `0.65`,`10000 - 19999` 使用 `0.75`,`5000 - 9999` 使用 `0.9`,`2000 - 4999` 使用 `1`,`800 - 1999` 使用 `1.1`,`200 - 799` 使用 `1.2`,`< 200` 使用 `1.3`。 diff --git a/frontend/package.json b/frontend/package.json index 71287e6..c0735c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ "pinia-plugin-persistedstate": "^3.2.1", "print-js": "^1.6.0", "qs": "^6.11.2", + "scichart": "^5.2.28", "screenfull": "^6.0.2", "semver": "^7.3.5", "sortablejs": "^1.15.0", diff --git a/frontend/src/api/steady/steadyDataView/index.ts b/frontend/src/api/steady/steadyDataView/index.ts index 3a1f1ca..096e937 100644 --- a/frontend/src/api/steady/steadyDataView/index.ts +++ b/frontend/src/api/steady/steadyDataView/index.ts @@ -16,3 +16,9 @@ export const querySteadyTrend = (params: SteadyDataView.SteadyTrendQueryParams) export const querySteadyTrendDay = (params: SteadyDataView.SteadyTrendQueryParams) => { return http.post('/steady/data-view/trend/day', params, { loading: false }) } + +export const querySteadyChecksquare = (params: SteadyDataView.SteadyChecksquareQueryParams) => { + return http.post('/steady/data-view/checksquare/query', params, { + loading: false + }) +} diff --git a/frontend/src/api/steady/steadyDataView/interface/index.ts b/frontend/src/api/steady/steadyDataView/interface/index.ts index e1f5a72..b80e896 100644 --- a/frontend/src/api/steady/steadyDataView/interface/index.ts +++ b/frontend/src/api/steady/steadyDataView/interface/index.ts @@ -59,6 +59,7 @@ export namespace SteadyDataView { indicatorName?: string seriesName?: string phase?: string + harmonicOrder?: number statType?: SteadyTrendStatType unit?: string points: SteadyTrendPoint[] @@ -70,7 +71,69 @@ export namespace SteadyDataView { sourcePointCount?: number displayPointCount?: number loadableDays?: string[] + queryTimeStart?: string + queryTimeEnd?: string + queryCompleted?: boolean series: SteadyTrendSeries[] } + export interface SteadyChecksquareQueryParams { + lineId: string + indicatorCodes: string[] + timeStart: string + timeEnd: string + harmonicOrders?: number[] + } + + export interface SteadyChecksquareSegment { + startTime: string + endTime: string + status: 'NORMAL' | 'MISSING' | string + missingPointCount?: number + durationMinutes?: number + } + + export interface SteadyChecksquareStatSummary { + statType: SteadyTrendStatType + supported: boolean + hasData?: boolean + expectedPointCount?: number + actualPointCount?: number + missingPointCount?: number + missingRate?: number | null + missingRateText?: string | null + maxContinuousMissingMinutes?: number + } + + export interface SteadyChecksquareStatDetail { + statType: SteadyTrendStatType + supported: boolean + segments: SteadyChecksquareSegment[] + } + + export interface SteadyChecksquareItem { + itemKey: string + indicatorCode: string + indicatorName?: string + harmonicOrder?: number | null + hasData?: boolean + expectedPointCount?: number + actualPointCount?: number + missingPointCount?: number + missingRate?: number | null + missingRateText?: string | null + maxContinuousMissingMinutes?: number + statSummaries: SteadyChecksquareStatSummary[] + statDetails: SteadyChecksquareStatDetail[] + children?: SteadyChecksquareItem[] + } + + export interface SteadyChecksquareQueryResult { + lineId: string + lineName?: string + timeStart: string + timeEnd: string + intervalMinutes?: number + items: SteadyChecksquareItem[] + } } diff --git a/frontend/src/api/steady/steadyTrend/index.ts b/frontend/src/api/steady/steadyTrend/index.ts new file mode 100644 index 0000000..c01bb66 --- /dev/null +++ b/frontend/src/api/steady/steadyTrend/index.ts @@ -0,0 +1,18 @@ +import http from '@/api' +import type { SteadyTrend } 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: SteadyTrend.SteadyTrendQueryParams) => { + return http.post('/steady/data-view/trend/query', params, { loading: false }) +} + +export const querySteadyTrendDay = (params: SteadyTrend.SteadyTrendQueryParams) => { + return http.post('/steady/data-view/trend/day', params, { loading: false }) +} diff --git a/frontend/src/api/steady/steadyTrend/interface/index.ts b/frontend/src/api/steady/steadyTrend/interface/index.ts new file mode 100644 index 0000000..66e4930 --- /dev/null +++ b/frontend/src/api/steady/steadyTrend/interface/index.ts @@ -0,0 +1,77 @@ +export namespace SteadyTrend { + 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[] + timeStart: string + timeEnd: 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 + harmonicOrder?: number + 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/components/echarts/line/index.vue b/frontend/src/components/echarts/line/index.vue index 16a4eff..53b5bd0 100644 --- a/frontend/src/components/echarts/line/index.vue +++ b/frontend/src/components/echarts/line/index.vue @@ -117,6 +117,57 @@ const resolveZoomRangeFromAxisValues = (startValue: unknown, endValue: unknown) ) } +const resolveTimeValue = (value: unknown): number | undefined => { + if (Array.isArray(value)) return resolveTimeValue(value[0]) + + const numberValue = getFiniteNumber(value) + if (numberValue !== undefined) return numberValue + + if (typeof value === 'string') { + const timestamp = Date.parse(value.replace(' ', 'T')) + + return Number.isFinite(timestamp) ? timestamp : undefined + } + + return undefined +} + +const getSeriesTimeRange = () => { + const seriesList = Array.isArray(props.options?.series) ? props.options.series : [] + let minTime = Number.POSITIVE_INFINITY + let maxTime = Number.NEGATIVE_INFINITY + + seriesList.forEach((series: { data?: unknown[] }) => { + ;(series.data || []).forEach(point => { + const timestamp = resolveTimeValue(point) + + if (timestamp === undefined) return + + minTime = Math.min(minTime, timestamp) + maxTime = Math.max(maxTime, timestamp) + }) + }) + + return Number.isFinite(minTime) && Number.isFinite(maxTime) && maxTime > minTime + ? { minTime, maxTime } + : null +} + +const resolveZoomRangeFromTimeAxisValues = (startValue: unknown, endValue: unknown) => { + const startTime = resolveTimeValue(startValue) + const endTime = resolveTimeValue(endValue) + const timeRange = getSeriesTimeRange() + + if (startTime === undefined || endTime === undefined || !timeRange) return null + + const rangeSize = timeRange.maxTime - timeRange.minTime + + return normalizeZoomPercentRange( + ((Math.min(startTime, endTime) - timeRange.minTime) / rangeSize) * 100, + ((Math.max(startTime, endTime) - timeRange.minTime) / rangeSize) * 100 + ) +} + const resolveCurrentDataZoomRange = (zoomPayload: ChartDataZoomPayload) => { const dataZoomOptions = chart?.getOption?.()?.dataZoom const dataZoomList = Array.isArray(dataZoomOptions) ? dataZoomOptions : dataZoomOptions ? [dataZoomOptions] : [] @@ -140,6 +191,9 @@ const resolveChartDataZoomRange = (zoomPayload: ChartDataZoomPayload) => { const valueRange = resolveZoomRangeFromAxisValues(zoomPayload?.startValue, zoomPayload?.endValue) if (valueRange) return valueRange + const timeValueRange = resolveZoomRangeFromTimeAxisValues(zoomPayload?.startValue, zoomPayload?.endValue) + if (timeValueRange) return timeValueRange + return resolveCurrentDataZoomRange(zoomPayload) } @@ -199,9 +253,18 @@ const resetChartCursor = () => { isPanPointerDown = false } +const isSliderDataZoomResizeHandle = (target: any) => { + return target?.type === 'path' && target?.draggable === true && target?.parent?.parent?.type === 'group' +} + const updatePanCursor = (event: { offsetX: number; offsetY: number }) => { const viewportRoot = getChartViewportRoot() + if (viewportRoot && isSliderDataZoomResizeHandle((event as any)?.target)) { + viewportRoot.style.cursor = 'pointer' + return + } + if (!viewportRoot || (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark')) { resetChartCursor() return diff --git a/frontend/src/routers/modules/dynamicRouter.ts b/frontend/src/routers/modules/dynamicRouter.ts index e30a7a8..702670a 100644 --- a/frontend/src/routers/modules/dynamicRouter.ts +++ b/frontend/src/routers/modules/dynamicRouter.ts @@ -21,7 +21,11 @@ const COMPONENT_PATH_ALIASES: Record = { '/event/event-list/index': '/event/eventList/index', // 后端菜单可能使用短横线模块名,前端页面目录统一为 steadyDataView。 '/steady/steady-data-view': '/steady/steadyDataView', - '/steady/steady-data-view/index': '/steady/steadyDataView/index' + '/steady/steady-data-view/index': '/steady/steadyDataView/index', + '/steady/steady-trend': '/steady/steadyTrend', + '/steady/steady-trend/index': '/steady/steadyTrend/index', + '/steady/check-square': '/steady/checksquare', + '/steady/check-square/index': '/steady/checksquare/index' } const STATIC_ROUTE_NAMES = new Set([ 'layout', @@ -34,6 +38,8 @@ const STATIC_ROUTE_NAMES = new Set([ 'toolAddLedger', 'eventList', 'steadyDataView', + 'steadyTrend', + 'checksquare', 'systemMonitor', 'diskMonitor', '403', diff --git a/frontend/src/routers/modules/staticRouter.ts b/frontend/src/routers/modules/staticRouter.ts index f7b6017..1696d79 100644 --- a/frontend/src/routers/modules/staticRouter.ts +++ b/frontend/src/routers/modules/staticRouter.ts @@ -117,6 +117,41 @@ export const staticRouter: RouteRecordRaw[] = [ title: '稳态数据' } }, + { + path: '/steadyTrend/index', + name: 'steadyTrend', + alias: [ + '/steadyTrend', + '/steadytrend', + '/steadytrend/index', + '/steady/steadyTrend', + '/steady/steadyTrend/index', + '/steady/steady-trend', + '/steady/steady-trend/index' + ], + component: () => import('@/views/steady/steadyTrend/index.vue'), + meta: { + cacheName: 'SteadyTrend', + title: '\u7a33\u6001\u8d8b\u52bf' + } + }, + { + path: '/checksquare/index', + name: 'checksquare', + alias: [ + '/checksquare', + '/checksquare/index', + '/steady/checksquare', + '/steady/checksquare/index', + '/steady/check-square', + '/steady/check-square/index' + ], + component: () => import('@/views/steady/checksquare/index.vue'), + meta: { + cacheName: 'ChecksquareView', + title: '数据验证入库' + } + }, { path: '/403', name: '403', diff --git a/frontend/src/stores/modules/auth.ts b/frontend/src/stores/modules/auth.ts index 596b48e..1e4cced 100644 --- a/frontend/src/stores/modules/auth.ts +++ b/frontend/src/stores/modules/auth.ts @@ -144,6 +144,18 @@ function normalizeBusinessMenu(menu: any): any { menu.component = '@/views/steady/steadyDataView/index.vue' } + if (isSteadyTrendMenu(menu)) { + menu.path = '/steadyTrend/index' + menu.name = 'steadyTrend' + menu.component = '@/views/steady/steadyTrend/index.vue' + } + + if (isChecksquareMenu(menu)) { + menu.path = '/checksquare/index' + menu.name = 'checksquare' + menu.component = '@/views/steady/checksquare/index.vue' + } + return menu } @@ -173,8 +185,36 @@ function isSteadyDataViewMenu(menu: any): boolean { return title.includes('稳态数据') } +function isSteadyTrendMenu(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 === 'steadytrend') return true + if (normalizedPath.includes('steadytrend')) return true + if (normalizedComponent.includes('steadytrend')) return true + + return title.includes('\u7a33\u6001\u8d8b\u52bf') +} + +function isChecksquareMenu(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 === 'checksquare') return true + if (normalizedPath.includes('checksquare')) return true + if (normalizedComponent.includes('checksquare')) return true + + return title.includes('数据验证') +} + export function resolveBusinessMenuPath(menu: Menu.MenuOptions): string { if (isEventListMenu(menu)) return '/eventList/index' + if (isChecksquareMenu(menu)) return '/checksquare/index' + if (isSteadyTrendMenu(menu)) return '/steadyTrend/index' return isSteadyDataViewMenu(menu) ? '/steadyDataView/index' : menu.path } diff --git a/frontend/src/views/components/TimePeriodSearch/check-time-period-contract.mjs b/frontend/src/views/components/TimePeriodSearch/check-time-period-contract.mjs index 8e8441a..d96b563 100644 --- a/frontend/src/views/components/TimePeriodSearch/check-time-period-contract.mjs +++ b/frontend/src/views/components/TimePeriodSearch/check-time-period-contract.mjs @@ -45,6 +45,11 @@ assert.deepEqual(buildTimePeriodRange('day', new Date(2026, 4, 13)), [ '2026-05-13 23:59:59.999' ]) +assert.deepEqual(buildTimePeriodRange('week', new Date(2026, 4, 13)), [ + '2026-05-11 00:00:00.000', + '2026-05-17 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' @@ -60,17 +65,25 @@ assert.deepEqual(buildTimePeriodRange('month', shiftTimePeriod('month', new Date '2026-04-30 23:59:59.999' ]) +assert.deepEqual(buildTimePeriodRange('week', shiftTimePeriod('week', new Date(2026, 4, 13), -1)), [ + '2026-05-04 00:00:00.000', + '2026-05-10 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'), '年') +assert.equal(resolveTimePeriodUnitLabel('custom'), '自定义') const componentExpectations = [ - ['component renders unit selector', /time-period-search__unit[\s\S]*timePeriodUnitOptions/], + ['component renders unit selector', /time-period-search__unit[\s\S]*visibleTimePeriodUnitOptions/], ['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 supports custom datetime range picker', /type="datetimerange"[\s\S]*handleRangeChange/], + ['component can limit visible units by props', /visibleUnits\?:\s*TimePeriodUnit\[\][\s\S]*visibleTimePeriodUnitOptions/], ['component uses fixed eventList-compatible picker width', /time-period-search__picker[\s\S]*width:\s*112px;[\s\S]*flex:\s*0 0 112px;/] ] diff --git a/frontend/src/views/components/TimePeriodSearch/index.vue b/frontend/src/views/components/TimePeriodSearch/index.vue index 2283df5..b4cf5cb 100644 --- a/frontend/src/views/components/TimePeriodSearch/index.vue +++ b/frontend/src/views/components/TimePeriodSearch/index.vue @@ -1,10 +1,16 @@