소스 검색

3d:故障列表30/页并初始自动选中;顶部日期显示+故障播放(5s, 从早到晚, 可暂停/停止);详情弹窗改双tab(故障信息/曲线图)与可调时间范围查询;日期中文化;加载改为居中转圈

18922397810 2 주 전
부모
커밋
3b2571f4ae

+ 1 - 0
3d/frontend/package-lock.json

@@ -8,6 +8,7 @@
       "name": "compressor-3d-viewer",
       "version": "0.1.0",
       "dependencies": {
+        "@ant-design/icons-vue": "^7.0.1",
         "ant-design-vue": "^4.2.6",
         "axios": "^1.7.9",
         "echarts": "^5.6.0",

+ 1 - 0
3d/frontend/package.json

@@ -9,6 +9,7 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@ant-design/icons-vue": "^7.0.1",
     "ant-design-vue": "^4.2.6",
     "axios": "^1.7.9",
     "echarts": "^5.6.0",

+ 1 - 1
3d/frontend/src/api/http.ts

@@ -1,4 +1,4 @@
 import axios from 'axios'
 
 /** 同源请求:开发由 Vite 代理到后端,生产由 FastAPI 统一托管。 */
-export const http = axios.create({ baseURL: '', timeout: 20000 })
+export const http = axios.create({ baseURL: '', timeout: 60000 })

+ 13 - 1
3d/frontend/src/components/AlarmDetailCard.vue

@@ -91,7 +91,8 @@ const smallChart = computed(() => {
 
     <div class="chart-block">
       <div v-if="charts.loading" class="chart-loading">
-        <a-progress :percent="charts.progress" size="small" stroke-color="#4da3ff" />
+        <a-spin size="small" />
+        <span class="chart-loading-text">加载中</span>
       </div>
       <div v-else-if="charts.error" class="chart-error">{{ charts.error }}</div>
       <FaultChart
@@ -182,8 +183,19 @@ const smallChart = computed(() => {
   padding: 4px 0;
 }
 .chart-loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
   padding: 12px 4px 6px;
 }
+.chart-loading-text {
+  color: #aaaaaa;
+  font-size: 12px;
+}
+.chart-loading :deep(.ant-spin-dot) {
+  font-size: 12px;
+}
 .chart-error {
   color: #ff4d4f;
   font-size: 12px;

+ 2 - 1
3d/frontend/src/components/DataPanel.vue

@@ -25,12 +25,13 @@ function onRowClick(row: AlarmRow): void {
 onMounted(async () => {
   await store.loadFilters()
   await store.load()
+  store.autoSelectFirst()
 })
 </script>
 
 <template>
   <section class="glass card data-panel">
-    <h3 class="card-title">数据区域</h3>
+    <h3 class="card-title">故障列表</h3>
 
     <div class="filters">
       <a-select

+ 20 - 7
3d/frontend/src/components/FaultChart.vue

@@ -32,20 +32,32 @@ let observer: ResizeObserver | null = null
 
 const BLUE = '#4da3ff'
 const RED = '#E53935'
-const HOUR = 3600 * 1000
-const PAD = 2 * HOUR
 
 function pad2(value: number): string {
   return String(value).padStart(2, '0')
 }
 
+/** 实际数据的时间范围(用于坐标轴/dataZoom 的范围;与故障红区无关)。 */
+function seriesExtent(): [number, number] {
+  let lo = Infinity
+  let hi = -Infinity
+  props.series.forEach((s) => {
+    s.data.forEach(([t]) => {
+      if (t < lo) lo = t
+      if (t > hi) hi = t
+    })
+  })
+  if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [props.faultStart, props.faultEnd]
+  return [lo, hi]
+}
+
 function buildDataZoom(
   indexes: number[],
   sliderH: number,
   axisColor: string,
+  startValue: number,
+  endValue: number,
 ): Record<string, unknown>[] {
-  const startValue = props.faultStart - PAD
-  const endValue = props.faultEnd + PAD
   return [
     { type: 'inside', xAxisIndex: indexes, startValue, endValue },
     {
@@ -76,6 +88,7 @@ function buildOption(): Record<string, unknown> {
   const gap = 8
   const gridH = Math.max(24, (totalH - top - bottom - gap * (n - 1)) / n)
   const indexes = props.series.map((_, i) => i)
+  const [tMin, tMax] = seriesExtent()
 
   const axisColor = props.dark ? '#aaaaaa' : '#666666'
   const lineColor = props.dark ? '#555555' : '#cccccc'
@@ -113,8 +126,8 @@ function buildOption(): Record<string, unknown> {
     xAxis.push({
       type: 'time',
       gridIndex: i,
-      min: props.faultStart - PAD,
-      max: props.faultEnd + PAD,
+      min: tMin,
+      max: tMax,
       axisLine: { show: last, lineStyle: { color: lineColor } },
       axisTick: { show: last },
       axisLabel: { show: last, fontSize: 9, color: axisColor, hideOverlap: false, interval: 'auto' },
@@ -191,7 +204,7 @@ function buildOption(): Record<string, unknown> {
       outOfRange: { color: BLUE },
     },
     axisPointer: { link: [{ xAxisIndex: 'all' }] },
-    dataZoom: props.zoom ? buildDataZoom(indexes, sliderH, axisColor) : undefined,
+    dataZoom: props.zoom ? buildDataZoom(indexes, sliderH, axisColor, tMin, tMax) : undefined,
     tooltip: {
       trigger: 'axis',
       axisPointer: { type: 'line' },

+ 224 - 43
3d/frontend/src/components/FaultChartModal.vue

@@ -1,29 +1,107 @@
 <script setup lang="ts">
 import { theme } from 'ant-design-vue'
-import { computed, ref } from 'vue'
+import zhCN from 'ant-design-vue/es/locale/zh_CN'
+import dayjs, { type Dayjs } from 'dayjs'
+import { computed, ref, watch } from 'vue'
 
+import { fetchPksSeries } from '../api/series'
 import { useAlarmsStore } from '../stores/alarms'
-import { useChartsStore } from '../stores/charts'
 import { buildChartSeries } from '../utils/chart'
+import { shiftHours, toMs } from '../utils/time'
 import FaultChart from './FaultChart.vue'
 
-defineProps<{ open: boolean }>()
+const props = defineProps<{ open: boolean }>()
 const emit = defineEmits<{ (event: 'update:open', value: boolean): void }>()
 
 const alarms = useAlarmsStore()
-const charts = useChartsStore()
 const mode = ref<'relative' | 'absolute'>('relative')
+const activeTab = ref<'info' | 'chart'>('info')
+
+const rangeStart = ref<Dayjs | null>(null)
+const rangeEnd = ref<Dayjs | null>(null)
+const ms = ref<number[]>([])
+const oil = ref<(number | null)[]>([])
+const coupling = ref<(number | null)[]>([])
+const chain = ref<(number | null)[]>([])
+const faultStart = ref(0)
+const faultEnd = ref(0)
+const loading = ref(false)
+const error = ref('')
+const loaded = ref(false)
+
+const HOUR = 3600 * 1000
 
 const isVibration = computed(() => alarms.selected?.alarm_type === '振动故障')
 const info = computed<Record<string, unknown>>(() => alarms.selected?.alarm_info ?? {})
 
+const HOUR_KEYS = new Set([
+  '持续自然小时数',
+  '有效运行小时数',
+  '阶段低报警小时数',
+  '阶段低低报警小时数',
+  '阶段高报警小时数',
+  '阶段高高报警小时数',
+])
+
+const LABELS: Record<string, string> = {
+  持续自然小时数: '持续',
+  有效运行小时数: '有效',
+  周期基线油压: '周期基线',
+  近期基线振动: '近期基线',
+  正常波动带: '波动带',
+  阶段开始小时油压: '开始油压',
+  阶段结束小时油压: '结束油压',
+  阶段最低小时油压: '最低油压',
+  阶段开始小时振动: '开始振动',
+  阶段结束小时振动: '结束振动',
+  阶段最大振动值: '最大振动',
+  阶段开始相对周期基线变化: '开始相对基线',
+  阶段结束相对周期基线变化: '结束相对基线',
+  阶段开始相对近期基线变化: '开始相对基线',
+  阶段结束相对近期基线变化: '结束相对基线',
+  阶段最大连续下降有效小时数: '最大连续下降',
+  阶段最大连续异常有效小时数: '最大连续异常',
+  阶段低报警小时数: '低报警',
+  阶段低低报警小时数: '低低报警',
+  阶段高报警小时数: '高报警',
+  阶段高高报警小时数: '高高报警',
+  是否形成确认等级: '确认等级',
+  阶段触发依据: '触发依据',
+  阶段综合评分: '综合评分',
+  持续时间得分: '持续得分',
+  平均振动得分: '平均得分',
+  最大振动值得分: '最大得分',
+  峰值偏离得分: '峰值偏离',
+  最强异常侧: '最强侧',
+  最强侧近期峰值基线: '最强侧基线',
+  最强侧近期稳健尺度: '稳健尺度',
+  '阶段最大峰值/基线比例': '峰值/基线',
+  模型异常概率均值: '概率均值',
+  模型异常概率最大值: '概率最大',
+  模型异常概率最小值: '概率最小',
+}
+
+const infoItems = computed<{ key: string; label: string; value: string; span: number }[]>(() =>
+  Object.entries(info.value).map(([key, value]) => {
+    const empty = value === null || value === undefined || value === ''
+    let text = empty ? '—' : String(value)
+    if (!empty && HOUR_KEYS.has(key)) text = `${text} 小时`
+    return {
+      key,
+      label: LABELS[key] ?? key,
+      value: text,
+      span: key === '阶段触发依据' ? 3 : 1,
+    }
+  }),
+)
+
 const built = computed(() => {
-  if (!charts.loaded || !alarms.selected) return null
+  if (!loaded.value || !alarms.selected) return null
   return buildChartSeries({
-    ms: charts.ms,
-    oil: charts.oil,
-    coupling: charts.coupling,
-    chain: charts.chain,
+    ms: ms.value,
+    oil: oil.value,
+    coupling: coupling.value,
+    chain: chain.value,
     isVibration: isVibration.value,
     mode: mode.value,
     baselineOil: Number(info.value['周期基线油压']),
@@ -31,6 +109,42 @@ const built = computed(() => {
   })
 })
 
+async function query(): Promise<void> {
+  const alarm = alarms.selected
+  if (!alarm || !rangeStart.value || !rangeEnd.value) return
+  loading.value = true
+  error.value = ''
+  try {
+    const start = rangeStart.value.format('YYYY-MM-DD HH:mm:ss')
+    const end = rangeEnd.value.format('YYYY-MM-DD HH:mm:ss')
+    const data = await fetchPksSeries(alarm.device_code, start, end)
+    ms.value = data.times.map(toMs)
+    oil.value = data.oil
+    coupling.value = data.coupling
+    chain.value = data.chain
+    faultStart.value = toMs(alarm.alarm_time_start)
+    faultEnd.value = toMs(alarm.alarm_time_end) + HOUR
+    loaded.value = true
+  } catch (err) {
+    error.value = '曲线数据加载失败:' + (err as Error).message
+    loaded.value = false
+  } finally {
+    loading.value = false
+  }
+}
+
+function init(): void {
+  const alarm = alarms.selected
+  if (!alarm) return
+  rangeStart.value = dayjs(shiftHours(alarm.alarm_time_start, -2))
+  rangeEnd.value = dayjs(shiftHours(alarm.alarm_time_end, 3))
+  mode.value = 'relative'
+  query()
+}
+
+watch(() => props.open, (value) => { if (value) init() }, { immediate: true })
+watch(() => alarms.selected?.id, () => { if (props.open) init() })
+
 function close(): void {
   emit('update:open', false)
 }
@@ -39,62 +153,129 @@ function close(): void {
 <template>
   <a-modal
     :open="open"
-    wrap-class-name="fault-chart-modal"
+    wrap-class-name="fault-detail-modal"
     :footer="null"
     width="900px"
-    title="故障曲线"
+    title="故障详细内容"
     @cancel="close"
   >
-    <a-config-provider :theme="{ algorithm: theme.defaultAlgorithm }">
-      <div class="modal-head">
-        <a-radio-group v-model:value="mode" size="small" button-style="solid">
-          <a-radio-button value="relative">相对基线</a-radio-button>
-          <a-radio-button value="absolute">原值</a-radio-button>
-        </a-radio-group>
-      </div>
-      <div v-if="charts.loading" class="modal-loading">
-        <div class="modal-loading-text">曲线数据加载中…</div>
-        <a-progress :percent="charts.progress" size="small" stroke-color="#4da3ff" />
-      </div>
-      <div v-else-if="charts.error" class="modal-error">{{ charts.error }}</div>
-      <FaultChart
-        v-else-if="built"
-        :series="built.series"
-        :fault-start="charts.faultStart"
-        :fault-end="charts.faultEnd"
-        :height="isVibration ? 520 : 360"
-        :y-min="built.yMin"
-        :y-max="built.yMax"
-        :unit="built.unit"
-        :ref-lines="built.refLines"
-        :show-y-axis="true"
-        :dark="false"
-        :zoom="true"
-      />
+    <a-config-provider :locale="zhCN" :theme="{ algorithm: theme.defaultAlgorithm }">
+      <a-tabs v-model:activeKey="activeTab" size="small">
+        <a-tab-pane key="info" tab="故障信息">
+          <div class="info-grid">
+            <template v-for="item in infoItems" :key="item.key">
+              <div class="info-label" :class="{ full: item.span === 3 }">{{ item.label }}</div>
+              <div class="info-value" :class="{ full: item.span === 3 }">{{ item.value }}</div>
+            </template>
+          </div>
+        </a-tab-pane>
+
+        <a-tab-pane key="chart" tab="曲线图">
+          <div class="chart-toolbar">
+            <a-radio-group v-model:value="mode" size="small" button-style="solid">
+              <a-radio-button value="relative">相对基线</a-radio-button>
+              <a-radio-button value="absolute">原值</a-radio-button>
+            </a-radio-group>
+            <span class="tool-label">开始</span>
+            <a-date-picker v-model:value="rangeStart" show-time format="YYYY-MM-DD HH:mm:ss" size="small" />
+            <span class="tool-label">结束</span>
+            <a-date-picker v-model:value="rangeEnd" show-time format="YYYY-MM-DD HH:mm:ss" size="small" />
+            <a-button type="primary" size="small" :loading="loading" @click="query">查询</a-button>
+          </div>
+
+          <div v-if="loading" class="modal-loading">
+            <a-spin size="small" />
+            <span class="modal-loading-text">加载中</span>
+          </div>
+          <div v-else-if="error" class="modal-error">{{ error }}</div>
+          <FaultChart
+            v-else-if="built"
+            :series="built.series"
+            :fault-start="faultStart"
+            :fault-end="faultEnd"
+            :height="isVibration ? 420 : 320"
+            :y-min="built.yMin"
+            :y-max="built.yMax"
+            :unit="built.unit"
+            :ref-lines="built.refLines"
+            :show-y-axis="true"
+            :dark="false"
+            :zoom="true"
+          />
+        </a-tab-pane>
+      </a-tabs>
     </a-config-provider>
   </a-modal>
 </template>
 
 <style scoped>
-.modal-head {
+.info-grid {
+  display: grid;
+  grid-template-columns: 120px 1fr 96px 1fr 96px 1fr;
+  border-top: 1px solid #f0f0f0;
+  border-left: 1px solid #f0f0f0;
+  font-size: 12px;
+}
+.info-label,
+.info-value {
+  padding: 6px 8px;
+  border-right: 1px solid #f0f0f0;
+  border-bottom: 1px solid #f0f0f0;
+  min-width: 0;
+  word-break: break-all;
+}
+.info-label {
+  color: #666666;
+  background: #fafafa;
+}
+.info-value {
+  color: #1a1a1a;
+}
+.info-label.full {
+  grid-column: 1;
+}
+.info-value.full {
+  grid-column: 2 / -1;
+}
+.chart-toolbar {
   display: flex;
-  justify-content: flex-end;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
   margin-bottom: 8px;
 }
-.modal-head :deep(.ant-radio-button-wrapper) {
+.tool-label {
   font-size: 12px;
+  color: #666666;
 }
 .modal-loading {
-  padding: 40px 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  padding: 60px 4px;
 }
 .modal-loading-text {
   color: #666666;
   font-size: 12px;
-  margin-bottom: 8px;
+}
+.modal-loading :deep(.ant-spin-dot) {
+  font-size: 12px;
 }
 .modal-error {
   color: #ff4d4f;
   font-size: 12px;
-  padding: 30px 0;
+  padding: 40px 0;
+}
+:deep(.ant-tabs-nav) {
+  margin-bottom: 12px;
+}
+:deep(.ant-tabs-tab) {
+  font-size: 13px;
+}
+:deep(.ant-picker),
+:deep(.ant-radio-button-wrapper),
+:deep(.ant-btn) {
+  font-size: 12px;
 }
 </style>

+ 31 - 0
3d/frontend/src/components/TopBar.vue

@@ -1,7 +1,9 @@
 <script setup lang="ts">
+import { BorderOutlined, CaretRightOutlined } from '@ant-design/icons-vue'
 import { Modal, message } from 'ant-design-vue'
 import { ref } from 'vue'
 
+import { useAlarmsStore } from '../stores/alarms'
 import { useModelStore } from '../stores/model'
 import { useScenesStore } from '../stores/scenes'
 import { useViewerStore } from '../stores/viewer'
@@ -10,6 +12,12 @@ import type { ScenePosition } from '../types'
 const modelStore = useModelStore()
 const viewerStore = useViewerStore()
 const scenesStore = useScenesStore()
+const alarmsStore = useAlarmsStore()
+
+function onPlayToggle(): void {
+  if (alarmsStore.playing) alarmsStore.stopPlayback()
+  else alarmsStore.startPlayback()
+}
 
 const renameOpen = ref(false)
 const renameId = ref<number | null>(null)
@@ -87,6 +95,16 @@ function removeFromRename(): void {
       {{ viewerStore.freeMode ? '轨道旋转' : '自由旋转' }}
     </a-button>
     <a-checkbox v-model:checked="viewerStore.sceneVisible">场景位置</a-checkbox>
+    <span
+      class="date-display"
+      :class="{ paused: alarmsStore.paused }"
+      title="播放时点击可暂停/继续"
+      @click="alarmsStore.togglePause()"
+    >{{ alarmsStore.displayDate }}</span>
+    <a-button class="rot-btn play-btn" size="small" :title="alarmsStore.playing ? '停止' : '播放故障'" @click="onPlayToggle">
+      <BorderOutlined v-if="alarmsStore.playing" />
+      <CaretRightOutlined v-else />
+    </a-button>
 
     <a-modal v-model:open="renameOpen" title="场景位置" :footer="null">
       <a-input v-model:value="renameName" placeholder="请输入场景名称" @press-enter="confirmRename" />
@@ -174,6 +192,19 @@ function removeFromRename(): void {
 .rename-spacer {
   flex: 1;
 }
+.date-display {
+  font-size: 12px;
+  color: #d6dbe2;
+  cursor: pointer;
+  user-select: none;
+  font-variant-numeric: tabular-nums;
+}
+.date-display.paused {
+  color: #f9a825;
+}
+.play-btn {
+  padding: 0 8px !important;
+}
 /* 与场景位置圆角卡片统一样式 */
 .rot-btn {
   display: inline-flex !important;

+ 4 - 0
3d/frontend/src/main.ts

@@ -1,9 +1,13 @@
 import Antd from 'ant-design-vue'
 import 'ant-design-vue/dist/reset.css'
+import dayjs from 'dayjs'
+import 'dayjs/locale/zh-cn'
 import { createPinia } from 'pinia'
 import { createApp } from 'vue'
 
 import App from './App.vue'
 import './styles/index.css'
 
+dayjs.locale('zh-cn')
+
 createApp(App).use(createPinia()).use(Antd).mount('#app')

+ 94 - 2
3d/frontend/src/stores/alarms.ts

@@ -4,18 +4,36 @@ import { computed, ref } from 'vue'
 import { fetchAlarmFilters, fetchAlarms } from '../api/alarms'
 import type { AlarmRow } from '../types'
 
+function pad2(value: number): string {
+  return String(value).padStart(2, '0')
+}
+
+function today(): string {
+  const d = new Date()
+  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
+}
+
+const PLAY_INTERVAL = 5000
+
 export const useAlarmsStore = defineStore('alarms', () => {
   const devices = ref<string[]>([])
   const alarmTypes = ref<string[]>([])
   const deviceCode = ref('')   // 空 = 全部
   const alarmType = ref('')    // 空 = 全部
   const page = ref(1)
-  const pageSize = ref(10)
+  const pageSize = ref(30)
   const total = ref(0)
   const items = ref<AlarmRow[]>([])
   const loading = ref(false)
   const selectedId = ref<number | null>(null)
 
+  // 播放状态
+  const playing = ref(false)
+  const paused = ref(false)
+  const displayDate = ref(today())
+  let playTimer: ReturnType<typeof setTimeout> | undefined
+  let playIndex = 0
+
   const selected = computed<AlarmRow | null>(
     () => items.value.find((row) => row.id === selectedId.value) ?? null,
   )
@@ -70,9 +88,83 @@ export const useAlarmsStore = defineStore('alarms', () => {
     selectedId.value = null
   }
 
+  /** 初始加载完成后自动选中第一条(最新)。 */
+  function autoSelectFirst(): void {
+    if (selectedId.value === null && items.value[0]) {
+      selectedId.value = items.value[0].id
+    }
+  }
+
+  /* ---------------- 故障播放(从最早到最新) ---------------- */
+  function clearTimer(): void {
+    if (playTimer) { clearTimeout(playTimer); playTimer = undefined }
+  }
+
+  async function showGlobalIndex(globalIndex: number): Promise<void> {
+    const targetPage = Math.floor(globalIndex / pageSize.value) + 1
+    if (targetPage !== page.value) {
+      page.value = targetPage
+      await load()
+    }
+    const row = items.value[globalIndex % pageSize.value]
+    if (row) {
+      selectedId.value = row.id
+      displayDate.value = row.alarm_time_start.slice(0, 10)
+    }
+  }
+
+  function schedule(): void {
+    clearTimer()
+    if (!playing.value || paused.value) return
+    playTimer = setTimeout(advance, PLAY_INTERVAL)
+  }
+
+  async function advance(): Promise<void> {
+    if (!playing.value || paused.value) return
+    playIndex -= 1
+    if (playIndex < 0) {
+      await resetToInitial()
+      return
+    }
+    await showGlobalIndex(playIndex)
+    schedule()
+  }
+
+  async function startPlayback(): Promise<void> {
+    if (!total.value) return
+    clearTimer()
+    playing.value = true
+    paused.value = false
+    playIndex = total.value - 1   // 全局最后一条(最早)
+    await showGlobalIndex(playIndex)
+    schedule()
+  }
+
+  async function resetToInitial(): Promise<void> {
+    clearTimer()
+    playing.value = false
+    paused.value = false
+    page.value = 1
+    await load()
+    if (items.value[0]) selectedId.value = items.value[0].id
+    displayDate.value = today()
+  }
+
+  async function stopPlayback(): Promise<void> {
+    await resetToInitial()
+  }
+
+  function togglePause(): void {
+    if (!playing.value) return
+    paused.value = !paused.value
+    if (paused.value) clearTimer()
+    else schedule()
+  }
+
   return {
     devices, alarmTypes, deviceCode, alarmType, page, pageSize, total, items,
-    loading, selectedId, selected,
+    loading, selectedId, selected, playing, paused, displayDate,
     loadFilters, load, setDevice, setType, setPage, select, clearSelected,
+    autoSelectFirst, startPlayback, stopPlayback, togglePause,
   }
 })

+ 9 - 7
3d/frontend/src/styles/index.css

@@ -119,26 +119,28 @@ body {
   color: #1a1a1a;
 }
 
-/* 故障曲线弹窗:白底黑字 */
-.fault-chart-modal .ant-modal-content {
+/* 故障详细内容弹窗:白底黑字,固定高度 */
+.fault-detail-modal .ant-modal-content {
   background: #fff;
   color: #1a1a1a;
 }
-.fault-chart-modal .ant-modal-header {
+.fault-detail-modal .ant-modal-header {
   background: #fff;
   border-bottom: none;
   margin-bottom: 8px;
 }
-.fault-chart-modal .ant-modal-title {
+.fault-detail-modal .ant-modal-title {
   color: #111;
   font-weight: 600;
 }
-.fault-chart-modal .ant-modal-close {
+.fault-detail-modal .ant-modal-close {
   color: #888;
 }
-.fault-chart-modal .ant-modal-close:hover {
+.fault-detail-modal .ant-modal-close:hover {
   color: #222;
 }
-.fault-chart-modal .ant-modal-body {
+.fault-detail-modal .ant-modal-body {
   color: #1a1a1a;
+  height: 620px;
+  overflow-y: auto;
 }

+ 3 - 3
3d/frontend/src/types/index.ts

@@ -81,7 +81,7 @@ export interface PresetDef {
 }
 
 export const PRESETS: PresetDef[] = [
-  { key: 'ok', label: '正常', color: '#43A047' },
-  { key: 'warn', label: '预警', color: '#F9A825' },
-  { key: 'fault', label: '故障', color: '#E53935' },
+  { key: 'ok', label: '轻微', color: '#F9A825' },
+  { key: 'warn', label: '异常', color: '#FB8C00' },
+  { key: 'fault', label: '严重', color: '#E53935' },
 ]