AlarmDetailCard.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import { fetchIndicator, type IndicatorSide } from '../api/indicator'
  4. import { fetchRecentSeries } from '../api/series'
  5. import { useAlarmsStore } from '../stores/alarms'
  6. import { useChartsStore } from '../stores/charts'
  7. import type { ChartSeries } from '../types'
  8. import { deviceLabel } from '../utils/device'
  9. import { buildChartSeries } from '../utils/chart'
  10. import { levelColor } from '../utils/level'
  11. import { toMs } from '../utils/time'
  12. import FaultChart from './FaultChart.vue'
  13. import FaultChartModal from './FaultChartModal.vue'
  14. import IndicatorChart from './IndicatorChart.vue'
  15. const store = useAlarmsStore()
  16. const charts = useChartsStore()
  17. const detailOpen = ref(false)
  18. const activeTab = ref<'realtime' | 'fault'>('realtime')
  19. const info = computed<Record<string, unknown>>(() => store.selected?.alarm_info ?? {})
  20. const isVibration = computed(() => store.selected?.alarm_type === '振动故障')
  21. function pick(...keys: string[]): string {
  22. for (const key of keys) {
  23. const value = info.value[key]
  24. if (value !== undefined && value !== null && value !== '') return String(value)
  25. }
  26. return '—'
  27. }
  28. function num2(value: string): string {
  29. const n = Number(value)
  30. return Number.isFinite(n) ? n.toFixed(2) : value
  31. }
  32. const stage = computed(() => (isVibration.value ? pick('振动阶段') : pick('压力阶段')))
  33. const stageColor = computed(() => levelColor(store.selected?.alarm_level ?? null))
  34. const machine = computed(
  35. () => String(info.value['机组'] ?? deviceLabel(store.selected?.device_code ?? '')),
  36. )
  37. const hours = computed(() => num2(pick('有效运行小时数')))
  38. const timeRange = computed(() => {
  39. const start = pick('开始小时')
  40. const end = pick('结束小时')
  41. if (start !== '—' || end !== '—') {
  42. return `${start === '—' ? start : start.slice(0, 16)}~${end === '—' ? end : end.slice(0, 16)}`
  43. }
  44. const row = store.selected
  45. return row ? `${row.alarm_time_start.slice(0, 16)}~${row.alarm_time_end.slice(0, 16)}` : '—'
  46. })
  47. const baselineLabel = computed(() => (isVibration.value ? '近期基线' : '周期基线'))
  48. const baseline = computed(() =>
  49. num2(isVibration.value ? pick('近期基线振动') : pick('周期基线油压')),
  50. )
  51. const consecutive = computed(() =>
  52. num2(isVibration.value ? pick('阶段最大连续异常有效小时数') : pick('阶段最大连续下降有效小时数')),
  53. )
  54. const basis = computed(() => pick('阶段触发依据'))
  55. const modelPrediction = computed(() => {
  56. const raw = pick('模型预测参考')
  57. return raw === '—' ? raw : raw.replace(/^模型预测参考:?/, '')
  58. })
  59. const smallChart = computed(() => {
  60. if (!charts.loaded || !store.selected) return null
  61. return buildChartSeries({
  62. ms: charts.ms,
  63. oil: charts.oil,
  64. coupling: charts.coupling,
  65. chain: charts.chain,
  66. isVibration: isVibration.value,
  67. mode: 'relative',
  68. baselineOil: Number(info.value['周期基线油压']),
  69. baselineVib: Number(info.value['近期基线振动']),
  70. downsampleMax: 10000,
  71. })
  72. })
  73. /* ---------------- 实时数据(数据库最后 6 小时,每分钟刷新) ---------------- */
  74. const RT_INTERVAL = 60000
  75. const rtMs = ref<number[]>([])
  76. const rtOil = ref<(number | null)[]>([])
  77. const rtCoupling = ref<(number | null)[]>([])
  78. const rtChain = ref<(number | null)[]>([])
  79. const rtLoading = ref(false)
  80. const rtError = ref('')
  81. let rtTimer: ReturnType<typeof setInterval> | undefined
  82. const RT_COLORS = { oil: '#4da3ff', coupling: '#ffb74d', chain: '#81c784' }
  83. function buildRt(
  84. ms: number[],
  85. values: (number | null)[],
  86. name: string,
  87. unit: string,
  88. color: string,
  89. ): ChartSeries {
  90. const data = ms.map((t, i) => [t, values[i] ?? null] as [number, number | null])
  91. return { name: `${name}(${unit})`, data, color }
  92. }
  93. const rtSeries = computed<ChartSeries[]>(() => [
  94. buildRt(rtMs.value, rtOil.value, '润滑油压力', 'MPa', RT_COLORS.oil),
  95. buildRt(rtMs.value, rtCoupling.value, '联轴器端', 'mm/s', RT_COLORS.coupling),
  96. buildRt(rtMs.value, rtChain.value, '链轮端', 'mm/s', RT_COLORS.chain),
  97. ])
  98. const rtXMin = computed(() => (rtMs.value.length ? rtMs.value[0] : 0))
  99. const rtXMax = computed(() => (rtMs.value.length ? rtMs.value[rtMs.value.length - 1] : 1))
  100. async function loadRealtime(): Promise<void> {
  101. if (!store.deviceCode) return
  102. rtLoading.value = true
  103. rtError.value = ''
  104. try {
  105. const data = await fetchRecentSeries(store.deviceCode, 6)
  106. rtMs.value = data.times.map(toMs)
  107. rtOil.value = data.oil
  108. rtCoupling.value = data.coupling
  109. rtChain.value = data.chain
  110. } catch (err) {
  111. rtError.value = '实时数据加载失败:' + (err as Error).message
  112. } finally {
  113. rtLoading.value = false
  114. }
  115. }
  116. /* ---------------- 示功图(P-V,按缸切换,随实时一起刷新) ---------------- */
  117. const cylinders = ['一', '二', '三', '四', '五', '六']
  118. const cylinder = ref('一')
  119. const indicator = ref<IndicatorSide[]>([])
  120. const indLoading = ref(false)
  121. const indError = ref('')
  122. async function loadIndicator(): Promise<void> {
  123. if (!store.deviceCode) return
  124. indLoading.value = true
  125. indError.value = ''
  126. try {
  127. const data = await fetchIndicator(store.deviceCode, cylinder.value)
  128. indicator.value = data.sides
  129. } catch (err) {
  130. indError.value = '功图加载失败:' + (err as Error).message
  131. } finally {
  132. indLoading.value = false
  133. }
  134. }
  135. function selectCylinder(value: string): void {
  136. if (value === cylinder.value) return
  137. cylinder.value = value
  138. loadIndicator()
  139. }
  140. function stopRealtime(): void {
  141. if (rtTimer) {
  142. clearInterval(rtTimer)
  143. rtTimer = undefined
  144. }
  145. }
  146. function refreshRealtime(): void {
  147. loadRealtime()
  148. loadIndicator()
  149. }
  150. function startRealtime(): void {
  151. stopRealtime()
  152. refreshRealtime()
  153. rtTimer = setInterval(refreshRealtime, RT_INTERVAL)
  154. }
  155. watch(() => store.deviceCode, () => startRealtime())
  156. watch(
  157. () => store.selected?.id,
  158. (id) => {
  159. activeTab.value = id ? 'fault' : 'realtime'
  160. },
  161. )
  162. onMounted(startRealtime)
  163. onBeforeUnmount(stopRealtime)
  164. </script>
  165. <template>
  166. <section class="glass card detail-card">
  167. <a-tabs v-model:activeKey="activeTab" size="small" class="detail-tabs">
  168. <a-tab-pane key="realtime" tab="实时数据">
  169. <div class="rt-wrap">
  170. <FaultChart
  171. :series="rtSeries"
  172. :fault-start="0"
  173. :fault-end="0"
  174. :x-min="rtXMin"
  175. :x-max="rtXMax"
  176. :grid-height="90"
  177. :ref-lines="[]"
  178. :show-y-axis="true"
  179. :dark="true"
  180. :per-series-legend="true"
  181. :inside-zoom="true"
  182. :grid-divider="true"
  183. :freeze-y="true"
  184. />
  185. <div v-if="rtLoading" class="rt-loading">
  186. <a-spin size="small" />
  187. <span class="rt-loading-text">加载中</span>
  188. </div>
  189. </div>
  190. <div v-if="rtError" class="rt-error">{{ rtError }}</div>
  191. <div class="indicator-block">
  192. <div class="cyl-tabs">
  193. <span
  194. v-for="item in cylinders"
  195. :key="item"
  196. class="cyl"
  197. :class="{ active: item === cylinder }"
  198. @click="selectCylinder(item)"
  199. >{{ item }}缸</span>
  200. </div>
  201. <div class="ind-wrap">
  202. <template v-if="indicator.length">
  203. <div v-for="side in indicator" :key="side.side" class="ind-item">
  204. <div class="ind-title">
  205. {{ side.side }} · {{ side.sample_time?.slice(0, 16) }} · {{ side.rpm?.toFixed(0) }} rpm
  206. </div>
  207. <IndicatorChart
  208. :pressure="side.pressure"
  209. :volume="side.volume"
  210. :angle="side.angle"
  211. :height="170"
  212. />
  213. </div>
  214. </template>
  215. <div v-else-if="!indLoading" class="ind-empty">无运行功图</div>
  216. <div v-if="indLoading" class="ind-loading">
  217. <a-spin size="small" />
  218. <span class="rt-loading-text">加载中</span>
  219. </div>
  220. </div>
  221. <div v-if="indError" class="rt-error">{{ indError }}</div>
  222. </div>
  223. </a-tab-pane>
  224. <a-tab-pane v-if="store.selected" key="fault" tab="故障详情">
  225. <div class="detail">
  226. <div class="pair">
  227. <div class="cell"><span class="label">机组:</span><span class="value">{{ machine }}</span></div>
  228. <div class="cell"><span class="label">有效运行小时数:</span><span class="value">{{ hours }}</span></div>
  229. </div>
  230. <div class="dline"><span class="label">时间:</span><span class="value wrap">{{ timeRange }}</span></div>
  231. <div class="pair">
  232. <div class="cell"><span class="label">{{ baselineLabel }}:</span><span class="value">{{ baseline }}</span></div>
  233. <div class="cell"><span class="label">最大连续:</span><span class="value">{{ consecutive }}</span></div>
  234. </div>
  235. <div class="dline"><span class="label">触发依据:</span><span class="value wrap">{{ basis }}</span></div>
  236. <div class="dline"><span class="label">模型预测:</span><span class="value wrap">{{ modelPrediction }}</span></div>
  237. </div>
  238. <div class="chart-block">
  239. <div v-if="charts.loading" class="chart-loading">
  240. <a-spin size="small" />
  241. <span class="chart-loading-text">加载中</span>
  242. </div>
  243. <div v-else-if="charts.error" class="chart-error">{{ charts.error }}</div>
  244. <FaultChart
  245. v-else-if="smallChart"
  246. :series="smallChart.series"
  247. :fault-start="charts.faultStart"
  248. :fault-end="charts.faultEnd"
  249. :height="isVibration ? 190 : 110"
  250. :y-min="smallChart.yMin"
  251. :y-max="smallChart.yMax"
  252. :unit="smallChart.unit"
  253. :ref-lines="smallChart.refLines"
  254. :show-y-axis="false"
  255. :dark="true"
  256. />
  257. </div>
  258. </a-tab-pane>
  259. </a-tabs>
  260. <div v-if="store.selected && activeTab === 'fault'" class="card-foot">
  261. <span class="stage" :style="{ background: stageColor }">{{ stage }}</span>
  262. <span class="foot-spacer" />
  263. <a-button type="link" size="small" @click="detailOpen = true">详细内容</a-button>
  264. <a-button type="link" size="small" @click="store.clearSelected()">关闭</a-button>
  265. </div>
  266. <FaultChartModal v-model:open="detailOpen" />
  267. </section>
  268. </template>
  269. <style scoped>
  270. .detail-card {
  271. pointer-events: auto;
  272. padding: 8px 10px;
  273. }
  274. .stage {
  275. display: inline-flex;
  276. align-items: center;
  277. height: 18px;
  278. line-height: 18px;
  279. font-size: 12px;
  280. color: #fff;
  281. border-radius: 3px;
  282. padding: 0 8px;
  283. white-space: nowrap;
  284. }
  285. .detail-tabs :deep(.ant-tabs-nav) {
  286. margin-bottom: 6px;
  287. }
  288. .detail-tabs :deep(.ant-tabs-tab) {
  289. font-size: 13px;
  290. padding: 4px 0;
  291. }
  292. .detail {
  293. font-size: 12px;
  294. }
  295. .pair {
  296. display: grid;
  297. grid-template-columns: 1fr 1fr;
  298. gap: 8px;
  299. }
  300. .cell,
  301. .dline {
  302. display: flex;
  303. min-width: 0;
  304. line-height: 20px;
  305. }
  306. .label {
  307. color: #aaaaaa;
  308. flex: 0 0 auto;
  309. }
  310. .value {
  311. color: #fff;
  312. min-width: 0;
  313. }
  314. .cell .value {
  315. overflow: hidden;
  316. text-overflow: ellipsis;
  317. white-space: nowrap;
  318. }
  319. .dline .value.wrap {
  320. word-break: break-all;
  321. }
  322. .chart-block {
  323. margin-top: 6px;
  324. padding: 4px 0;
  325. }
  326. .chart-loading {
  327. display: flex;
  328. align-items: center;
  329. justify-content: center;
  330. gap: 6px;
  331. padding: 12px 4px 6px;
  332. }
  333. .chart-loading-text {
  334. color: #aaaaaa;
  335. font-size: 12px;
  336. }
  337. .chart-loading :deep(.ant-spin-dot) {
  338. font-size: 12px;
  339. }
  340. .chart-error {
  341. color: #ff4d4f;
  342. font-size: 12px;
  343. padding: 8px 0;
  344. }
  345. .rt-wrap {
  346. position: relative;
  347. }
  348. .rt-loading {
  349. position: absolute;
  350. inset: 0;
  351. display: flex;
  352. align-items: center;
  353. justify-content: center;
  354. gap: 6px;
  355. background: rgba(0, 0, 0, 0.35);
  356. border-radius: 6px;
  357. pointer-events: none;
  358. }
  359. .rt-loading-text {
  360. color: #ffffff;
  361. font-size: 12px;
  362. }
  363. .rt-loading :deep(.ant-spin-dot) {
  364. font-size: 12px;
  365. }
  366. .rt-error {
  367. color: #ff4d4f;
  368. font-size: 12px;
  369. padding: 8px 0;
  370. }
  371. .indicator-block {
  372. margin-top: 8px;
  373. padding-top: 6px;
  374. border-top: 1px solid #333a45;
  375. }
  376. .cyl-tabs {
  377. display: flex;
  378. justify-content: center;
  379. gap: 12px;
  380. margin-bottom: 4px;
  381. }
  382. .cyl {
  383. font-size: 12px;
  384. color: #8a929c;
  385. cursor: pointer;
  386. padding-bottom: 3px;
  387. border-bottom: 2px solid transparent;
  388. user-select: none;
  389. }
  390. .cyl:hover {
  391. color: #c3c9d1;
  392. }
  393. .cyl.active {
  394. color: #ffffff;
  395. border-bottom-color: #ffffff;
  396. }
  397. .ind-wrap {
  398. position: relative;
  399. }
  400. .ind-item {
  401. margin-bottom: 6px;
  402. }
  403. .ind-title {
  404. font-size: 11px;
  405. color: #aaaaaa;
  406. line-height: 16px;
  407. text-align: center;
  408. }
  409. .ind-empty {
  410. color: #aaaaaa;
  411. font-size: 12px;
  412. text-align: center;
  413. padding: 24px 0;
  414. }
  415. .ind-loading {
  416. position: absolute;
  417. inset: 0;
  418. display: flex;
  419. align-items: center;
  420. justify-content: center;
  421. gap: 6px;
  422. background: rgba(0, 0, 0, 0.35);
  423. border-radius: 6px;
  424. pointer-events: none;
  425. }
  426. .card-foot {
  427. display: flex;
  428. align-items: center;
  429. gap: 8px;
  430. margin-top: 4px;
  431. padding-top: 4px;
  432. border-top: 1px solid #333a45;
  433. }
  434. .foot-spacer {
  435. flex: 1;
  436. }
  437. .card-foot :deep(.ant-btn) {
  438. font-size: 12px;
  439. }
  440. </style>