|
|
@@ -1,5 +1,5 @@
|
|
|
<!--
|
|
|
- 设备详情的分区(票 11):档案 / 指标 / 预警 / 报警 / 工单状态 / 最新图片。
|
|
|
+ 设备详情的分区(票 11):档案 / 指标 / 预警 / 报警 / 工单状态 / 设备图片。
|
|
|
|
|
|
票 46 返工(维护者 2026-10-15 第 1 条):**弃用六块竖排的长滚动**,改为标签页切换
|
|
|
设备档案(默认,图片归此页下方)|最新指标|预警信息|报警信息|工单状态;
|
|
|
@@ -10,8 +10,8 @@
|
|
|
本组件不再自画 pager、不再有第二份翻页算术;前后页/页码/跳页共用唯一出口
|
|
|
`gotoPage(panel, target)`,越界夹回由 `pagedRows` 做。
|
|
|
**渲染计划仍是六块独立分区**(t11 D1/D9):标签页只改呈现归属,
|
|
|
- 每块仍各带自己的状态标签与成败 —— 档案读失败但指标可用是后端契约的原生态,
|
|
|
- 界面共用一个成败等于把后端已说清的信息揉回含糊的「加载失败」。
|
|
|
+ 每块仍独立成败;指标页改读独立采集记录分区,按当前需求不画状态点/过期说明。
|
|
|
+ 档案读失败但指标可用仍各自呈现,不能共用一个成败。
|
|
|
|
|
|
三种「没内容」在界面上的区别(票 16 的词汇,措辞由 domain/equipmentDetailView.js 给):
|
|
|
空数据 EMPTY → 「该分区当前没有数据」
|
|
|
@@ -19,8 +19,7 @@
|
|
|
不可用 UNAVAILABLE → 「该分区数据不可用(其它分区不受影响)」
|
|
|
过期 STALE 是**有值**:内容照画,只是标签说「数据已过期,显示最后一次成功数据」。
|
|
|
|
|
|
- 刻意不做的事:**不画事件的四级配色**(票 13/19 的视觉语言)。等级只以文本 +
|
|
|
- `data-event-level-color` 如实带出后端给的值。
|
|
|
+ 事件高亮采用既有四级颜色词表;数值预警黄/报警红与等级提示点分别呈现。
|
|
|
|
|
|
票 12 在**工单状态这块里**接上了状态时间线(不新增第七块面板):
|
|
|
每张工单下面挂它自己的那条轨迹(`timelineSource()` 按工单编码配好)。
|
|
|
@@ -51,7 +50,7 @@
|
|
|
</div>
|
|
|
|
|
|
<!--
|
|
|
- 当前页签的面板(档案页含两块:档案 + 最新图片,所以 tabpanel 挂在**包装层**
|
|
|
+ 当前页签的面板(档案页含两块:档案 + 设备图片,所以 tabpanel 挂在**包装层**
|
|
|
而不是每块 section 上 —— 一个页签对应一个 tabpanel 才是正确的可访问语义)。
|
|
|
-->
|
|
|
<div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>
|
|
|
@@ -65,11 +64,11 @@
|
|
|
>
|
|
|
<header class="equipment-section__head">
|
|
|
<span class="equipment-section__title">{{ panel.label }}</span>
|
|
|
- <SectionStateTag :name="panel.name" :section="panel.section" />
|
|
|
+ <SectionStateTag v-if="panel.kind !== 'metrics'" :name="panel.name" :section="panel.section" />
|
|
|
</header>
|
|
|
|
|
|
<!-- 过期分区在内容之上再点一次题(颜色之外的第二种提示,灰度下也读得出) -->
|
|
|
- <p v-if="panel.section.retained" class="equipment-section__stale" data-section-stale-note>
|
|
|
+ <p v-if="panel.kind !== 'metrics' && panel.section.retained" class="equipment-section__stale" data-section-stale-note>
|
|
|
{{ staleNote }}
|
|
|
</p>
|
|
|
|
|
|
@@ -106,28 +105,77 @@
|
|
|
</div>
|
|
|
</dl>
|
|
|
|
|
|
- <!-- 指标:名称 + 读数 + 单位 + 采样时间 -->
|
|
|
- <div v-else-if="panel.kind === 'metrics'" class="equipment-section__list" data-metric-list>
|
|
|
- <p v-if="metricsSource(panel)" class="equipment-section__note" data-metric-source>{{ metricsSource(panel) }}</p>
|
|
|
- <article
|
|
|
- v-for="(metric, index) in rowsOf(panel)"
|
|
|
- :key="metric.code || index"
|
|
|
- class="equipment-section__item"
|
|
|
- :data-metric="metric.code || index"
|
|
|
- >
|
|
|
- <span class="equipment-section__item-name">{{ metric.name }}</span>
|
|
|
- <b class="equipment-section__value" :class="{ 'is-muted': isPlaceholder(metric.value) }" data-metric-value>
|
|
|
- {{ metric.value }}<em v-if="metric.unit && !isPlaceholder(metric.value)"> {{ metric.unit }}</em>
|
|
|
- </b>
|
|
|
- <span class="equipment-section__meta">
|
|
|
- <span v-if="metric.status">{{ metric.status }}</span>
|
|
|
- <span v-if="metric.freshnessLabel" data-metric-stale>{{ metric.freshnessLabel }}</span>
|
|
|
- <span data-sampled-at>{{ metric.sampledAtLabel }} {{ metric.sampledAt || '—' }}</span>
|
|
|
- </span>
|
|
|
- </article>
|
|
|
- <p v-if="!rowsOf(panel).length" class="equipment-section__hint" data-section-empty-list>
|
|
|
- {{ emptyList('指标') }}
|
|
|
- </p>
|
|
|
+ <!-- 采集记录:最近50行;分页后只合并当前页相同采集时间。 -->
|
|
|
+ <div v-else-if="panel.kind === 'metrics'" class="equipment-section__list" data-collection-records>
|
|
|
+ <p class="equipment-section__note">采集记录(最近50条)</p>
|
|
|
+ <div class="collection-table-wrap">
|
|
|
+ <table v-if="recordTableOf(panel).total" class="collection-table" data-collection-table aria-label="设备最近采集记录">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th v-for="column in recordTableOf(panel).columns" :key="column.key" :class="{ 'collection-table__time': column.isTime, 'collection-table__audio-col': column.isAudio }">
|
|
|
+ <span class="collection-table__heading" :title="column.label">{{ column.label }}</span>
|
|
|
+ <span v-if="column.unit" class="collection-table__unit">({{ column.unit }})</span>
|
|
|
+ </th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody v-for="group in recordTableOf(panel).groups" :key="group.key"
|
|
|
+ :class="{ 'collection-table__group--latest': group.isLatest }"
|
|
|
+ :style="{ '--collection-accent': group.color }"
|
|
|
+ :data-collection-group="group.key" :data-collection-group-state="group.state">
|
|
|
+ <tr v-for="(row, rowIndex) in group.rows" :key="rowIndex" data-collection-row :class="{ 'collection-table__row--latest': group.isLatest }" :data-collection-latest="group.isLatest ? 'true' : undefined">
|
|
|
+ <template v-for="(cell, cellIndex) in row.cells" :key="cell.key">
|
|
|
+ <td
|
|
|
+ v-if="!recordTableOf(panel).columns[cellIndex].isTime || row.timeRowspan > 0"
|
|
|
+ data-collection-cell
|
|
|
+ :data-reading-state="cell.alert?.state"
|
|
|
+ :style="cell.alert ? { '--reading-color': cell.alert.color } : undefined"
|
|
|
+ :rowspan="recordTableOf(panel).columns[cellIndex].isTime ? row.timeRowspan : undefined"
|
|
|
+ :class="{ 'collection-table__time': recordTableOf(panel).columns[cellIndex].isTime, 'collection-table__audio-col': recordTableOf(panel).columns[cellIndex].isAudio }"
|
|
|
+ >
|
|
|
+ <template v-if="recordTableOf(panel).columns[cellIndex].isTime">
|
|
|
+ <span v-if="group.isLatest" class="collection-table__latest-badge" data-collection-latest-badge>最新</span>
|
|
|
+ <span class="collection-table__time-date">{{ row.createDate }}</span>
|
|
|
+ <span v-if="row.createClock" class="collection-table__time-clock">{{ row.createClock }}</span>
|
|
|
+ </template>
|
|
|
+ <button v-else-if="cell.audioPath" type="button" class="collection-table__audio-button" data-collection-audio-open :aria-label="'播放' + (cell.audioLabel || '音频记录')" :title="cell.audioLabel || '播放音频'" @click="openRecordAudio(cell, $event)">
|
|
|
+ <span aria-hidden="true">▶</span><span>播放</span>
|
|
|
+ </button>
|
|
|
+ <!--
|
|
|
+ long 布局的格内是「指标名:读数」整条:名称走正文样式,
|
|
|
+ 读数段与其它设备的指标值同一套等宽数字字体。
|
|
|
+ -->
|
|
|
+ <span
|
|
|
+ v-else-if="cell.reading && cell.reading.label"
|
|
|
+ class="collection-table__reading"
|
|
|
+ >
|
|
|
+ <span class="collection-table__reading-label">{{ cell.reading.label }}</span>
|
|
|
+ <span class="collection-table__reading-result">
|
|
|
+ <span class="collection-table__reading-value" :class="{ 'is-muted': isPlaceholder(cell.reading.value) }">{{ cell.reading.value }}</span>
|
|
|
+ <span v-if="cell.alert" class="collection-table__alert-dot" tabindex="0" role="img"
|
|
|
+ :style="{ color: cell.alert.dotColor }" :title="cell.alert.label" :aria-label="cell.alert.label" />
|
|
|
+ </span>
|
|
|
+ </span>
|
|
|
+ <span v-else class="collection-table__reading-result">
|
|
|
+ <span :class="{ 'is-muted': isPlaceholder(cell.text), 'collection-table__value': cell.isReading }">{{ cell.text }}</span>
|
|
|
+ <span v-if="cell.alert" class="collection-table__alert-dot" tabindex="0" role="img"
|
|
|
+ :style="{ color: cell.alert.dotColor }" :title="cell.alert.label" :aria-label="cell.alert.label" />
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ </template>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ <p v-if="!recordTableOf(panel).total" class="equipment-section__hint" data-section-empty-list>暂无采集记录</p>
|
|
|
+ <p v-else class="equipment-section__hint" data-collection-assessment-note>{{ recordTableOf(panel).assessmentNote }}</p>
|
|
|
+ <PagerNav
|
|
|
+ v-if="recordTableOf(panel).total"
|
|
|
+ :page="recordTableOf(panel).page"
|
|
|
+ :page-count="recordTableOf(panel).pageCount"
|
|
|
+ :total="recordTableOf(panel).total"
|
|
|
+ list-name="采集记录"
|
|
|
+ @page="(target) => gotoPage(panel, target)"
|
|
|
+ />
|
|
|
</div>
|
|
|
|
|
|
<!--
|
|
|
@@ -141,8 +189,13 @@
|
|
|
v-for="(event, index) in pagedOf(panel).rows"
|
|
|
:key="event.code || index"
|
|
|
class="equipment-section__item equipment-section__item--collapsible"
|
|
|
+ :class="{ 'equipment-section__item--active': event.active }"
|
|
|
+ :style="{ '--detail-accent': event.activeColor }"
|
|
|
+ :open="isDetailOpen(panel, event, index)"
|
|
|
+ @toggle="rememberDetailToggle(panel, event, index, $event)"
|
|
|
:data-event="event.code || index"
|
|
|
:data-event-level-color="event.levelColor"
|
|
|
+ :data-item-active="event.active ? 'true' : 'false'"
|
|
|
data-history-item
|
|
|
>
|
|
|
<summary class="equipment-section__item-head">
|
|
|
@@ -150,10 +203,11 @@
|
|
|
<span class="equipment-section__meta">
|
|
|
<span data-event-level>{{ event.levelText }}</span>
|
|
|
<span v-if="event.status">{{ event.status }}</span>
|
|
|
+ <span v-if="event.active" class="equipment-section__active-badge" data-active-badge>{{ event.activeLabel }}</span>
|
|
|
</span>
|
|
|
<span class="equipment-section__meta">
|
|
|
<span v-if="event.timeText">{{ event.timeText }}</span>
|
|
|
- <span class="equipment-section__mono">{{ event.code }}</span>
|
|
|
+ <span class="equipment-section__mono">{{ event.codeText }}</span>
|
|
|
</span>
|
|
|
<span class="equipment-section__value" :class="{ 'is-muted': isPlaceholder(event.quantity) }" data-event-value>
|
|
|
{{ event.quantity }}
|
|
|
@@ -191,8 +245,13 @@
|
|
|
v-for="(order, index) in pagedOf(panel).rows"
|
|
|
:key="order.code || index"
|
|
|
class="equipment-section__item equipment-section__item--collapsible"
|
|
|
+ :class="{ 'equipment-section__item--active': order.active }"
|
|
|
+ :style="{ '--detail-accent': order.activeColor }"
|
|
|
+ :open="isDetailOpen(panel, order, index)"
|
|
|
+ @toggle="rememberDetailToggle(panel, order, index, $event)"
|
|
|
:data-work-order="order.code || index"
|
|
|
:data-work-order-state="order.stateCode"
|
|
|
+ :data-item-active="order.active ? 'true' : 'false'"
|
|
|
data-history-item
|
|
|
>
|
|
|
<summary class="equipment-section__item-head">
|
|
|
@@ -203,6 +262,7 @@
|
|
|
<span class="equipment-section__meta">
|
|
|
<span class="equipment-section__mono">{{ order.code }}</span>
|
|
|
<span data-work-order-association>{{ order.associationText }}</span>
|
|
|
+ <span v-if="order.active" class="equipment-section__active-badge" data-active-badge>{{ order.activeLabel }}</span>
|
|
|
</span>
|
|
|
<span class="equipment-section__meta">
|
|
|
<span v-if="order.timeText">创建 {{ order.timeText }}</span>
|
|
|
@@ -251,11 +311,11 @@
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 最新图片:只画后端指定的那一张(前端不索引、不挑选) -->
|
|
|
+ <!-- 设备图片:只画后端指定的那一张(前端不索引、不挑选) -->
|
|
|
<figure v-else-if="panel.kind === 'image'" class="equipment-section__figure" data-latest-image>
|
|
|
<template v-if="image(panel).hasImage">
|
|
|
<button v-if="!imageFailed" type="button" class="equipment-section__image-button" aria-label="放大查看设备图片" @click="imageDialog.showModal()">
|
|
|
- <img class="equipment-section__img" :src="image(panel).url" :alt="`${deviceCode} 最新图片`" @load="imageLoading = false" @error="imageFailed = true; imageLoading = false" />
|
|
|
+ <img class="equipment-section__img" :src="image(panel).url" :alt="`${deviceCode} 设备图片`" @load="imageLoading = false" @error="imageFailed = true; imageLoading = false" />
|
|
|
<span>{{ imageLoading ? '图片加载中…' : '点击放大' }}</span>
|
|
|
</button>
|
|
|
<p v-else class="equipment-section__hint" role="status">图片加载失败,请检查图片服务地址或文件是否可访问。<button type="button" @click="imageFailed = false; imageLoading = true">重试</button></p>
|
|
|
@@ -282,13 +342,33 @@
|
|
|
</div>
|
|
|
<dialog ref="imageDialog" class="equipment-image-viewer" aria-label="设备图片放大查看" @click="closeImageBackdrop">
|
|
|
<button type="button" autofocus class="equipment-image-viewer__close" @click="imageDialog.close()">关闭</button>
|
|
|
- <img :src="currentImageUrl" :alt="`${deviceCode} 最新图片`" />
|
|
|
+ <img :src="currentImageUrl" :alt="`${deviceCode} 设备图片`" />
|
|
|
</dialog>
|
|
|
+ <!--
|
|
|
+ 音频播放条:贴在播放键上方渐入的小浮层(Teleport 到 body,避免被滚动容器裁剪)。
|
|
|
+ 不再用居中大弹窗/背景虚化;点击外部、滚动或 Esc 都会收起。
|
|
|
+ -->
|
|
|
+ <Teleport to="body">
|
|
|
+ <div
|
|
|
+ v-if="activeAudioPath"
|
|
|
+ ref="audioPlayer"
|
|
|
+ class="collection-audio-player"
|
|
|
+ :class="{ 'is-below': audioPlacement.below }"
|
|
|
+ :style="audioPlayerStyle"
|
|
|
+ data-collection-audio-player
|
|
|
+ >
|
|
|
+ <div class="collection-audio-player__head">
|
|
|
+ <span class="collection-audio-player__label">{{ activeAudioLabel }}</span>
|
|
|
+ <button type="button" class="collection-audio-player__close" aria-label="关闭播放条" @click="closeAudioPlayer">×</button>
|
|
|
+ </div>
|
|
|
+ <audio v-if="activeAudioUrl" class="collection-audio-player__audio" controls preload="metadata" :src="activeAudioUrl" />
|
|
|
+ </div>
|
|
|
+ </Teleport>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { computed, ref, watch } from 'vue'
|
|
|
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
import { resolveDeviceImageUrl } from '../services/deviceImage.js'
|
|
|
import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
|
|
|
import {
|
|
|
@@ -305,10 +385,10 @@ import {
|
|
|
eventRows,
|
|
|
filterByCategory,
|
|
|
imageView,
|
|
|
- metricRows,
|
|
|
- metricsSourceText,
|
|
|
+ collectionRecordTable,
|
|
|
pagedRows,
|
|
|
panelsForTab,
|
|
|
+ prioritizeActiveRows,
|
|
|
tabCount,
|
|
|
timelineSource,
|
|
|
workOrderDetailRows,
|
|
|
@@ -360,12 +440,68 @@ const categories = ref({})
|
|
|
|
|
|
/** 各分区当前页码(1 起;预警/报警/工单三块都接入分页)。 */
|
|
|
const pages = ref({})
|
|
|
+const recordTables = computed(() => Object.fromEntries(
|
|
|
+ panels.value.filter((panel) => panel.kind === 'metrics').map((panel) => [panel.name, collectionRecordTable(panel.section.data, pages.value[panel.name] || 1)]),
|
|
|
+))
|
|
|
+function recordTableOf(panel) {
|
|
|
+ return recordTables.value[panel.name]
|
|
|
+}
|
|
|
+const audioPlayer = ref(null)
|
|
|
+const activeAudioPath = ref('')
|
|
|
+const activeAudioLabel = ref('音频记录')
|
|
|
+const activeAudioUrl = computed(() => activeAudioPath.value ? resolveDeviceImageUrl(activeAudioPath.value) : '')
|
|
|
+const audioPlacement = ref({ left: 0, top: 0, below: false })
|
|
|
+const audioPlayerStyle = computed(() => ({
|
|
|
+ left: `${audioPlacement.value.left}px`,
|
|
|
+ top: `${audioPlacement.value.top}px`,
|
|
|
+}))
|
|
|
+let audioTriggerEl = null
|
|
|
+
|
|
|
+/** 打开播放条:以触发按钮为锚,默认在按钮上方渐入;顶部空间不足时翻到按钮下方。 */
|
|
|
+function openRecordAudio(cell, event) {
|
|
|
+ if (!cell?.audioPath) return
|
|
|
+ const rect = event?.currentTarget?.getBoundingClientRect?.()
|
|
|
+ if (rect) {
|
|
|
+ const margin = 132
|
|
|
+ const left = Math.min(Math.max(rect.left + rect.width / 2, margin), Math.max(margin, window.innerWidth - margin))
|
|
|
+ const below = rect.top < 96
|
|
|
+ audioPlacement.value = { left: Math.round(left), top: Math.round(below ? rect.bottom : rect.top), below }
|
|
|
+ }
|
|
|
+ audioTriggerEl = event?.currentTarget || null
|
|
|
+ activeAudioPath.value = cell.audioPath
|
|
|
+ activeAudioLabel.value = cell.audioLabel || '音频记录'
|
|
|
+}
|
|
|
+function closeAudioPlayer() {
|
|
|
+ if (!activeAudioPath.value) return
|
|
|
+ activeAudioPath.value = ''
|
|
|
+ audioTriggerEl = null
|
|
|
+}
|
|
|
+function audioOutsideHandler(event) {
|
|
|
+ if (audioPlayer.value?.contains(event.target) || audioTriggerEl?.contains?.(event.target)) return
|
|
|
+ closeAudioPlayer()
|
|
|
+}
|
|
|
+function audioEscapeHandler(event) {
|
|
|
+ if (event.key === 'Escape') closeAudioPlayer()
|
|
|
+}
|
|
|
+function attachAudioGuards() {
|
|
|
+ document.addEventListener('pointerdown', audioOutsideHandler, true)
|
|
|
+ // 滚动时按钮会离开锚点,直接收起(capture 才能收到各滚动容器不冒泡的 scroll)。
|
|
|
+ window.addEventListener('scroll', closeAudioPlayer, true)
|
|
|
+ window.addEventListener('keydown', audioEscapeHandler, true)
|
|
|
+}
|
|
|
+function detachAudioGuards() {
|
|
|
+ if (typeof document === 'undefined' || typeof window === 'undefined') return
|
|
|
+ document.removeEventListener('pointerdown', audioOutsideHandler, true)
|
|
|
+ window.removeEventListener('scroll', closeAudioPlayer, true)
|
|
|
+ window.removeEventListener('keydown', audioEscapeHandler, true)
|
|
|
+}
|
|
|
+watch(activeAudioPath, (open) => { if (open) attachAudioGuards(); else detachAudioGuards() })
|
|
|
+onBeforeUnmount(detachAudioGuards)
|
|
|
|
|
|
/** 某分区的行视图(未过滤)。 */
|
|
|
function rowsOf(panel) {
|
|
|
const data = panel.section.data
|
|
|
if (panel.kind === 'archive') return archiveRows(data)
|
|
|
- if (panel.kind === 'metrics') return metricRows(data)
|
|
|
if (panel.kind === 'orders') return workOrderRows(data)
|
|
|
return eventRows(data)
|
|
|
}
|
|
|
@@ -395,7 +531,9 @@ function toggleCategory(panelName, key) {
|
|
|
|
|
|
/** 过滤后的行(chip 只筛视图,不动数据、不发请求)。 */
|
|
|
function visibleRows(panel) {
|
|
|
- return filterByCategory(rowsOf(panel), activeCategory(panel.name))
|
|
|
+ const rows = filterByCategory(rowsOf(panel), activeCategory(panel.name))
|
|
|
+ if (panel.kind !== 'events' && panel.kind !== 'orders') return rows
|
|
|
+ return prioritizeActiveRows(rows)
|
|
|
}
|
|
|
|
|
|
/** 过滤 + 分页后的窗口(domain 单源 `pagedRows` 收边界;票 59 三清单同一条路径,组件不再备第二份兜底形状)。 */
|
|
|
@@ -415,12 +553,35 @@ function categoryNoteText(panel) {
|
|
|
return categoryNote(chipsOf(panel), activeCategory(panel.name))
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * 票 115 优化:未解除/未结案条目的展开状态。
|
|
|
+ *
|
|
|
+ * 切换页签时清空手动状态 ⇒ 进入对应页签时,active 条目默认展开;
|
|
|
+ * 用户手动收起/展开后以手动状态为准(manualOpen[key]),不会每帧被默认值顶回去。
|
|
|
+ */
|
|
|
+const manualOpen = ref({})
|
|
|
+function detailKey(panel, item, index) {
|
|
|
+ return panel.name + '|' + (item.code || index)
|
|
|
+}
|
|
|
+function isDetailOpen(panel, item, index) {
|
|
|
+ const key = detailKey(panel, item, index)
|
|
|
+ return Object.hasOwn(manualOpen.value, key) ? manualOpen.value[key] : item.active === true
|
|
|
+}
|
|
|
+function rememberDetailToggle(panel, item, index, event) {
|
|
|
+ const key = detailKey(panel, item, index)
|
|
|
+ manualOpen.value = { ...manualOpen.value, [key]: event.target.open }
|
|
|
+}
|
|
|
+watch(activeTab, () => {
|
|
|
+ manualOpen.value = {}
|
|
|
+})
|
|
|
+
|
|
|
// 换设备:页签、分类条件与页码全部复位(不同设备的分布互不相干,留着就是拿 A 的状态看 B)。
|
|
|
watch(
|
|
|
() => props.deviceCode,
|
|
|
() => {
|
|
|
categories.value = {}
|
|
|
pages.value = {}
|
|
|
+ manualOpen.value = {}
|
|
|
activeTab.value = DEFAULT_EQUIPMENT_TAB
|
|
|
},
|
|
|
)
|
|
|
@@ -438,10 +599,6 @@ function trackOfOrder(order) {
|
|
|
return full || pathOnlyTrack(order)
|
|
|
}
|
|
|
|
|
|
-function metricsSource(panel) {
|
|
|
- return metricsSourceText(panel.section.data)
|
|
|
-}
|
|
|
-
|
|
|
function image(panel) {
|
|
|
const view = imageView(panel.section.data)
|
|
|
return { ...view, url: resolveDeviceImageUrl(view.url) }
|
|
|
@@ -458,6 +615,9 @@ watch([() => props.deviceCode, currentImageUrl], () => {
|
|
|
imageFailed.value = false
|
|
|
imageLoading.value = true
|
|
|
})
|
|
|
+watch([() => props.deviceCode, activeTab], () => {
|
|
|
+ closeAudioPlayer()
|
|
|
+})
|
|
|
function closeImageBackdrop(event) {
|
|
|
if (event.target !== imageDialog.value) return
|
|
|
const rect = imageDialog.value.getBoundingClientRect()
|
|
|
@@ -474,6 +634,159 @@ function isPlaceholder(text) {
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
+.collection-table-wrap { min-width: 0; overflow-x: hidden; }
|
|
|
+.collection-table {
|
|
|
+ width: 100%;
|
|
|
+ table-layout: fixed;
|
|
|
+ border-collapse: collapse;
|
|
|
+ color: var(--text-primary, #e5f4ff);
|
|
|
+ font-size: calc(11 * var(--u, 1px));
|
|
|
+}
|
|
|
+.collection-table th,
|
|
|
+.collection-table td {
|
|
|
+ min-width: 0;
|
|
|
+ padding: calc(4 * var(--u, 1px)) calc(3 * var(--u, 1px));
|
|
|
+ border: 1px solid rgba(69, 161, 226, .16);
|
|
|
+ text-align: left;
|
|
|
+ vertical-align: middle;
|
|
|
+}
|
|
|
+.collection-table th {
|
|
|
+ color: #a6d8ff;
|
|
|
+ background: rgba(29, 83, 124, .25);
|
|
|
+ font-size: calc(10 * var(--u, 1px));
|
|
|
+ letter-spacing: .02em;
|
|
|
+ line-height: 1.2;
|
|
|
+}
|
|
|
+.collection-table__heading {
|
|
|
+ display: inline-block;
|
|
|
+ max-width: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ color: inherit;
|
|
|
+ font-weight: 600;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ vertical-align: bottom;
|
|
|
+ white-space: nowrap;
|
|
|
+ word-break: keep-all;
|
|
|
+}
|
|
|
+.collection-table__unit {
|
|
|
+ white-space: normal;
|
|
|
+ word-break: break-word;
|
|
|
+ overflow-wrap: anywhere;
|
|
|
+ font-size: .92em;
|
|
|
+}
|
|
|
+.collection-table td { background: rgba(7, 27, 48, .35); font-variant-numeric: tabular-nums; line-height: 1.35; overflow-wrap: anywhere; }
|
|
|
+.collection-table tbody:not(.collection-table__group--latest):nth-of-type(even) td { background: rgba(7, 27, 48, .22); }
|
|
|
+.collection-table tbody:not(.collection-table__group--latest) tr:hover td { background: rgba(1, 171, 234, .12); }
|
|
|
+/* 渐变只画在整次采集的行组上,td 透明;长表 rowspan 的时间格也共用同一张背景。 */
|
|
|
+.collection-table__group--latest {
|
|
|
+ --reading-color: var(--lv4, #27d17a);
|
|
|
+ background: linear-gradient(90deg, color-mix(in srgb, var(--collection-accent) 24%, transparent), rgba(7, 27, 48, .28) 100%);
|
|
|
+}
|
|
|
+.collection-table .collection-table__group--latest td {
|
|
|
+ background: transparent;
|
|
|
+ border-color: color-mix(in srgb, var(--collection-accent) 30%, transparent);
|
|
|
+}
|
|
|
+.collection-table__group--latest tr:first-child td:first-child { box-shadow: inset 3px 0 0 var(--collection-accent); }
|
|
|
+.collection-table__group--latest:hover {
|
|
|
+ background: linear-gradient(90deg, color-mix(in srgb, var(--collection-accent) 30%, transparent), rgba(7, 27, 48, .34) 100%);
|
|
|
+}
|
|
|
+.collection-table__latest-badge {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: calc(2 * var(--u, 1px));
|
|
|
+ padding: 0 calc(5 * var(--u, 1px));
|
|
|
+ border: 1px solid color-mix(in srgb, var(--collection-accent) 65%, transparent);
|
|
|
+ border-radius: 999px;
|
|
|
+ background: color-mix(in srgb, var(--collection-accent) 16%, transparent);
|
|
|
+ color: var(--collection-accent);
|
|
|
+ font-size: var(--fs-caption, 10.5px);
|
|
|
+ line-height: calc(16 * var(--u, 1px));
|
|
|
+ letter-spacing: .04em;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+/* 采集时间已拆两行(日期 10 字符是最宽一行),定宽按内容收紧,
|
|
|
+ 余量让给右侧指标列(维护者 2026-11-06 反馈:125u 约剩一半空白)。 */
|
|
|
+.collection-table__time { width: calc(82 * var(--u, 1px)); }
|
|
|
+.collection-table__time-date,
|
|
|
+.collection-table__time-clock { display: block; white-space: nowrap; }
|
|
|
+.collection-table__audio-col { width: calc(72 * var(--u, 1px)); }
|
|
|
+.collection-table__audio-button {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: calc(3 * var(--u, 1px));
|
|
|
+ width: 100%;
|
|
|
+ min-width: 0;
|
|
|
+ padding: calc(3 * var(--u, 1px)) calc(4 * var(--u, 1px));
|
|
|
+ border: 1px solid rgba(1, 171, 234, .5);
|
|
|
+ border-radius: 999px;
|
|
|
+ background: rgba(1, 91, 150, .26);
|
|
|
+ color: #b8e8ff;
|
|
|
+ font: inherit;
|
|
|
+ white-space: nowrap;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.collection-table__audio-button:hover,
|
|
|
+.collection-table__audio-button:focus-visible { border-color: #ffb020; color: #ffd08a; outline: none; }
|
|
|
+/* 指标读数排版(wide / long 两种布局共用这一套,不因列名不同而脱节):
|
|
|
+ 字体与左右侧面板 KPI 数值(.panel-kpi__value)同档 --num(Rajdhani / Share Tech Mono…,
|
|
|
+ 单源 tokens.css);字号按表格密度自 22u KPI 档收到 13.5u;满不透明度的 --text-strong
|
|
|
+ 高亮白并加粗,读数在大屏上一眼可读;读数相对单元格左边界略微缩进。
|
|
|
+ long 布局的「指标名:读数」整条,名称段用正文样式、读数段复用本套字体(见下)。
|
|
|
+ 报警行以更高优先级抢回红色,占位灰由后置 is-muted 覆盖。 */
|
|
|
+.collection-table__value,
|
|
|
+.collection-table__reading-value {
|
|
|
+ color: var(--reading-color, var(--text-strong, #fff));
|
|
|
+ opacity: 1;
|
|
|
+ font-family: var(--num, monospace);
|
|
|
+ font-size: calc(13.5 * var(--u, 1px));
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: .02em;
|
|
|
+ text-shadow: 0 0 8px color-mix(in srgb, currentColor 24%, transparent);
|
|
|
+}
|
|
|
+.collection-table__value {
|
|
|
+ padding-left: calc(4 * var(--u, 1px));
|
|
|
+}
|
|
|
+.collection-table__reading-label {
|
|
|
+ color: var(--muted, #5680b0);
|
|
|
+ font-size: calc(11 * var(--u, 1px));
|
|
|
+ font-weight: 400;
|
|
|
+}
|
|
|
+.collection-table__reading {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: minmax(0, 48%) minmax(0, 1fr);
|
|
|
+ column-gap: calc(14 * var(--u, 1px));
|
|
|
+ align-items: baseline;
|
|
|
+ padding-left: calc(4 * var(--u, 1px));
|
|
|
+ text-align: left;
|
|
|
+}
|
|
|
+.collection-table__reading-result {
|
|
|
+ display: inline-flex;
|
|
|
+ min-width: 0;
|
|
|
+ align-items: baseline;
|
|
|
+ justify-content: flex-start;
|
|
|
+ gap: calc(6 * var(--u, 1px));
|
|
|
+}
|
|
|
+.collection-table__reading-result > span:first-child { min-width: 0; }
|
|
|
+.collection-table__value.is-muted,
|
|
|
+.collection-table__reading-value.is-muted { color: var(--muted, #5680b0); font-weight: 400; text-shadow: none; }
|
|
|
+.collection-table__alert-dot {
|
|
|
+ display: inline-block;
|
|
|
+ flex: none;
|
|
|
+ width: calc(7 * var(--u, 1px));
|
|
|
+ height: calc(7 * var(--u, 1px));
|
|
|
+ border-radius: 50%;
|
|
|
+ background: currentColor;
|
|
|
+ box-shadow: 0 0 6px currentColor;
|
|
|
+ cursor: help;
|
|
|
+}
|
|
|
+.collection-table__alert-dot:focus-visible { outline: 1px solid currentColor; outline-offset: 3px; }
|
|
|
+.collection-table__label {
|
|
|
+ color: var(--muted, #5680b0);
|
|
|
+ font-size: calc(10.5 * var(--u, 1px));
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
.equipment-sections,
|
|
|
.equipment-sections *,
|
|
|
.equipment-sections *::before,
|
|
|
@@ -680,6 +993,37 @@ function isPlaceholder(text) {
|
|
|
padding-bottom: calc(5 * var(--u, 1px));
|
|
|
}
|
|
|
|
|
|
+/* 事件按等级色强调;未分级预警黄、报警红;非终态工单天蓝。 */
|
|
|
+.equipment-section__item--active {
|
|
|
+ padding-left: calc(6 * var(--u, 1px));
|
|
|
+ border-bottom-color: color-mix(in srgb, var(--detail-accent) 65%, transparent);
|
|
|
+ border-radius: calc(6 * var(--u, 1px));
|
|
|
+ background: linear-gradient(90deg, color-mix(in srgb, var(--detail-accent) 20%, transparent), rgba(7, 27, 48, .12) 78%);
|
|
|
+ box-shadow: inset 3px 0 0 var(--detail-accent), inset 0 0 0 1px color-mix(in srgb, var(--detail-accent) 30%, transparent), 0 0 12px color-mix(in srgb, var(--detail-accent) 12%, transparent);
|
|
|
+}
|
|
|
+.equipment-section__item--active[open] {
|
|
|
+ border-bottom-color: color-mix(in srgb, var(--detail-accent) 78%, transparent);
|
|
|
+ background: linear-gradient(90deg, color-mix(in srgb, var(--detail-accent) 26%, transparent), rgba(7, 27, 48, .16) 82%);
|
|
|
+}
|
|
|
+.equipment-section__item--active .equipment-section__item-head,
|
|
|
+.equipment-section__item--collapsible.equipment-section__item--active[open] .equipment-section__item-head {
|
|
|
+ border-color: color-mix(in srgb, var(--detail-accent) 26%, transparent);
|
|
|
+ background: color-mix(in srgb, var(--detail-accent) 6%, transparent);
|
|
|
+}
|
|
|
+.equipment-section__active-badge {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 0 calc(6 * var(--u, 1px));
|
|
|
+ border: 1px solid var(--detail-accent);
|
|
|
+ border-radius: 999px;
|
|
|
+ background: color-mix(in srgb, var(--detail-accent) 24%, transparent);
|
|
|
+ color: color-mix(in srgb, var(--detail-accent) 55%, white);
|
|
|
+ font-size: var(--fs-caption, 10.5px);
|
|
|
+ line-height: calc(16 * var(--u, 1px));
|
|
|
+ letter-spacing: .04em;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
.equipment-section__item-head {
|
|
|
display: grid;
|
|
|
gap: 1px;
|
|
|
@@ -822,4 +1166,57 @@ function isPlaceholder(text) {
|
|
|
.equipment-image-viewer::backdrop { background: rgba(1, 5, 20, .8); backdrop-filter: blur(6px); }
|
|
|
.equipment-image-viewer img { display: block; width: 100%; height: auto; max-height: 78vh; object-fit: contain; }
|
|
|
.equipment-image-viewer__close { display: block; margin-left: auto; margin-bottom: 8px; cursor: pointer; }
|
|
|
+/* 音频播放条:贴播放键上方渐入的小浮层,不遮罩、不虚化整屏 UI。 */
|
|
|
+.collection-audio-player {
|
|
|
+ position: fixed;
|
|
|
+ z-index: calc(var(--z-columns, 30) + 5);
|
|
|
+ width: min(264px, 88vw);
|
|
|
+ padding: calc(6 * var(--u, 1px)) calc(8 * var(--u, 1px)) calc(8 * var(--u, 1px));
|
|
|
+ border: 1px solid var(--cyan, #01abea);
|
|
|
+ border-radius: calc(8 * var(--u, 1px));
|
|
|
+ background: rgba(4, 20, 38, .97);
|
|
|
+ box-shadow: 0 8px 22px rgba(0, 0, 0, .45);
|
|
|
+ color: var(--text, #e5f4ff);
|
|
|
+ transform: translate(-50%, calc(-100% - 8px));
|
|
|
+ animation: collection-audio-pop-above .18s ease-out;
|
|
|
+}
|
|
|
+.collection-audio-player.is-below {
|
|
|
+ transform: translate(-50%, 8px);
|
|
|
+ animation-name: collection-audio-pop-below;
|
|
|
+}
|
|
|
+@keyframes collection-audio-pop-above {
|
|
|
+ from { opacity: 0; transform: translate(-50%, calc(-100% - 2px)); }
|
|
|
+}
|
|
|
+@keyframes collection-audio-pop-below {
|
|
|
+ from { opacity: 0; transform: translate(-50%, 2px); }
|
|
|
+}
|
|
|
+.collection-audio-player__head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: calc(6 * var(--u, 1px));
|
|
|
+ margin-bottom: calc(4 * var(--u, 1px));
|
|
|
+}
|
|
|
+.collection-audio-player__label {
|
|
|
+ min-width: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ color: #a6d8ff;
|
|
|
+ font-size: var(--fs-caption, 10.5px);
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.collection-audio-player__close {
|
|
|
+ flex: none;
|
|
|
+ width: calc(20 * var(--u, 1px));
|
|
|
+ height: calc(20 * var(--u, 1px));
|
|
|
+ padding: 0;
|
|
|
+ border: 1px solid rgba(1, 171, 234, .5);
|
|
|
+ border-radius: 999px;
|
|
|
+ background: rgba(1, 91, 150, .26);
|
|
|
+ color: inherit;
|
|
|
+ font-size: calc(12 * var(--u, 1px));
|
|
|
+ line-height: 1;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.collection-audio-player__audio { display: block; width: 100%; max-width: 100%; height: calc(32 * var(--u, 1px)); }
|
|
|
</style>
|