Преглед изворни кода

✨ feat(设备监控): 完善实时告警、图表量程与监控看板展示

- 拆分告警阈值和 Y 轴显示范围,支持分别配置、清空及参数校验
- 增加实时数据超限计数、告警明细提示和告警声音联动
- 监控看板调整为单屏 2×2 分页布局,保留已访问页面的图表状态
- 非活动分页自动断开 MQTT,切回后恢复连接并重新适配图表尺寸
- 使用 ResizeObserver 处理容器尺寸变化,提升图表布局稳定性
- 调整监控详情页面名称,避免连油详情与设备监控详情混淆
- 修复海康视频单窗口全屏时电子放大框选层定位异常
Zimo пре 4 часа
родитељ
комит
7e1bac97dc

+ 2 - 1
src/locales/zh-CN.ts

@@ -1201,7 +1201,8 @@ export default {
     AddEquipment: '设备添加',
     EquipmentEditing: '设备编辑',
     EquipmentDetails: '设备详情',
-    MonitoringDetails: '监控详情',
+    MonitoringDetails: '连油详情',
+    Monitoring1Details: '监控详情',
     UploadFile: '资料上传',
     EquipmentBOM: '设备BOM',
     EquipmentResponsiblePerson: '设备责任人',

+ 1 - 1
src/router/modules/remaining.ts

@@ -546,7 +546,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
           hidden: true,
           canTo: true,
           icon: 'ep:info',
-          title: t('rem.MonitoringDetails'),
+          title: t('rem.Monitoring1Details'),
           activeMenu: '/device/info'
         }
       },

+ 9 - 1
src/utils/useSocketBus.ts

@@ -117,6 +117,8 @@ export interface Dimensions {
   isText?: boolean
   minValue?: number
   maxValue?: number
+  axisMin?: number
+  axisMax?: number
   id?: number
 }
 
@@ -153,6 +155,8 @@ export interface IotTdItem {
   alarmSettingId?: number
   maxValue?: unknown
   minValue?: unknown
+  axisMax?: unknown
+  axisMin?: unknown
 }
 
 export function mapToDimension(item: IotTdItem): RawDimension {
@@ -167,7 +171,9 @@ export function mapToDimension(item: IotTdItem): RawDimension {
     response: false,
     id: item.alarmSettingId,
     maxValue: item.alarmSettingId ? normalizeRangeValue(item.maxValue) : undefined,
-    minValue: item.alarmSettingId ? normalizeRangeValue(item.minValue) : undefined
+    minValue: item.alarmSettingId ? normalizeRangeValue(item.minValue) : undefined,
+    axisMax: item.alarmSettingId ? normalizeRangeValue(item.axisMax) : undefined,
+    axisMin: item.alarmSettingId ? normalizeRangeValue(item.axisMin) : undefined
   }
 }
 
@@ -188,4 +194,6 @@ export interface RangeSettingDraft {
   color: string
   minValue?: number
   maxValue?: number
+  axisMin?: number
+  axisMax?: number
 }

+ 289 - 65
src/views/oli-connection/monitoring-board/chart.vue

@@ -13,8 +13,8 @@ import {
   RangeSettingDraft,
   withDisplayStyle
 } from '@/utils/useSocketBus'
-import { useDebounceFn } from '@vueuse/core'
 import { Setting } from '@element-plus/icons-vue'
+import { useDebounceFn } from '@vueuse/core'
 import dayjs from 'dayjs'
 import * as echarts from 'echarts'
 
@@ -70,6 +70,10 @@ const props = defineProps({
   token: {
     type: String,
     required: true
+  },
+  active: {
+    type: Boolean,
+    default: true
   }
 })
 
@@ -81,7 +85,30 @@ const dimensions = ref<Dimensions[]>([])
 const selectedDimension = ref<Record<string, boolean>>({})
 const message = useMessage()
 const { setAlarmActive } = useAlarmSound()
-const exceededThresholds = new Set<string>()
+
+interface ThresholdAlarm {
+  key: string
+  name: string
+  value: number
+  threshold: number
+  direction: 'high' | 'low'
+  suffix?: string
+}
+
+const thresholdAlarms = shallowRef<Record<string, ThresholdAlarm>>({})
+const activeThresholdAlarms = computed(() => Object.values(thresholdAlarms.value))
+
+function getThresholdAlarmText(alarm: ThresholdAlarm) {
+  const directionText = alarm.direction === 'high' ? '高于上限' : '低于下限'
+  const suffix = alarm.suffix || ''
+
+  return `${alarm.name}:当前 ${formatChartValue(alarm.value)}${suffix},${directionText} ${formatChartValue(alarm.threshold)}${suffix}`
+}
+
+function clearThresholdAlarms() {
+  thresholdAlarms.value = {}
+  setAlarmActive(false)
+}
 
 const currentOtherName = ref(props.otherName)
 
@@ -94,7 +121,7 @@ watch(
   }
 )
 
-const { connect, destroy, isConnected, subscribe } = useMqtt()
+const { client, connect, destroy, isConnected, subscribe } = useMqtt()
 
 const REALTIME_WINDOW_MS = 10 * 60 * 1000
 const REALTIME_RIGHT_BLANK_MS = 60 * 1000
@@ -142,13 +169,19 @@ const handleMessageUpdate = (_topic: string, data: any) => {
     }
   }
 
-  if (!updatedNames.size) return
-
   updateDimensionValues(valueMap)
   updateThresholdAlarm(valueMap)
+
+  if (!updatedNames.size) return
+
   scheduleRealtimeChartUpdate(Array.from(updatedNames))
 }
 
+function connectRealtime() {
+  if (!props.active || client.value) return
+  connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
+}
+
 watch(isConnected, (newVal) => {
   if (newVal) {
     // subscribe(`/636/${props.deviceCode}/property/post`)
@@ -170,8 +203,7 @@ watch(isConnected, (newVal) => {
     subscribe(props.mqttUrl)
     // subscribe('/636/YF649/property/post')
   } else {
-    exceededThresholds.clear()
-    setAlarmActive(false)
+    clearThresholdAlarms()
   }
 })
 
@@ -211,50 +243,52 @@ let chartLoadVersion = 0
 let realtimeUpdateFrame: number | null = null
 const pendingRealtimeSeriesNames = new Set<string>()
 
+watch(
+  () => props.active,
+  async (active) => {
+    if (!active) {
+      destroy()
+      cancelRealtimeChartUpdate()
+      clearThresholdAlarms()
+      return
+    }
+
+    await nextTick()
+    chart?.resize()
+
+    if (chartRealtimeMode.value) {
+      connectRealtime()
+    }
+  }
+)
+
 const TREND_AXIS_MIN = 0
 const TREND_AXIS_MAX = 100
 
 const resizeChart = useDebounceFn(() => {
   chart?.resize()
 }, 100)
+let chartResizeObserver: ResizeObserver | null = null
 
 function getDimensionByName(name: string) {
   return dimensions.value.find((item) => item.name === name)
 }
 
-function getDatasetMaxValue(name: string) {
-  const dataset = chartData.value[name] || []
-  let maxValue = -Infinity
-
-  dataset.forEach(({ value }) => {
-    const numberValue = Number(value)
-    if (Number.isFinite(numberValue) && numberValue > maxValue) {
-      maxValue = numberValue
-    }
-  })
-
-  const currentValue = normalizeRangeValue(getDimensionByName(name)?.value)
-  if (currentValue !== undefined && currentValue > maxValue) {
-    maxValue = currentValue
-  }
-
-  return Number.isFinite(maxValue) ? maxValue : 0
-}
-
 function getSeriesRange(name: string) {
   const dimension = getDimensionByName(name)
-  const configuredMin = normalizeRangeValue(dimension?.minValue)
-  const configuredMax = normalizeRangeValue(dimension?.maxValue)
-  const min = configuredMin ?? 0
-  const max = configuredMax ?? getDatasetMaxValue(name)
-  const normalizedMax = max > min ? max : min + 1
+  const configuredMin = normalizeRangeValue(dimension?.axisMin)
+  const configuredMax = normalizeRangeValue(dimension?.axisMax)
+  const configured =
+    configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
+  const min = configured ? configuredMin : 0
+  const max = configured ? configuredMax : 100
 
   return {
     min,
-    max: normalizedMax,
+    max,
     labelMin: min,
-    labelMax: normalizedMax,
-    configured: configuredMin !== undefined && configuredMax !== undefined
+    labelMax: max,
+    configured
   }
 }
 
@@ -268,7 +302,8 @@ const chartLegendItems = computed(() =>
       bgHover: item.bgHover,
       selected: selectedDimension.value[item.name] !== false,
       max: formatChartValue(range.labelMax),
-      min: formatChartValue(range.labelMin)
+      min: formatChartValue(range.labelMin),
+      alarm: thresholdAlarms.value[`${item.identifier}|${item.name}`]
     }
   })
 )
@@ -425,6 +460,8 @@ function updateDimensionValues(valueMap: Map<string, number>) {
 }
 
 function updateThresholdAlarm(valueMap: Map<string, number>) {
+  const nextAlarms = { ...thresholdAlarms.value }
+
   dimensions.value.forEach((item) => {
     const value = valueMap.get(item.identifier)
     if (value === undefined) return
@@ -432,16 +469,31 @@ function updateThresholdAlarm(valueMap: Map<string, number>) {
     const min = normalizeRangeValue(item.minValue)
     const max = normalizeRangeValue(item.maxValue)
     const alarmKey = `${item.identifier}|${item.name}`
-    const exceeded = (min !== undefined && value < min) || (max !== undefined && value > max)
-
-    if (exceeded) {
-      exceededThresholds.add(alarmKey)
+    if (max !== undefined && value > max) {
+      nextAlarms[alarmKey] = {
+        key: alarmKey,
+        name: item.name,
+        value,
+        threshold: max,
+        direction: 'high',
+        suffix: item.suffix
+      }
+    } else if (min !== undefined && value < min) {
+      nextAlarms[alarmKey] = {
+        key: alarmKey,
+        name: item.name,
+        value,
+        threshold: min,
+        direction: 'low',
+        suffix: item.suffix
+      }
     } else {
-      exceededThresholds.delete(alarmKey)
+      delete nextAlarms[alarmKey]
     }
   })
 
-  setAlarmActive(exceededThresholds.size > 0)
+  thresholdAlarms.value = nextAlarms
+  setAlarmActive(activeThresholdAlarms.value.length > 0)
 }
 
 function getChartAnimationOptions() {
@@ -553,6 +605,12 @@ function chartInit() {
 
   window.removeEventListener('resize', resizeChart)
   window.addEventListener('resize', resizeChart)
+
+  chartResizeObserver?.disconnect()
+  if (chartRef.value) {
+    chartResizeObserver = new ResizeObserver(resizeChart)
+    chartResizeObserver.observe(chartRef.value)
+  }
 }
 
 function render() {
@@ -753,16 +811,14 @@ async function initLoadChartData(real_time: boolean = true) {
       applySelectedDateWindow()
     }
     updateSeriesByNames(
-      dimensions.value
-        .filter((item) => selectedDimension.value[item.name])
-        .map((item) => item.name)
+      dimensions.value.filter((item) => selectedDimension.value[item.name]).map((item) => item.name)
     )
   } finally {
     chartLoading.value = false
   }
 
   if (real_time && loadVersion === chartLoadVersion) {
-    connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
+    connectRealtime()
   }
 }
 
@@ -810,6 +866,8 @@ onUnmounted(() => {
   cancelRealtimeChartUpdate()
 
   window.removeEventListener('resize', resizeChart)
+  chartResizeObserver?.disconnect()
+  chartResizeObserver = null
   chart?.dispose()
   chart = null
 })
@@ -845,7 +903,9 @@ function buildRangeSettingDrafts() {
     identifier: item.identifier,
     color: item.color,
     minValue: normalizeRangeValue(item.minValue),
-    maxValue: normalizeRangeValue(item.maxValue)
+    maxValue: normalizeRangeValue(item.maxValue),
+    axisMin: normalizeRangeValue(item.axisMin),
+    axisMax: normalizeRangeValue(item.axisMax)
   }))
 }
 
@@ -878,36 +938,56 @@ async function saveDeviceName() {
   })
 }
 
-async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?: number) {
+async function saveRangeSetting(
+  item: Dimensions,
+  minValue?: number,
+  maxValue?: number,
+  axisMinValue?: number,
+  axisMaxValue?: number
+) {
   const min = normalizeRangeValue(minValue)
   const max = normalizeRangeValue(maxValue)
+  const axisMin = normalizeRangeValue(axisMinValue)
+  const axisMax = normalizeRangeValue(axisMaxValue)
 
-  if (min === undefined && max === undefined) {
+  if (min === undefined && max === undefined && axisMin === undefined && axisMax === undefined) {
     if (item.id) {
       await IotDeviceApi.deleteMaxMin({ id: item.id })
     }
 
     item.minValue = undefined
     item.maxValue = undefined
+    item.axisMin = undefined
+    item.axisMax = undefined
     item.id = undefined
     return
   }
 
-  if (min === undefined || max === undefined) {
-    throw new Error(`${item.name} 的最大值和最小值需要同时填写`)
+  if ((min === undefined) !== (max === undefined)) {
+    throw new Error(`${item.name} 的告警上限和告警下限需要同时填写`)
+  }
+
+  if (min !== undefined && max !== undefined && min > max) {
+    throw new Error(`${item.name} 的告警下限不能大于告警上限`)
   }
 
-  if (min > max) {
-    throw new Error(`${item.name} 的最小值不能大于最大值`)
+  if (min !== undefined && max !== undefined && min === max) {
+    throw new Error(`${item.name} 的告警上限和告警下限不能相等`)
   }
 
-  if (min === max) {
-    throw new Error(`${item.name} 的最大值和最小值不能相等`)
+  if ((axisMin === undefined) !== (axisMax === undefined)) {
+    throw new Error(`${item.name} 的 Y 轴上限和 Y 轴下限需要同时填写`)
+  }
+
+  if (axisMin !== undefined && axisMax !== undefined && axisMin >= axisMax) {
+    throw new Error(`${item.name} 的 Y 轴下限必须小于 Y 轴上限`)
   }
 
   const body = {
-    minValue: min,
-    maxValue: max,
+    minValue: min ?? null,
+    maxValue: max ?? null,
+    axisMin: axisMin ?? null,
+    axisMax: axisMax ?? null,
     deviceId: props.id,
     propertyCode: item.identifier,
     alarmProperty: item.name,
@@ -920,6 +1000,8 @@ async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?:
   if (res.id) item.id = res.id
   item.minValue = min
   item.maxValue = max
+  item.axisMin = axisMin
+  item.axisMax = axisMax
 }
 
 async function handleSettingsSave() {
@@ -931,7 +1013,7 @@ async function handleSettingsSave() {
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       if (!item) continue
 
-      await saveRangeSetting(item, draft.minValue, draft.maxValue)
+      await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
     }
 
     updateSeriesByNames(dimensions.value.map((item) => item.name))
@@ -948,13 +1030,17 @@ function handleRangeDialogReset() {
   rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
     ...item,
     minValue: undefined,
-    maxValue: undefined
+    maxValue: undefined,
+    axisMin: undefined,
+    axisMax: undefined
   }))
 }
 
 function clearRangeDraft(row: RangeSettingDraft) {
   row.minValue = undefined
   row.maxValue = undefined
+  row.axisMin = undefined
+  row.axisMax = undefined
 }
 </script>
 <template>
@@ -963,6 +1049,10 @@ function clearRangeDraft(row: RangeSettingDraft) {
       <div class="flex items-center">
         <div class="title-icon"></div>
         <div>{{ `${props.deviceCode}-${displayDeviceName}` }}</div>
+        <span v-if="activeThresholdAlarms.length" class="chart-header__alarm-count">
+          <i class="i-material-symbols:warning-rounded"></i>
+          {{ activeThresholdAlarms.length }} 项超限
+        </span>
       </div>
       <div class="chart-header__actions">
         <el-button link type="primary" :icon="Setting" @click.stop="openSettingsDialog">
@@ -982,8 +1072,9 @@ function clearRangeDraft(row: RangeSettingDraft) {
           :key="item.name"
           type="button"
           class="chart-legend__item"
-          :class="{ 'is-active': item.selected }"
+          :class="{ 'is-active': item.selected, 'is-alarm': item.alarm }"
           :aria-pressed="item.selected"
+          :title="item.alarm ? getThresholdAlarmText(item.alarm) : undefined"
           :style="{
             '--theme-color': item.color,
             '--theme-bg-hover': item.bgHover
@@ -998,6 +1089,19 @@ function clearRangeDraft(row: RangeSettingDraft) {
       </div>
 
       <div class="chart-stage">
+        <div
+          v-if="activeThresholdAlarms.length"
+          class="chart-alarm-panel"
+          role="alert"
+          aria-live="polite">
+          <span
+            v-for="alarm in activeThresholdAlarms"
+            :key="alarm.key"
+            class="chart-alarm-panel__item">
+            <i class="i-material-symbols:warning-rounded"></i>
+            <span>{{ getThresholdAlarmText(alarm) }}</span>
+          </span>
+        </div>
         <div ref="chartRef" class="chart-canvas"></div>
       </div>
     </main>
@@ -1005,7 +1109,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
     <el-dialog
       v-model="settingsDialogVisible"
       title="设备设置"
-      width="900px"
+      width="1100px"
       class="board-settings-dialog"
       modal-class="board-settings-overlay"
       append-to-body
@@ -1021,7 +1125,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
       </el-form>
 
       <div class="board-settings-section">
-        <div class="board-settings-section__title">曲线量程设置</div>
+        <div class="board-settings-section__title">告警阈值与 Y 轴范围</div>
         <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="420" :show-border="true">
           <ZmTableColumn label="曲线" min-width="180">
             <template #default="{ row }">
@@ -1033,25 +1137,43 @@ function clearRangeDraft(row: RangeSettingDraft) {
               </div>
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="最小值" width="180">
+          <ZmTableColumn label="告警下限" width="140">
             <template #default="{ row }">
               <el-input-number
                 v-model="row.minValue"
                 class="!w-full"
                 :controls="false"
-                :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
+                placeholder="未设置" />
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="最大值" width="180">
+          <ZmTableColumn label="告警上限" width="140">
             <template #default="{ row }">
               <el-input-number
                 v-model="row.maxValue"
                 class="!w-full"
                 :controls="false"
+                placeholder="未设置" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="Y 轴下限" width="140">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.axisMin"
+                class="!w-full"
+                :controls="false"
+                :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="Y 轴上限" width="140">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.axisMax"
+                class="!w-full"
+                :controls="false"
                 :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="当前使用范围" width="170">
+          <ZmTableColumn label="当前 Y 轴范围" width="150">
             <template #default="{ row }">
               <span class="board-settings-range-text">
                 {{ formatChartValue(getSeriesRange(row.name).labelMin) }}
@@ -1136,6 +1258,23 @@ function clearRangeDraft(row: RangeSettingDraft) {
   align-items: center;
 }
 
+.chart-header__alarm-count {
+  display: inline-flex;
+  gap: 4px;
+  align-items: center;
+  height: 24px;
+  padding: 0 8px;
+  margin-left: 12px;
+  font-size: 12px;
+  font-weight: 800;
+  color: #fecaca;
+  background: rgb(220 38 38 / 22%);
+  border: 1px solid rgb(248 113 113 / 70%);
+  border-radius: 999px;
+  box-shadow: 0 0 14px rgb(239 68 68 / 28%);
+  animation: alarm-pulse 1.4s ease-in-out infinite;
+}
+
 .title-icon {
   width: 4px;
   height: 16px;
@@ -1228,6 +1367,39 @@ function clearRangeDraft(row: RangeSettingDraft) {
   color: var(--theme-color);
 }
 
+.chart-legend__item.is-alarm {
+  color: #fecaca;
+  background: rgb(220 38 38 / 24%);
+  border-color: rgb(248 113 113 / 85%);
+  opacity: 1;
+  box-shadow:
+    0 0 0 1px rgb(239 68 68 / 20%),
+    0 0 18px rgb(239 68 68 / 38%);
+  animation: alarm-pulse 1.4s ease-in-out infinite;
+}
+
+.chart-legend__item.is-alarm::after {
+  position: absolute;
+  top: 3px;
+  right: 3px;
+  width: 7px;
+  height: 7px;
+  background: #ef4444;
+  border: 1px solid #fecaca;
+  border-radius: 50%;
+  content: '';
+  box-shadow: 0 0 8px #ef4444;
+}
+
+.chart-legend__item.is-alarm .chart-legend__line {
+  background: #ef4444;
+  opacity: 1;
+}
+
+.chart-legend__item.is-alarm .chart-legend__range {
+  color: #fecaca;
+}
+
 .chart-legend__range {
   font-size: 10px;
   line-height: 1;
@@ -1266,6 +1438,58 @@ function clearRangeDraft(row: RangeSettingDraft) {
   border-radius: 8px;
 }
 
+.chart-alarm-panel {
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  left: 8px;
+  z-index: 5;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  max-height: 76px;
+  padding: 7px;
+  overflow: auto;
+  background: rgb(69 10 10 / 88%);
+  border: 1px solid rgb(248 113 113 / 72%);
+  border-radius: 8px;
+  box-shadow: 0 8px 24px rgb(0 0 0 / 36%);
+  backdrop-filter: blur(6px);
+}
+
+.chart-alarm-panel__item {
+  display: inline-flex;
+  gap: 5px;
+  align-items: center;
+  padding: 3px 7px;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1.4;
+  color: #fee2e2;
+  white-space: nowrap;
+  background: rgb(220 38 38 / 24%);
+  border: 1px solid rgb(248 113 113 / 40%);
+  border-radius: 999px;
+}
+
+@keyframes alarm-pulse {
+  0%,
+  100% {
+    filter: brightness(1);
+  }
+
+  50% {
+    filter: brightness(1.35);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .chart-header__alarm-count,
+  .chart-legend__item.is-alarm {
+    animation: none;
+  }
+}
+
 .chart-canvas {
   width: 100%;
   height: 100%;

+ 88 - 220
src/views/oli-connection/monitoring-board/index.vue

@@ -139,10 +139,9 @@ async function loadDeviceOptions() {
 
 const deviceList = ref<DeviceData[]>([])
 const chartOption = ref<EChartsOption>({})
-const pageSize = 5
+const pageSize = 4
 const pageIndex = ref(0)
-const activeMainCardId = ref<number | null>(null)
-const boardMotion = ref<'page-prev' | 'page-next' | 'promote' | ''>('')
+const boardMotion = ref<'page-prev' | 'page-next' | ''>('')
 let boardMotionTimer: number | undefined
 
 const totalPages = computed(() => Math.max(1, Math.ceil(deviceList.value.length / pageSize)))
@@ -152,19 +151,27 @@ const currentPageCards = computed(() => {
   return deviceList.value.slice(start, start + pageSize)
 })
 
-const mainCard = computed(() => {
-  const matchedCard = currentPageCards.value.find((item) => item.id === activeMainCardId.value)
-  return matchedCard ?? currentPageCards.value[0] ?? null
-})
-
-const sideCards = computed(() => {
-  if (!mainCard.value) return [] as DeviceData[]
-  return currentPageCards.value.filter((item) => item.id !== mainCard.value?.id)
-})
-
 const boardMotionClass = computed(() => (boardMotion.value ? `is-${boardMotion.value}` : ''))
+const visitedPageIndexes = ref([0])
+
+const renderedBoardPages = computed(() =>
+  visitedPageIndexes.value
+    .filter((index) => index < totalPages.value)
+    .map((index) => {
+      const start = index * pageSize
+      return {
+        index,
+        cards: deviceList.value.slice(start, start + pageSize)
+      }
+    })
+)
+
+function markPageVisited(index: number) {
+  if (visitedPageIndexes.value.includes(index)) return
+  visitedPageIndexes.value = [...visitedPageIndexes.value, index]
+}
 
-function triggerBoardMotion(type: 'page-prev' | 'page-next' | 'promote') {
+function triggerBoardMotion(type: 'page-prev' | 'page-next') {
   if (boardMotionTimer) {
     window.clearTimeout(boardMotionTimer)
   }
@@ -179,28 +186,19 @@ function triggerBoardMotion(type: 'page-prev' | 'page-next' | 'promote') {
   })
 }
 
-function syncPageMainCard() {
-  activeMainCardId.value = currentPageCards.value[0]?.id ?? null
-}
-
-function setMainCard(card: DeviceData) {
-  if (activeMainCardId.value === card.id) return
-
-  activeMainCardId.value = card.id
-  triggerBoardMotion('promote')
-}
-
 function goPrevGroup() {
   if (pageIndex.value <= 0) return
-  pageIndex.value -= 1
-  syncPageMainCard()
+  const targetPageIndex = pageIndex.value - 1
+  markPageVisited(targetPageIndex)
+  pageIndex.value = targetPageIndex
   triggerBoardMotion('page-prev')
 }
 
 function goNextGroup() {
   if (pageIndex.value >= totalPages.value - 1) return
-  pageIndex.value += 1
-  syncPageMainCard()
+  const targetPageIndex = pageIndex.value + 1
+  markPageVisited(targetPageIndex)
+  pageIndex.value = targetPageIndex
   triggerBoardMotion('page-next')
 }
 
@@ -228,13 +226,8 @@ async function handleDeviceChange(selectedIds: number[]) {
     }
   }
 
-  if (!deviceList.value.some((item) => item.id === activeMainCardId.value)) {
-    syncPageMainCard()
-  }
-
   if (pageIndex.value > totalPages.value - 1) {
     pageIndex.value = Math.max(totalPages.value - 1, 0)
-    syncPageMainCard()
   }
 }
 
@@ -255,7 +248,7 @@ function handleDeptChange() {
   deviceList.value = []
   chartOption.value = {}
   pageIndex.value = 0
-  activeMainCardId.value = null
+  visitedPageIndexes.value = [0]
   loadDeviceOptions()
 }
 
@@ -268,7 +261,7 @@ function handleRest() {
   query.value = getOriginalQuery()
   isRealTime.value = true
   pageIndex.value = 0
-  activeMainCardId.value = null
+  visitedPageIndexes.value = [0]
   loadDeptOptions()
   loadDeviceOptions()
 }
@@ -311,9 +304,9 @@ onMounted(() => {
 <template>
   <div
     ref="targetArea"
-    class="relative flex flex-col w-full rounded-lg bg-[#020408] overflow-hidden h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    class="monitor-board-page relative flex flex-col w-full rounded-lg bg-[#020408] overflow-hidden">
     <header
-      class="relative w-full h-14 flex items-center justify-center select-none bg-[#0b1121] border-b border-white/5 shadow-lg">
+      class="relative w-full h-12 flex items-center justify-center select-none bg-[#0b1121] border-b border-white/5 shadow-lg">
       <div
         class="absolute inset-0 opacity-20"
         style="background-image: radial-gradient(circle at 50% 50%, #083344 0%, transparent 50%)">
@@ -346,7 +339,20 @@ onMounted(() => {
         </span>
       </h1>
 
+      <div class="monitor-board-status absolute left-16 top-1/2 -translate-y-1/2 z-10">
+        当前第 {{ pageIndex + 1 }} 组 / 共 {{ totalPages }} 组
+      </div>
+
       <div class="absolute right-16 top-1/2 -translate-y-1/2 z-10 flex items-center gap-2">
+        <el-button class="custom-btn reset-btn" :disabled="pageIndex === 0" @click="goPrevGroup">
+          上 4 个
+        </el-button>
+        <el-button
+          class="custom-btn reset-btn"
+          :disabled="pageIndex >= totalPages - 1"
+          @click="goNextGroup">
+          下 4 个
+        </el-button>
         <el-button size="default" class="custom-btn primary-btn" @click="showSearchDialog = true">
           筛选
         </el-button>
@@ -361,85 +367,21 @@ onMounted(() => {
       </div>
     </header>
 
-    <div class="px-4 pt-4 pb-4 monitor-board-shell">
-      <div class="monitor-board-toolbar">
-        <div class="monitor-board-status">
-          <span>当前第 {{ pageIndex + 1 }} 组 / 共 {{ totalPages }} 组</span>
-          <span v-if="mainCard">
-            主看板:{{ mainCard.deviceCode }}-{{ getDeviceDisplayName(mainCard) }}
-          </span>
-        </div>
-        <div class="flex items-center gap-3">
-          <el-button class="custom-btn reset-btn" :disabled="pageIndex === 0" @click="goPrevGroup">
-            上 5 个
-          </el-button>
-          <el-button
-            class="custom-btn reset-btn"
-            :disabled="pageIndex >= totalPages - 1"
-            @click="goNextGroup">
-            下 5 个
-          </el-button>
-        </div>
-      </div>
-
-      <div v-if="mainCard" class="monitor-board-scroll-area" :class="boardMotionClass">
-        <div class="monitor-board-layout">
-          <div
-            v-if="sideCards[0]"
-            class="monitor-card-shell monitor-card-side monitor-card-left-top"
-            @click="setMainCard(sideCards[0])">
-            <chart
-              :key="sideCards[0].id"
-              v-bind="sideCards[0]"
-              :date="query.time"
-              :is-real-time="isRealTime"
-              :token="token"
-              @other-name-updated="handleOtherNameUpdated" />
-          </div>
-
-          <div
-            v-if="sideCards[1]"
-            class="monitor-card-shell monitor-card-side monitor-card-left-bottom"
-            @click="setMainCard(sideCards[1])">
-            <chart
-              :key="sideCards[1].id"
-              v-bind="sideCards[1]"
-              :date="query.time"
-              :is-real-time="isRealTime"
-              :token="token"
-              @other-name-updated="handleOtherNameUpdated" />
-          </div>
-
-          <div class="monitor-card-shell monitor-card-main" @click="setMainCard(mainCard)">
-            <chart
-              :key="mainCard.id"
-              v-bind="mainCard"
-              :date="query.time"
-              :is-real-time="isRealTime"
-              :token="token"
-              @other-name-updated="handleOtherNameUpdated" />
-          </div>
-
-          <div
-            v-if="sideCards[2]"
-            class="monitor-card-shell monitor-card-side monitor-card-right-top"
-            @click="setMainCard(sideCards[2])">
-            <chart
-              :key="sideCards[2].id"
-              v-bind="sideCards[2]"
-              :date="query.time"
-              :is-real-time="isRealTime"
-              :token="token"
-              @other-name-updated="handleOtherNameUpdated" />
-          </div>
-
-          <div
-            v-if="sideCards[3]"
-            class="monitor-card-shell monitor-card-side monitor-card-right-bottom"
-            @click="setMainCard(sideCards[3])">
+    <div class="monitor-board-shell">
+      <div
+        v-if="currentPageCards.length"
+        class="monitor-board-scroll-area"
+        :class="boardMotionClass">
+        <div
+          v-for="boardPage in renderedBoardPages"
+          v-show="boardPage.index === pageIndex"
+          :key="boardPage.index"
+          class="monitor-board-layout"
+          :class="`is-${boardPage.cards.length}-cards`">
+          <div v-for="cardItem in boardPage.cards" :key="cardItem.id" class="monitor-card-shell">
             <chart
-              :key="sideCards[3].id"
-              v-bind="sideCards[3]"
+              v-bind="cardItem"
+              :active="boardPage.index === pageIndex"
               :date="query.time"
               :is-real-time="isRealTime"
               :token="token"
@@ -595,37 +537,20 @@ onMounted(() => {
   }
 }
 
-@keyframes card-promote-main {
-  0% {
-    opacity: 0.72;
-    transform: scale(0.94);
-  }
-
-  55% {
-    opacity: 1;
-    transform: scale(1.018);
-  }
-
-  100% {
-    opacity: 1;
-    transform: scale(1);
-  }
+.animate-scan-line {
+  animation: scan-line 3s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
 }
 
-@keyframes card-promote-side {
-  0% {
-    opacity: 0.62;
-    transform: scale(1.04);
-  }
-
-  100% {
-    opacity: 1;
-    transform: scale(1);
-  }
+.monitor-board-page {
+  height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  min-height: 0;
 }
 
-.animate-scan-line {
-  animation: scan-line 3s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
+.monitor-board-page:fullscreen {
+  height: 100vh;
+  min-height: 100vh;
 }
 
 .monitor-board-shell {
@@ -635,70 +560,52 @@ onMounted(() => {
   height: 100%;
   flex: 1;
   min-height: 0;
-}
-
-.monitor-board-toolbar {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 16px;
-  gap: 16px;
+  padding: 10px;
 }
 
 .monitor-board-status {
-  display: flex;
   font-size: 14px;
   color: rgb(165 243 252 / 90%);
-  flex-wrap: wrap;
-  gap: 16px;
+  white-space: nowrap;
 }
 
 .monitor-board-layout {
   display: grid;
-  width: max-content;
+  width: 100%;
   height: 100%;
-  padding-bottom: 8px;
+  min-height: 0;
   flex: 1;
-  grid-template-columns: 760px 1500px 760px;
-  grid-template-rows: repeat(2, minmax(0, 1fr));
-  gap: 18px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
 }
 
-.monitor-board-scroll-area {
-  height: 100%;
-  min-height: 0;
-  padding: 8px;
-  overflow: auto hidden;
-  flex: 1 1 0;
+.monitor-board-layout.is-1-cards {
+  grid-template-columns: minmax(0, 1fr);
+  grid-template-rows: minmax(0, 1fr);
 }
 
-.monitor-board-scroll-area::-webkit-scrollbar {
-  height: 10px;
+.monitor-board-layout.is-2-cards {
+  grid-template-rows: minmax(0, 1fr);
 }
 
-.monitor-board-scroll-area::-webkit-scrollbar-thumb {
-  background: rgb(34 211 238 / 70%);
-  border-radius: 999px;
+.monitor-board-layout.is-3-cards,
+.monitor-board-layout.is-4-cards {
+  grid-template-rows: repeat(2, minmax(0, 1fr));
 }
 
-.monitor-board-scroll-area::-webkit-scrollbar-track {
-  background: rgb(255 255 255 / 6%);
+.monitor-board-scroll-area {
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+  flex: 1 1 0;
 }
 
 .monitor-card-shell {
   height: 100%;
   min-width: 0;
   min-height: 0;
-  cursor: pointer;
   will-change: transform, opacity;
-  transition:
-    transform 0.25s ease,
-    box-shadow 0.25s ease,
-    border-color 0.25s ease;
-}
-
-.monitor-card-shell:hover {
-  transform: translateY(-2px);
 }
 
 .monitor-board-scroll-area.is-page-next .monitor-board-layout {
@@ -709,41 +616,6 @@ onMounted(() => {
   animation: board-page-prev 0.46s cubic-bezier(0.2, 0.8, 0.2, 1);
 }
 
-.monitor-board-scroll-area.is-promote .monitor-card-main {
-  animation: card-promote-main 0.48s cubic-bezier(0.2, 0.8, 0.2, 1);
-}
-
-.monitor-board-scroll-area.is-promote .monitor-card-side {
-  animation: card-promote-side 0.38s cubic-bezier(0.2, 0.8, 0.2, 1);
-}
-
-.monitor-card-main {
-  grid-column: 2;
-  grid-row: 1 / span 2;
-  flex: 1;
-  height: 100%;
-}
-
-.monitor-card-left-top {
-  grid-column: 1;
-  grid-row: 1;
-}
-
-.monitor-card-left-bottom {
-  grid-column: 1;
-  grid-row: 2;
-}
-
-.monitor-card-right-top {
-  grid-column: 3;
-  grid-row: 1;
-}
-
-.monitor-card-right-bottom {
-  grid-column: 3;
-  grid-row: 2;
-}
-
 .monitor-card-shell :deep(.h-100) {
   height: 100%;
 }
@@ -756,10 +628,6 @@ onMounted(() => {
   min-width: 0;
 }
 
-.monitor-card-shell :deep(.chart-header) {
-  cursor: pointer;
-}
-
 .search-container {
   position: relative;
   padding: 24px;

+ 79 - 56
src/views/oli-connection/monitoring/detail.vue

@@ -197,39 +197,21 @@ function getDimensionByName(name: string) {
   return dimensions.value.find((item) => item.name === name)
 }
 
-function getDatasetMaxValue(name: string) {
-  const dataset = chartData.value[name] || []
-  let maxValue = -Infinity
-
-  dataset.forEach(({ value }) => {
-    const numberValue = Number(value)
-    if (Number.isFinite(numberValue) && numberValue > maxValue) {
-      maxValue = numberValue
-    }
-  })
-
-  const currentValue = normalizeRangeValue(getDimensionByName(name)?.value)
-  if (currentValue !== undefined && currentValue > maxValue) {
-    maxValue = currentValue
-  }
-
-  return Number.isFinite(maxValue) ? maxValue : 0
-}
-
 function getSeriesRange(name: string) {
   const dimension = getDimensionByName(name)
-  const configuredMin = normalizeRangeValue(dimension?.minValue)
-  const configuredMax = normalizeRangeValue(dimension?.maxValue)
-  const min = configuredMin ?? 0
-  const max = configuredMax ?? getDatasetMaxValue(name)
-  const normalizedMax = max > min ? max : min + 1
+  const configuredMin = normalizeRangeValue(dimension?.axisMin)
+  const configuredMax = normalizeRangeValue(dimension?.axisMax)
+  const configured =
+    configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
+  const min = configured ? configuredMin : 0
+  const max = configured ? configuredMax : 100
 
   return {
     min,
-    max: normalizedMax,
+    max,
     labelMin: min,
-    labelMax: normalizedMax,
-    configured: configuredMin !== undefined && configuredMax !== undefined
+    labelMax: max,
+    configured
   }
 }
 
@@ -735,9 +717,7 @@ async function initLoadChartData({
       applySelectedDateWindow()
     }
     updateSeriesByNames(
-      dimensions.value
-        .filter((item) => selectedDimension.value[item.name])
-        .map((item) => item.name)
+      dimensions.value.filter((item) => selectedDimension.value[item.name]).map((item) => item.name)
     )
   } finally {
     chartLoading.value = false
@@ -881,10 +861,7 @@ async function exportChartData() {
   exportDataLoading.value = true
   try {
     const worksheet = XLSX.utils.aoa_to_sheet(rows, { cellDates: true })
-    worksheet['!cols'] = [
-      { wch: 24 },
-      ...timestamps.map(() => ({ wch: 20 }))
-    ]
+    worksheet['!cols'] = [{ wch: 24 }, ...timestamps.map(() => ({ wch: 20 }))]
 
     timestamps.forEach((_, index) => {
       const cell = worksheet[XLSX.utils.encode_cell({ r: 0, c: index + 1 })]
@@ -940,41 +917,63 @@ function openRangeDialog(targetName?: string) {
     identifier: item.identifier,
     color: item.color,
     minValue: normalizeRangeValue(item.minValue),
-    maxValue: normalizeRangeValue(item.maxValue)
+    maxValue: normalizeRangeValue(item.maxValue),
+    axisMin: normalizeRangeValue(item.axisMin),
+    axisMax: normalizeRangeValue(item.axisMax)
   }))
   rangeDialogVisible.value = true
 }
 
-async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?: number) {
+async function saveRangeSetting(
+  item: Dimensions,
+  minValue?: number,
+  maxValue?: number,
+  axisMinValue?: number,
+  axisMaxValue?: number
+) {
   const min = normalizeRangeValue(minValue)
   const max = normalizeRangeValue(maxValue)
+  const axisMin = normalizeRangeValue(axisMinValue)
+  const axisMax = normalizeRangeValue(axisMaxValue)
 
-  if (min === undefined && max === undefined) {
+  if (min === undefined && max === undefined && axisMin === undefined && axisMax === undefined) {
     if (item.id) {
       await IotDeviceApi.deleteMaxMin({ id: item.id })
     }
 
     item.minValue = undefined
     item.maxValue = undefined
+    item.axisMin = undefined
+    item.axisMax = undefined
     item.id = undefined
     return
   }
 
-  if (min === undefined || max === undefined) {
-    throw new Error(`${item.name} 的最大值和最小值需要同时填写`)
+  if ((min === undefined) !== (max === undefined)) {
+    throw new Error(`${item.name} 的告警上限和告警下限需要同时填写`)
+  }
+
+  if (min !== undefined && max !== undefined && min > max) {
+    throw new Error(`${item.name} 的告警下限不能大于告警上限`)
   }
 
-  if (min > max) {
-    throw new Error(`${item.name} 的最小值不能大于最大值`)
+  if (min !== undefined && max !== undefined && min === max) {
+    throw new Error(`${item.name} 的告警上限和告警下限不能相等`)
   }
 
-  if (min === max) {
-    throw new Error(`${item.name} 的最大值和最小值不能相等`)
+  if ((axisMin === undefined) !== (axisMax === undefined)) {
+    throw new Error(`${item.name} 的 Y 轴上限和 Y 轴下限需要同时填写`)
   }
-  12
+
+  if (axisMin !== undefined && axisMax !== undefined && axisMin >= axisMax) {
+    throw new Error(`${item.name} 的 Y 轴下限必须小于 Y 轴上限`)
+  }
+
   const body = {
-    minValue: min,
-    maxValue: max,
+    minValue: min ?? null,
+    maxValue: max ?? null,
+    axisMin: axisMin ?? null,
+    axisMax: axisMax ?? null,
     deviceId: query.id,
     propertyCode: item.identifier,
     alarmProperty: item.name,
@@ -987,6 +986,8 @@ async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?:
   if (res.id) item.id = res.id
   item.minValue = min
   item.maxValue = max
+  item.axisMin = axisMin
+  item.axisMax = axisMax
 }
 
 async function handleRangeDialogSave() {
@@ -995,7 +996,7 @@ async function handleRangeDialogSave() {
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       if (!item) continue
 
-      await saveRangeSetting(item, draft.minValue, draft.maxValue)
+      await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
     }
 
     updateSeriesByNames(dimensions.value.map((item) => item.name))
@@ -1010,13 +1011,17 @@ function handleRangeDialogReset() {
   rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
     ...item,
     minValue: undefined,
-    maxValue: undefined
+    maxValue: undefined,
+    axisMin: undefined,
+    axisMax: undefined
   }))
 }
 
 function clearRangeDraft(row: RangeSettingDraft) {
   row.minValue = undefined
   row.maxValue = undefined
+  row.axisMin = undefined
+  row.axisMax = undefined
 }
 
 onMounted(() => {
@@ -1120,13 +1125,13 @@ onUnmounted(() => {
                       class="dimension-card__ranges">
                       <span v-if="item.maxValue !== undefined" class="range-pill range-pill--max">
                         <i class="i-material-symbols:arrow-upward-alt-rounded"></i>
-                        <span>MAX</span>
+                        <span>告警上限</span>
                         <strong>{{ formatChartValue(item.maxValue) }}</strong>
                       </span>
 
                       <span v-if="item.minValue !== undefined" class="range-pill range-pill--min">
                         <i class="i-material-symbols:arrow-downward-alt-rounded"></i>
-                        <span>MIN</span>
+                        <span>告警下限</span>
                         <strong>{{ formatChartValue(item.minValue) }}</strong>
                       </span>
                     </span>
@@ -1224,8 +1229,8 @@ onUnmounted(() => {
 
     <el-dialog
       v-model="rangeDialogVisible"
-      title="曲线量程设置"
-      width="900px"
+      title="告警阈值与 Y 轴范围"
+      width="1100px"
       append-to-body
       destroy-on-close>
       <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="520" :show-border="true">
@@ -1239,25 +1244,43 @@ onUnmounted(() => {
             </div>
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="最小值" width="180">
+        <ZmTableColumn label="告警下限" width="140">
           <template #default="{ row }">
             <el-input-number
               v-model="row.minValue"
               class="!w-full"
               :controls="false"
-              :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
+              placeholder="未设置" />
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="最大值" width="180">
+        <ZmTableColumn label="告警上限" width="140">
           <template #default="{ row }">
             <el-input-number
               v-model="row.maxValue"
               class="!w-full"
               :controls="false"
+              placeholder="未设置" />
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="Y 轴下限" width="140">
+          <template #default="{ row }">
+            <el-input-number
+              v-model="row.axisMin"
+              class="!w-full"
+              :controls="false"
+              :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="Y 轴上限" width="140">
+          <template #default="{ row }">
+            <el-input-number
+              v-model="row.axisMax"
+              class="!w-full"
+              :controls="false"
               :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="当前使用范围" width="170">
+        <ZmTableColumn label="当前 Y 轴范围" width="150">
           <template #default="{ row }">
             <span class="text-xs text-slate-500">
               {{ formatChartValue(getSeriesRange(row.name).labelMin) }}

+ 12 - 3
src/views/pms/video_center/haikang/index.vue

@@ -18,10 +18,8 @@ import {
 } from '@/utils/hikvisionH5Player'
 import { Loading, Location, Search, VideoCamera } from '@element-plus/icons-vue'
 import type { LoadFunction } from 'element-plus/es/components/tree/src/tree.type'
-import { IotDeviceApi } from '@/api/pms/device'
-import { IotOpeationFillApi } from '@/api/pms/iotopeationfill'
 
-defineOptions({ name: 'HikvisionVideoCenter' })
+defineOptions({ name: 'HaiKang' })
 
 const layoutTriggerRef = ref<HTMLElement>()
 const playerContainerRef = ref<HTMLElement>()
@@ -2185,6 +2183,17 @@ onBeforeUnmount(() => {
   inset: 0 !important;
 }
 
+/*
+ * 单窗口全屏会把 SDK 的 sub-wnd 从 flex 改为 block。draw-window 未设置坐标时会按静态位置
+ * 排到视频下方一整屏,导致电子放大的框选层无法命中;将它固定回目标视口左上角。
+ */
+.video-stage.is-single-window-fullscreen
+  .player-container
+  :deep(.sub-wnd.is-single-fullscreen-target > .draw-window) {
+  top: 0 !important;
+  left: 0 !important;
+}
+
 .video-stage.is-single-window-fullscreen .window-loading-layer {
   grid-template-rows: minmax(0, 1fr) !important;
   grid-template-columns: minmax(0, 1fr) !important;