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

fix(cockpit): 恢复设备详情多标签状态与即时提示点

视频监控开发线以 11-06 旧基线继续,覆盖了设备详情面板的多项已验收改动。
本次按当前代码重新接回并修复,不覆盖视频监控新增能力:

- 状态栏恢复为全部标签并列(在线/离线 + 预警/报警/工单处置),独立设备标签不上栏;
  主地图与管线详情共用 DeviceStateTags,管线面板同步收窄到 340u/上限 380px。
- 指标提示点改用 MetricStateHint 即时浮层(无原生 title 等待、无「?」光标),
  正常值不画点,仅保留预警与报警;刷新时收起已过时的浮层。
- 同设备后台刷新保留页签/分页/展开/滚动,并按可见记录身份锚定;换设备才清零。
- 点位摘要后台 loading 不再卸载已加载内容;旧点位载荷不跨点显示。
- 管线附近管道清单后台更新保留页码,换对象或邻域范围才回首页。
- 修复被误改的环境变量键 VITE_COCKPIT_BASEMAP_SOURCE(曾混入组件名导致底图配置失效)。
ChenJZ пре 10 часа
родитељ
комит
e67b513387

+ 53 - 20
src/modules/cockpit/CockpitShell.vue

@@ -475,7 +475,7 @@
             -->
             <div v-if="detailOpen && !introVisible && selectionVisibleInMode && anchorLayout.visible && !fixedLocationReason"
                 class="cockpit-shell__anchor-lead" data-anchor-lead :style="anchorLeadStyle" aria-hidden="true"></div>
-            <aside v-if="detailOpen && !introVisible && selectionVisibleInMode" ref="detailRef" class="cockpit-shell__detail"
+            <aside v-if="detailOpen && !introVisible" v-show="selectionVisibleInMode" ref="detailRef" class="cockpit-shell__detail"
                 :class="{ 'is-anchored': !fixedLocationReason, 'is-fixed-left': facilityFixedLeft }"
                 :style="detailStyle"
                 :data-anchor-state="fixedLocationReason ? 'fixed' : anchorLayout.visible ? 'visible' : 'hidden'"
@@ -1233,12 +1233,18 @@ const pipeEnhancementPlan = computed(() => {
         deviceCoverage: enhancementDevicesStore.source,
         relationCoverage: facilityRelationsState.value }
 })
-watch(pipeEnhancementSelection, () => enhancementDevicesStore.closeDetail())
+watch(() => JSON.stringify(pipeEnhancementSelection.value), () => enhancementDevicesStore.closeDetail())
+watch(pipeEnhancementRadius, () => enhancementDevicesStore.closeDetail())
+// Explicit filters/mode changes are a new scope; do not retain the old scope's local devices.
+watch(() => JSON.stringify([mode.value, scope.value, industry.value, deviceType.value, pointStatus.value]),
+    () => enhancementDevicesStore.clear(), { flush: 'sync' })
 // 首次打开详情视图时取一次真实关联(只读、免认证);失败按空索引降级,绝不阻塞场景。
 watch(() => pipeEnhancementPlan.value.visible, (visible) => {
     if (visible && !facilityRelationsState.value.loaded) void loadFacilityRelations()
 })
-watch(() => pipeEnhancementPlan.value.deviceList, (list) => enhancementDevicesStore.reconcile(list))
+watch(() => pipeEnhancementPlan.value.deviceList, (list) => {
+    if (!mapStore.refreshing) enhancementDevicesStore.reconcile(list)
+})
 watch(() => mapStore.refreshing, (refreshing) => {
     if (refreshing) enhancementDevicesStore.invalidate()
     else if (pipeEnhancementPlan.value.visible) void enhancementDevicesStore.load(mapStore.summary)
@@ -1274,9 +1280,15 @@ function closePipeEnhancement() {
     pipeEnhancementStore.clear()
     enhancementDevicesStore.clear()
 }
-const selectionVisibleInMode = computed(() => facilityDetailOpen.value
-    ? ['overview', 'infrastructure'].includes(mode.value)
-    : modeLayerPlan.value.selection.visible === true)
+// Only a background refresh of the same viewing context may retain its visibility/anchor.
+const detailViewKey = computed(() => JSON.stringify([mode.value, scope.value, industry.value, deviceType.value, pointStatus.value,
+    selection.value.level, selection.value.pointCode, selection.value.deviceCode, entityDetailStore.identity]))
+const detailRefreshState = ref(null)
+const keepDetailDuringRefresh = computed(() => detailRefreshState.value?.key === detailViewKey.value)
+watch(detailViewKey, () => { detailRefreshState.value = null }, { flush: 'sync' })
+const selectionVisibleInMode = computed(() => keepDetailDuringRefresh.value ? detailRefreshState.value.visible
+    : facilityDetailOpen.value ? ['overview', 'infrastructure'].includes(mode.value)
+        : modeLayerPlan.value.selection.visible === true)
 
 /**
  * 快照变化 → 先撤下旧设备图层(规格:刷新或快照失效时先撤下,免得旧快照的设备被误读成新范围的)。
@@ -1306,24 +1318,36 @@ function syncDeviceLayerForSnapshot(batchUpdatedAt) {
  *   · **完整加载仍找不到**:关闭详情并在顶栏说明原因。
  * 查找失败(failed 相位)**保持待核对**:已取数据与顶栏关闭入口都在,允许再次刷新重试。
  */
-function reconcileSnapshotSelection() {
-    const pointCode = selection.value.pointCode
-    if (!pointCode || selection.value.level === 'none') {
+async function reconcileSnapshotSelection() {
+    // A reload can pass through an empty/settled state before the current chain completes.
+    if (mapStore.refreshing) return
+    const { pointCode, deviceCode, level } = selection.value
+    if ((!pointCode && !deviceCode) || level === 'none') {
         snapshotPending.value = false
+        detailRefreshState.value = null
         return
     }
     const phase = mapStore.summary.phase
-    // 失败与失效都是「查找失败」:保持待核对、已取数据与顶栏关闭入口,允许再次刷新重试
-    // (规格 14:86 原句)——不得对已失效的快照做「找到即重载」。
     if (phase === MAP_SUMMARY_PHASE.FAILED || phase === MAP_SUMMARY_PHASE.EXPIRED) return
-    if (mapStore.hasSnapshotObject('monitoring-point', pointCode)) {
+    const kind = pointCode ? 'monitoring-point' : 'unassociated-equipment'
+    const code = pointCode || deviceCode
+    if (mapStore.hasSnapshotObject(kind, code)) {
         snapshotPending.value = false
-        void detailStore.reloadPointDetail(pointCode)
+        const retained = detailRefreshState.value
+        const results = await Promise.all([
+            pointCode ? detailStore.reloadPointDetail(pointCode) : null,
+            deviceCode ? detailStore.loadEquipmentAggregate(deviceCode) : null,
+        ])
+        // Let the point-detail watcher restore its device layer before releasing the old anchor.
+        await nextTick()
+        if (detailRefreshState.value === retained && !mapStore.refreshing
+            && results.every((result) => !result || (!result.retained && !['failed', 'unavailable'].includes(result.state)))) detailRefreshState.value = null
         return
     }
     if (phase === MAP_SUMMARY_PHASE.COMPLETE || phase === MAP_SUMMARY_PHASE.EMPTY) {
         snapshotPending.value = false
-        snapshotAbsentNote.value = `所选点位 ${pointCode} 已不在当前快照范围,详情已关闭`
+        detailRefreshState.value = null
+        snapshotAbsentNote.value = `所选${pointCode ? '点位' : '设备'} ${code} 已不在当前快照范围,详情已关闭`
         detailStore.closeDetail()
     }
 }
@@ -1391,9 +1415,10 @@ const anchorOrigin = ref({ left: 0, top: 0 })
 let lastAnchorKey = ''
 
 /** 摘要锚向监测点;设备详情锚向所选设备;业务归属编码保持不变。 */
-const detailTarget = computed(() => facilityDetailOpen.value
-    ? resolveFacilityDetailAnchor(facilityPointStore.selectedPoint)
-    : resolveDetailAnchor({ selection: selection.value, objects: mapStore.summary.objects, devices: deviceRows.value }))
+const detailTarget = computed(() => keepDetailDuringRefresh.value && detailRefreshState.value.target
+    ? detailRefreshState.value.target
+    : facilityDetailOpen.value ? resolveFacilityDetailAnchor(facilityPointStore.selectedPoint)
+        : resolveDetailAnchor({ selection: selection.value, objects: mapStore.summary.objects, devices: deviceRows.value }))
 
 /** 详情卡上要显示的对象编码(顶栏暂隐入口读它)。 */
 const selectedDetailCode = computed(() => {
@@ -1462,6 +1487,9 @@ function measureAnchor() {
 function refreshAnchor(options = {}) {
     if (!detailOpen.value || introVisible.value || typeof document === 'undefined') return
     if (document.hidden && options.force !== true) return
+    const retainedTarget = keepDetailDuringRefresh.value && detailRefreshState.value.target
+    // The old marker is temporarily withdrawn, not a reason to hide/move a panel being read.
+    if (retainedTarget && !hasMapMarker(retainedTarget.kind, retainedTarget.code, retainedTarget.pointCode, retainedTarget.key)) return
     // 维护者反馈:限速 10Hz 后"点位摘要/设备详情面板不跟手"。锚定必须**逐帧跟随**,
     // 与 DOM 图标同一条纪律;重复写 DOM 由下面的 `lastAnchorKey` 判定挡住,镜头不动时不写。
 
@@ -2146,11 +2174,16 @@ onMounted(() => {
         //   ② 快照**失效**:expired 相位不改 batchUpdatedAt,必须单独触发撤层;
         //   ③ 链条 **settled**:对挂起中的所选点位核对收口(找到重载 / 完整找不到关闭 / 失败保持待核对)。
         watch(() => mapStore.refreshing, (on) => {
-            if (!on) return
+            if (!on) {
+                if (snapshotPending.value && mapStore.settled) void reconcileSnapshotSelection()
+                else if (!snapshotPending.value) detailRefreshState.value = null
+                return
+            }
+            detailRefreshState.value = { key: detailViewKey.value, visible: selectionVisibleInMode.value, target: detailTarget.value }
             deviceLayerStore.suspendForSnapshot()
             snapshotAbsentNote.value = ''
-            snapshotPending.value = Boolean(selection.value.pointCode)
-        }, { deep: false }),
+            snapshotPending.value = Boolean(selection.value.pointCode || selection.value.deviceCode)
+        }, { deep: false, flush: 'sync' }),
         watch(() => mapStore.summary.phase, (phase) => {
             if (phase !== MAP_SUMMARY_PHASE.EXPIRED || !selection.value.pointCode) return
             deviceLayerStore.suspendForSnapshot()

+ 33 - 0
src/modules/cockpit/components/DeviceStateTags.vue

@@ -0,0 +1,33 @@
+<template>
+    <div class="device-state-tags" role="group" aria-label="设备状态标签" data-device-state-tags :data-device-code="deviceCode">
+        <StatusTag v-for="tag in tags" :key="tag.key" :tone="tag.tone" :shape="tag.shape" :label="tag.label"
+            :icon="tag.icon" data-device-state-tag :data-state-key="tag.key" />
+        <span v-if="!tags.length" class="device-state-tags__empty">—</span>
+    </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useDeviceStatusStore } from '../stores/deviceStatusStore.js'
+import { deviceDetailTags } from '../domain/deviceDetailTags.js'
+import StatusTag from './StatusTag.vue'
+
+const props = defineProps({
+    deviceCode: { type: String, default: '' },
+    fallbackStatus: { type: String, default: '' },
+    aggregate: { type: Object, default: null },
+})
+const statusStore = useDeviceStatusStore()
+const tags = computed(() => deviceDetailTags(props.deviceCode, statusStore.pools, props.fallbackStatus, props.aggregate))
+</script>
+
+<style scoped>
+.device-state-tags {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    min-width: 0;
+    gap: calc(5 * var(--u, 1px));
+}
+.device-state-tags__empty { color: var(--muted, #5680b0); }
+</style>

+ 56 - 17
src/modules/cockpit/components/EquipmentDetailSections.vue

@@ -27,7 +27,7 @@
   (`SOURCE_SECTION_FAILED`),那时界面必须说「轨迹没取到」而不是"没有动作记录"。
 -->
 <template>
-    <div class="equipment-sections" data-equipment-sections :data-device-code="deviceCode">
+    <div ref="sectionsRoot" class="equipment-sections" data-equipment-sections :data-device-code="deviceCode">
         <!--
             票 46 返工:标签页条。计数挂在页签上(预警/报警/工单才有;
             维护者要「避免长滚动列表查找困难」——点之前就知道每页后面有多少条)。
@@ -53,6 +53,7 @@
             当前页签的面板(档案页含两块:档案 + 设备图片,所以 tabpanel 挂在**包装层**
             而不是每块 section 上 —— 一个页签对应一个 tabpanel 才是正确的可访问语义)。
         -->
+        <p v-if="aggregate?.retained" class="equipment-section__hint" data-detail-refresh-retained>本次详情更新未完成,保留上次已加载内容。</p>
         <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>
             <VideoMonitorEntry v-if="activeTab === 'metrics'" :device-code="deviceCode" :monitor-source="monitorSource" />
             <section
@@ -153,14 +154,14 @@
                                                 <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" />
+                                                    <MetricStateHint v-if="cell.assessment?.hint" :color="cell.assessment.hint.color"
+                                                        :label="cell.assessment.hint.label" :refresh-key="aggregate" />
                                                 </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" />
+                                                <MetricStateHint v-if="cell.assessment?.hint" :color="cell.assessment.hint.color"
+                                                    :label="cell.assessment.hint.label" :refresh-key="aggregate" />
                                             </span>
                                         </td>
                                     </template>
@@ -370,7 +371,7 @@
 </template>
 
 <script setup>
-import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
 import { resolveDeviceImageUrl } from '../services/deviceImage.js'
 import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
 import {
@@ -401,6 +402,7 @@ import VideoMonitorEntry from './VideoMonitorEntry.vue'
 import SectionStateTag from './SectionStateTag.vue'
 import WorkOrderTimeline from './WorkOrderTimeline.vue'
 import PagerNav from './PagerNav.vue'
+import MetricStateHint from './MetricStateHint.vue'
 
 const props = defineProps({
     /** `stores/detailStore.js` 的 `sections.equipmentAggregate`。 */
@@ -410,6 +412,7 @@ const props = defineProps({
     monitorSource: { type: String, default: 'detail' },
 })
 
+const sectionsRoot = ref(null)
 const panels = computed(() => equipmentPanels(props.aggregate))
 
 // ---- 票 46 返工第 1 条:标签页切换(弃用六块竖排的长滚动)----
@@ -422,6 +425,49 @@ const tabs = computed(() =>
 /** 当前页签(默认「设备档案」;换设备复位,不把 A 的页签停在 B 上)。 */
 const activeTab = ref(DEFAULT_EQUIPMENT_TAB)
 
+// 同设备刷新只更新内容;DOM 重排后保留内部及外层详情的滚动位置与可见条目。
+let scrollRevision = 0
+watch([() => props.aggregate, () => props.deviceCode], (_current, previous) => {
+    const revision = ++scrollRevision
+    const device = props.deviceCode
+    const sameDevice = previous[1] === device
+    const tab = activeTab.value
+    const positions = []
+    const items = Array.from(sectionsRoot.value?.querySelectorAll?.('[data-collection-record-key], [data-history-item]') || [])
+    for (let element = sectionsRoot.value; element; element = element.parentElement) {
+        if (typeof document !== 'undefined' && (element === document.body || element === document.documentElement)) break
+        if (typeof element.scrollTop === 'number' && (element.scrollTop || element.scrollHeight > element.clientHeight)) {
+            const rect = element.getBoundingClientRect?.()
+            const anchor = rect && items.find((item) => {
+                const bounds = item.getBoundingClientRect()
+                return bounds.bottom > rect.top && bounds.top < rect.bottom
+            })
+            const key = anchor && (anchor.getAttribute('data-collection-record-key') || anchor.getAttribute('data-event') || anchor.getAttribute('data-work-order'))
+            positions.push({ element, top: element.scrollTop, left: element.scrollLeft,
+                key: key && key !== DETAIL_PLACEHOLDER ? key : '',
+                offset: anchor ? anchor.getBoundingClientRect().top - rect.top : 0,
+            })
+        }
+    }
+    nextTick(() => {
+        if (revision !== scrollRevision || device !== props.deviceCode || (sameDevice && tab !== activeTab.value)) return
+        if (!sameDevice) {
+            for (const { element } of positions) { element.scrollTop = 0; element.scrollLeft = 0 }
+            return
+        }
+        const currentItems = Array.from(sectionsRoot.value?.querySelectorAll?.('[data-collection-record-key], [data-history-item]') || [])
+        for (const { element, top, left, key, offset } of positions) {
+            if (element.isConnected === false) continue
+            const anchor = key && currentItems.find((item) =>
+                [item.getAttribute('data-collection-record-key'), item.getAttribute('data-event'), item.getAttribute('data-work-order')].includes(key))
+            // 新记录插到前面时仍跟住原来正在看的条目;条目已移除才退回像素位置。
+            element.scrollTop = anchor ? element.scrollTop + anchor.getBoundingClientRect().top - element.getBoundingClientRect().top - offset : top
+            element.scrollLeft = left
+        }
+    })
+}, { flush: 'pre' })
+onBeforeUnmount(() => { scrollRevision += 1 })
+
 /** 当前页签要渲染的面板(保留渲染计划自己的次序;图片随档案页出现)。 */
 const activePanels = computed(() => panelsForTab(panels.value, activeTab.value))
 
@@ -450,6 +496,10 @@ const recordTables = computed(() => Object.fromEntries(
 function recordTableOf(panel) {
     return recordTables.value[panel.name]
 }
+function recordKey(row) {
+    if (!row.timeKey) return undefined
+    return JSON.stringify([row.timeKey, row.cells.filter((cell) => cell.isReading).map((cell) => [cell.key, cell.reading?.label])])
+}
 const audioPlayer = ref(null)
 const activeAudioPath = ref('')
 const activeAudioLabel = ref('音频记录')
@@ -774,17 +824,6 @@ function isPlaceholder(text) {
 .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));

+ 113 - 0
src/modules/cockpit/components/MetricStateHint.vue

@@ -0,0 +1,113 @@
+<template>
+    <span class="metric-state-hint" :style="{ color }" :aria-label="label" role="img" tabindex="0"
+        data-metric-state-hint @mouseenter="show($event)" @mouseleave="hovered = false"
+        @focus="show($event, true)" @blur="focused = false" @keydown.esc.stop="close">
+        <i class="metric-state-hint__dot" aria-hidden="true"></i>
+    </span>
+    <Teleport to="body">
+        <span v-if="visible" class="metric-state-hint__tooltip" :class="{ 'is-below': placement.below }"
+            :style="{ ...presentation, left: placement.left + 'px', top: placement.top + 'px', borderColor: presentation.borderColor || color }"
+            role="tooltip" aria-hidden="true" data-metric-state-tooltip>{{ label }}</span>
+    </Teleport>
+</template>
+
+<script setup>
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+
+const props = defineProps({
+    color: { type: String, required: true },
+    label: { type: String, required: true },
+    refreshKey: { type: Object, default: null },
+})
+const hovered = ref(false)
+const focused = ref(false)
+const visible = computed(() => hovered.value || focused.value)
+const placement = ref({ left: 0, top: 0, below: false })
+const presentation = ref({})
+
+// 与分区状态点一样即时提示;Teleport 避免被表格/详情滚动容器裁掉。
+function show(event, focus = false) {
+    const rect = event.currentTarget.getBoundingClientRect()
+    const style = window.getComputedStyle?.(event.currentTarget)
+    // 浮层移到body后不继承驾驶舱token,保留触发点已解析的配色与字号。
+    presentation.value = style ? {
+        color: style.getPropertyValue('--text') || '#d7ecff',
+        background: style.getPropertyValue('--bg-0') || '#010514',
+        borderColor: style.color,
+        fontFamily: style.fontFamily,
+        fontSize: style.fontSize,
+        '--u': style.getPropertyValue('--u'),
+    } : {}
+    const width = Math.min(240, window.innerWidth - 16)
+    const below = rect.top < 64
+    placement.value = {
+        left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)),
+        top: below ? rect.bottom + 6 : rect.top - 6,
+        below,
+    }
+    if (focus) focused.value = true
+    else hovered.value = true
+}
+function close() {
+    hovered.value = false
+    focused.value = false
+}
+function detach() {
+    if (typeof window !== 'undefined') {
+        window.removeEventListener('scroll', close, true)
+        window.removeEventListener('resize', close)
+    }
+}
+watch(visible, (open) => {
+    detach()
+    if (open && typeof window !== 'undefined') {
+        window.addEventListener('scroll', close, true)
+        window.addEventListener('resize', close)
+    }
+}, { flush: 'sync' })
+// 刷新即使没有滚动,也可能因前面的行增高而移动触发点;收起旧位置的提示。
+watch([() => props.refreshKey, () => props.color, () => props.label], close, { flush: 'sync' })
+onBeforeUnmount(detach)
+</script>
+
+<style scoped>
+.metric-state-hint {
+    display: inline-flex;
+    flex: none;
+    align-self: center;
+    align-items: center;
+    justify-content: center;
+    width: 18px;
+    height: 18px;
+    cursor: default;
+    font-family: var(--font, sans-serif);
+    font-size: var(--fs-caption, 11.5px);
+}
+.metric-state-hint__dot {
+    width: 7px;
+    height: 7px;
+    border-radius: 50%;
+    background: currentColor;
+    box-shadow: 0 0 6px currentColor;
+}
+.metric-state-hint:focus-visible { outline: 1px solid currentColor; outline-offset: 2px; border-radius: 50%; }
+.metric-state-hint__tooltip {
+    position: fixed;
+    z-index: 100;
+    box-sizing: border-box;
+    width: max-content;
+    max-width: min(240px, calc(100vw - 16px));
+    padding: 6px 8px;
+    border: 1px solid;
+    border-radius: calc(8 * var(--u, 1px));
+    background: var(--bg-0, #010514);
+    color: var(--text, #d7ecff);
+    font-family: var(--font, sans-serif);
+    font-size: var(--fs-caption, 11.5px);
+    white-space: normal;
+    overflow-wrap: anywhere;
+    transform: translateY(-100%);
+    pointer-events: none;
+}
+.metric-state-hint__tooltip.is-below { transform: none; }
+</style>

+ 16 - 20
src/modules/cockpit/components/PipeEnhancement.vue

@@ -28,20 +28,13 @@
                     <button type="button" data-enhancement-device-close @click="$emit('device-close')">关闭详情</button>
                 </header>
                 <div class="pipe-enhancement__device-status-card" data-enhancement-device-status-card>
-                    <StatusTag
-                        v-if="selectedDeviceStatus"
-                        :tone="selectedDeviceStatus.tone"
-                        :shape="selectedDeviceStatus.shape"
-                        :label="selectedDeviceStatus.label"
-                        :icon="selectedDeviceStatus.icon"
-                        data-enhancement-device-status
-                    />
-                    <span v-else class="pipe-enhancement__device-status-empty">—</span>
+                    <DeviceStateTags data-enhancement-device-status :device-code="deviceState.selected.code"
+                        :fallback-status="deviceState.selected.status || ''" :aggregate="deviceState.aggregate" />
                     <span class="pipe-enhancement__device-status-label">设备状态</span>
                 </div>
                 <p>{{ deviceState.selected.placementText }}</p>
                 <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
-                <p v-if="deviceState.loadingDetail">设备详情加载中</p>
+                <p v-if="deviceState.loadingDetail && !deviceState.aggregate?.data">设备详情加载中</p>
                 <template v-else-if="!deviceState.aggregate?.data"><p>设备详情暂不可用</p><button type="button" @click="$emit('device-retry')">重新加载</button></template>
                 <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" monitor-source="enhancement" />
             </section>
@@ -93,9 +86,8 @@
 <script setup>
 import { computed, nextTick, onBeforeUnmount, watch, ref } from 'vue'
 import { mountPipeEnhancement } from '../services/threePipeEnhancement.js'
-import { deviceStatusView as deviceVisualOf } from '../domain/detailFlow.js'
 import EquipmentDetailSections from './EquipmentDetailSections.vue'
-import StatusTag from './StatusTag.vue'
+import DeviceStateTags from './DeviceStateTags.vue'
 
 const props = defineProps({
     plan: { type: Object, required: true },
@@ -103,10 +95,6 @@ const props = defineProps({
 })
 const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close', 'device-retry', 'devices-reload', 'object-pick'])
 const viewport = ref(null)
-const selectedDeviceStatus = computed(() => {
-    const device = props.deviceState?.selected
-    return device?.status ? deviceVisualOf(device.status, device.statusLabel || '') : null
-})
 const localPipeStates = computed(() => [...new Map((props.plan.objects || [])
     .filter((object) => object.sourceKind === 'pipe').map((object) => [object.code, object])).values()])
 /**
@@ -121,8 +109,9 @@ const pagedPipeStates = computed(() => {
     const start = (page - 1) * PIPE_PAGE_SIZE
     return localPipeStates.value.slice(start, start + PIPE_PAGE_SIZE)
 })
-// 换选管段/邻域对象变化时回到第 1 页,避免停在一个已不存在的页码上。
-watch(localPipeStates, () => { pipePage.value = 1 })
+// 同一管段的后台数据更新保留页码;换对象/邻域范围才回首页。
+watch(() => JSON.stringify([props.plan.object?.sourceKind, props.plan.object?.code, props.plan.radiusM]), () => { pipePage.value = 1 })
+watch(pipePageCount, (count) => { pipePage.value = Math.min(pipePage.value, count) })
 let viewer = null
 let mountGeneration = 0
 const open = ref(false)
@@ -233,7 +222,8 @@ onBeforeUnmount(() => {
     z-index: 6;
     left: 12px;
     top: 12px;
-    width: min(clamp(460px, calc(520 * var(--u, 1px)), 620px), calc(100% - 24px));
+    /* 管线详情用紧凑宽度,不再套主地图详情的460px下限。 */
+    width: min(calc(340 * var(--u, 1px)), 380px, calc(100% - 24px));
     max-height: calc(100% - 24px);
     overflow: auto;
     box-sizing: border-box;
@@ -247,6 +237,7 @@ onBeforeUnmount(() => {
 .pipe-enhancement__device-detail header { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-bottom: 8px; }
 .pipe-enhancement__device-status-card {
     display: flex;
+    flex-wrap: wrap;
     align-items: center;
     gap: 10px;
     margin: 6px 0 10px;
@@ -256,10 +247,15 @@ onBeforeUnmount(() => {
     background: linear-gradient(135deg, rgba(4, 42, 76, .72), rgba(5, 23, 47, .88));
 }
 .pipe-enhancement__device-status-label { color: #7ba4c3; }
-.pipe-enhancement__device-status-empty { color: #a7c4d9; }
 .pipe-enhancement__device-detail button { color: #e4f4ff; background: #08213a; border: 1px solid #21618a; padding: 5px 8px; cursor: pointer;
     border-radius: calc(8 * var(--u, 1px));}
 .pipe-enhancement__device-detail :deep(.equipment-sections) { max-height: 60vh; overflow: auto; }
+/* 紧凑详情下缩短时间/播放列,保留横向读数空间;字段名仍禁止竖排。 */
+.pipe-enhancement__device-detail :deep(.collection-table__time) { width: calc(76 * var(--u, 1px)); }
+.pipe-enhancement__device-detail :deep(.collection-table__audio-col) { width: calc(52 * var(--u, 1px)); }
+.pipe-enhancement__device-detail :deep(.collection-table__value) { padding-left: calc(2 * var(--u, 1px)); }
+.pipe-enhancement__device-detail :deep(.collection-table__reading) { column-gap: calc(8 * var(--u, 1px)); }
+.pipe-enhancement__device-detail :deep(.collection-table__heading) { font-size: calc(9 * var(--u, 1px)); }
 .pipe-enhancement__pager { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 6px; font-size: 11px; color: #a7c4d9; }
 .pipe-enhancement__pager button { padding: 3px 8px; }
 .pipe-enhancement__pager button:disabled { opacity: .4; cursor: default; }

+ 8 - 14
src/modules/cockpit/components/PointDetailCard.vue

@@ -45,7 +45,8 @@
 
         <!-- ---------- 第一级:点位摘要 + 设备卡片 ---------- -->
         <div v-if="level !== LEVEL.EQUIPMENT" class="point-detail__body" data-detail-point>
-            <p v-if="pointLoading" class="point-detail__hint" data-detail-loading>点位摘要加载中…</p>
+            <SectionStateTag v-if="pointSection.retained" name="pointDetail" label="点位摘要" :section="pointSection" />
+            <p v-if="pointLoading && !summary" class="point-detail__hint" data-detail-loading>点位摘要加载中…</p>
 
             <template v-else-if="summary">
                 <!--
@@ -244,20 +245,12 @@
                 </div>
             </dl>
 
-            <!-- 票 115 优化:设备状态沿用点位摘要的状态卡样式,单独成行突出显示。 -->
-            <div v-if="headerStatus" class="point-detail__status-card point-detail__status-card--device" data-device-status-card>
-                <StatusTag
-                    data-device-status
-                    :tone="headerStatus.tone"
-                    :shape="headerStatus.shape"
-                    :label="headerStatus.label"
-                    :icon="headerStatus.icon"
-                />
+            <!-- 全部设备标签并列展示,独立设备分类不进入状态栏。 -->
+            <div class="point-detail__status-card point-detail__status-card--device" data-device-status-card>
+                <DeviceStateTags data-device-status :device-code="selection.deviceCode"
+                    :fallback-status="headerStatus?.key || ''" :aggregate="aggregateSection" />
                 <span class="point-detail__status-reason is-muted" data-device-status-label>设备状态</span>
             </div>
-            <div v-else class="point-detail__status-card point-detail__status-card--device" data-device-status-card>
-                <span class="is-muted">—</span>
-            </div>
 
             <!--
                 票 11:六个分区(档案 / 指标 / 预警 / 报警 / 工单 / 图片)。每块各自成败、各自渲染,
@@ -322,6 +315,7 @@ import EquipmentDetailSections from './EquipmentDetailSections.vue'
 import PagerNav from './PagerNav.vue'
 import SectionStateTag from './SectionStateTag.vue'
 import StatusTag from './StatusTag.vue'
+import DeviceStateTags from './DeviceStateTags.vue'
 
 const LEVEL = DETAIL_LEVEL
 const props = defineProps({
@@ -345,7 +339,7 @@ const deviceEffectsAbnormal = computed(() => (deviceEffects.value ? deviceEffect
 
 const level = computed(() => selection.value.level)
 const pointSection = computed(() => sections.value.pointDetail)
-const pointData = computed(() => (pointSection.value && pointSection.value.data) || null)
+const pointData = computed(() => pointSection.value?.data?.code === selection.value.pointCode ? pointSection.value.data : null)
 const summary = computed(() => (pointData.value ? pointSummary(pointData.value) : null))
 /** 票 46:设备计数 KPI(统计整段缺失时回 null → KPI 条不出现,不画一排 0)。 */
 const deviceCountsView = computed(() => (pointData.value ? pointDeviceCountsView(pointData.value) : null))

+ 25 - 0
src/modules/cockpit/domain/detailRefresh.js

@@ -0,0 +1,25 @@
+/** Keep only identity-matched values when this refresh explicitly fails; EMPTY is authoritative. */
+export function retainEquipmentRefresh(previous, next, deviceCode) {
+    if (!previous?.data || previous.data.deviceCode !== deviceCode) return next
+    const failed = (value) => ['failed', 'unavailable'].includes(value?.state)
+    if (!next?.data) return failed(next)
+        ? { ...next, state: 'stale', data: previous.data, retained: true } : next
+    if (next.data.deviceCode !== deviceCode) return next
+    let retained = false
+    const retainSection = (old, current) => {
+        if (!failed(current) || old?.data == null || !['success', 'stale', 'empty'].includes(old.state)) return current
+        retained = true
+        return { ...current, state: 'stale', data: old.data, retained: true }
+    }
+    const oldSections = previous.data.sections || {}
+    const sections = { ...next.data.sections }
+    for (const [name, current] of Object.entries(sections)) {
+        sections[name] = retainSection(oldSections[name], current)
+    }
+    // The record feed is independent of legacy metrics; a healthy feed must still replace an old one.
+    if (sections.metrics && next.data.sections?.metrics?.collectionRecords) {
+        const collectionRecords = retainSection(oldSections.metrics?.collectionRecords, next.data.sections.metrics.collectionRecords)
+        sections.metrics = { ...sections.metrics, collectionRecords }
+    }
+    return retained ? { ...next, retained: true, data: { ...next.data, sections } } : next
+}

+ 33 - 0
src/modules/cockpit/domain/deviceDetailTags.js

@@ -0,0 +1,33 @@
+import { deviceStatusView } from './detailFlow.js'
+import { WORK_ORDER_STATES } from './workOrder.js'
+
+const BUSINESS_TAGS = ['WARNING', 'ALARM', 'WORK_ORDER']
+const normalize = (value) => String(value || '').trim().toUpperCase()
+
+/** 完整设备标签,不使用地图单一显示状态的优先级;独立设备不进入设备状态栏。 */
+export function deviceDetailTags(deviceCode, pools, fallbackStatus = '', aggregate = null) {
+    const code = String(deviceCode || '').trim()
+    if (!code) return []
+    const entry = pools?.available && Array.isArray(pools.devices)
+        ? pools.devices.find((row) => String(row?.deviceCode || '').trim() === code) : null
+    // 命中台账行时不让旧的单一状态覆盖当前的在线/离线证据。
+    const base = normalize(entry ? entry.base : fallbackStatus)
+    const tags = new Set(entry && Array.isArray(entry.tags) ? entry.tags.map(normalize) : [])
+    if (!entry && BUSINESS_TAGS.includes(normalize(fallbackStatus))) tags.add(normalize(fallbackStatus))
+    // 独立工单不一定在当前事件的workOrder摘要中;只补本设备明确未到终态的工单。
+    const data = aggregate?.data
+    const orders = data?.deviceCode === code ? data.sections?.workOrders : null
+    if (['success', 'stale'].includes(orders?.state) && Array.isArray(orders.data)
+        && orders.data.some((order) => order && (!order.deviceCode || order.deviceCode === code)
+            && WORK_ORDER_STATES.some((state) => state.code === order.stateCode && !state.terminal))) {
+        tags.add('WORK_ORDER')
+    }
+    const keys = []
+    if (base === 'NORMAL' || base === 'ONLINE') keys.push('NORMAL')
+    else if (base === 'OFFLINE') keys.push('OFFLINE')
+    for (const key of BUSINESS_TAGS) if (tags.has(key)) keys.push(key)
+    return (keys.length ? keys : ['UNKNOWN']).map((key) => {
+        const visual = deviceStatusView(key)
+        return key === 'NORMAL' ? { ...visual, label: '在线' } : visual
+    })
+}

+ 5 - 4
src/modules/cockpit/domain/equipmentDetailColors.js

@@ -7,7 +7,7 @@ const COLORS = {
     lv4: 'var(--lv4, #27d17a)',
 }
 
-/** 等级色与业务状态色分开:四级报警仍是报警,等级点可以是绿色。 */
+/** 事件清单的等级高亮色独立于指标状态色:四级报警依然是报警,指标点始终随状态着色。 */
 export function detailEventLevel(event) {
     const level = eventLevelFromBucket(event?.level, event?.levelColor)
     const family = String(event?.family || '').toUpperCase() === 'ALARM' ? '报警' : '预警'
@@ -40,15 +40,16 @@ export function collectionCellAssessment(cell) {
             label: '未判定:' + (UNKNOWN_REASONS[assessment.reason] || '当前规则无法给出结论'),
         }
         const state = status.toLowerCase()
+        // 正常值不加提示点:只保留预警/报警的点,避免整表都是点显得花哨。
         if (state === 'normal') return { state, color: COLORS.lv4, alert: null, label: '正常' }
         const level = detailEventLevel({ ...assessment, family: status })
         const color = state === 'alarm' ? COLORS.lv1 : COLORS.lv2
-        const alert = { state, color, dotColor: level.color, label: level.label }
-        return { state, color, alert, label: alert.label }
+        const alert = { state, color, dotColor: color, label: level.label }
+        return { state, color, alert, label: alert.label, hint: { color, label: alert.label } }
     }
     if (cell?.alarm === true) {
         const alert = { state: 'alarm', color: COLORS.lv1, dotColor: COLORS.lv1, label: '报警 · 未分级(采集记录未提供等级)' }
-        return { state: 'alarm', color: COLORS.lv1, alert, label: alert.label }
+        return { state: 'alarm', color: COLORS.lv1, alert, label: alert.label, hint: { color: COLORS.lv1, label: alert.label } }
     }
     return { state: 'unknown', color: 'var(--text-strong, #fff)', alert: null, label: '未判定:采集接口未提供判定结果' }
 }

+ 28 - 11
src/modules/cockpit/stores/detailStore.js

@@ -33,6 +33,7 @@ import { SECTION_STATE, overallState, section, sectionStateFromReason } from '..
 import { REQUEST_REASON } from '../domain/requestReason.js'
 import { MAP_SUMMARY_PHASE } from '../domain/mapSummary.js'
 import { equipmentDetailMatches, collectionRecordsSection } from '../domain/equipmentDetailView.js'
+import { retainEquipmentRefresh } from '../domain/detailRefresh.js'
 import {
     DETAIL_LEVEL,
     backToPoint as backToPointSelection,
@@ -173,6 +174,9 @@ export const useDetailStore = defineStore('detailStore', () => {
 
         pointToken += 1
         const mine = pointToken
+        if (sections.value.pointDetail.data?.code !== pointCode) {
+            sections.value = { ...sections.value, pointDetail: pending() }
+        }
         pointLoading.value = true
         const active = Object.keys(options).length ? { ...requestOptions, ...options } : requestOptions
 
@@ -180,20 +184,27 @@ export const useDetailStore = defineStore('detailStore', () => {
             const result = await fetchMonitoringPointDetail(pointCode, active)
             // 迟到的响应丢弃:A 的点位还没回来就点了 B,A 的数据不得盖在 B 的表头下。
             if (mine !== pointToken) return sections.value.pointDetail
-            setSection('pointDetail', result)
-            return result
+            return setPointResult(pointCode, result)
         } catch (error) {
             // 适配层本身不会抛(票 04 的失败一律收敛成 `{ok:false,reason}`),但转换层万一抛了
             // 也不能把详情卡在「加载中」或冒成页面级报错:收成一条失败分区,用户还能重试。
             if (mine !== pointToken) return sections.value.pointDetail
             const failed = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
-            setSection('pointDetail', failed)
-            return failed
+            return setPointResult(pointCode, failed)
         } finally {
             if (mine === pointToken) pointLoading.value = false
         }
     }
 
+    function setPointResult(pointCode, result) {
+        const current = sections.value.pointDetail
+        const retained = !result.data && [SECTION_STATE.FAILED, SECTION_STATE.UNAVAILABLE].includes(result.state)
+            && current.data?.code === pointCode
+        const next = retained ? { ...result, state: SECTION_STATE.STALE, data: current.data, retained: true } : result
+        setSection('pointDetail', next)
+        return next
+    }
+
     /**
      * 票 15:状态推送把**已打开**的点位详情里的状态就地换掉(验收第 4 条)。
      *
@@ -240,20 +251,25 @@ export const useDetailStore = defineStore('detailStore', () => {
             const result = await fetchEquipmentDetailAggregate(deviceCode, active)
             // 迟到的响应丢弃:A 还没回来就换了 B,A 的数据不得挂在新表头下。
             if (mine !== deviceToken) return sections.value.equipmentAggregate
-            setSection('equipmentAggregate', result)
-            return result
+            return setEquipmentResult(deviceCode, result)
         } catch (error) {
             // 与点位级同款兜底:适配层不抛,但转换层万一抛了要收成一条失败分区,
             // 不能把设备详情卡在「加载中」(用户还得能看见其它已成功的区块)。
             if (mine !== deviceToken) return sections.value.equipmentAggregate
             const failed = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
-            setSection('equipmentAggregate', failed)
-            return failed
+            return setEquipmentResult(deviceCode, failed)
         } finally {
             if (mine === deviceToken) deviceLoading.value = false
         }
     }
 
+    /** A failed background read does not erase an already displayed, identity-matched result. */
+    function setEquipmentResult(deviceCode, result) {
+        const next = retainEquipmentRefresh(sections.value.equipmentAggregate, result, deviceCode)
+        setSection('equipmentAggregate', next)
+        return next
+    }
+
     /**
      * 进入设备级:换层级 + **按需取数**(票 11 把设备详情聚合的加载收在这里)。
      *
@@ -273,7 +289,7 @@ export const useDetailStore = defineStore('detailStore', () => {
         const collection = collectionRecordsSection(aggregate.data?.sections?.metrics)
         const answered =
             equipmentDetailMatches(aggregate, deviceCode) &&
-            aggregate.loaded === true &&
+            aggregate.loaded === true && !aggregate.invalidated && !aggregate.retained &&
             ![SECTION_STATE.FAILED, SECTION_STATE.UNAVAILABLE].includes(collection.state) &&
             [SECTION_STATE.SUCCESS, SECTION_STATE.EMPTY, SECTION_STATE.STALE].includes(aggregate.state)
         // 同一台设备正在取数时再点一下不该叠第二个请求。
@@ -309,7 +325,8 @@ export const useDetailStore = defineStore('detailStore', () => {
          * **失败或不可用不算**:那两种情况要允许用户再点一次同一个点位重试,
          * 否则点同一个标记只会反复看到同一张失败卡片,永远没有恢复路径。
          */
-        const answered = sections.value.pointDetail.loaded === true && [SECTION_STATE.SUCCESS, SECTION_STATE.EMPTY, SECTION_STATE.STALE].includes(state)
+        const answered = sections.value.pointDetail.loaded === true && !sections.value.pointDetail.retained
+            && [SECTION_STATE.SUCCESS, SECTION_STATE.EMPTY, SECTION_STATE.STALE].includes(state)
         /** 同一点位正在取数时再点一下不该叠第二个请求。 */
         const inFlight = pointLoading.value && samePoint
 
@@ -382,7 +399,7 @@ export const useDetailStore = defineStore('detailStore', () => {
         if (!current || !current.data || current.data.deviceCode !== deviceCode) return false
         deviceToken += 1
         deviceLoading.value = false
-        sections.value = { ...sections.value, equipmentAggregate: { ...current, loaded: false } }
+        sections.value = { ...sections.value, equipmentAggregate: { ...current, invalidated: true } }
         return true
     }
 

+ 30 - 10
src/modules/cockpit/stores/enhancementDevicesStore.js

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
 import { ref } from 'vue'
 import { loadEnhancementDevices } from '../services/enhancementDevices.js'
 import { fetchEquipmentDetailAggregate } from '../services/detailApi.js'
+import { retainEquipmentRefresh } from '../domain/detailRefresh.js'
 
 /** Local viewer ownership: never overwrite the main map's point/device selection. */
 export const useEnhancementDevicesStore = defineStore('enhancementDevices', () => {
@@ -12,43 +13,62 @@ export const useEnhancementDevicesStore = defineStore('enhancementDevices', () =
     let sourceToken = 0
     let detailToken = 0
     let fingerprint = ''
+    let detailOptions = {}
     async function load(summary, options = {}) {
         const next = JSON.stringify([summary.batchUpdatedAt, summary.phase, summary.truncated, summary.objects])
         if (next === fingerprint && !options.force) return
         fingerprint = next
         const token = ++sourceToken
-        source.value = { rows: [], partial: true, loading: true }
-        closeDetail()
+        const retainedRows = source.value.rows.map((row) => ({ ...row, retained: true }))
+        const publish = (state, loading) => {
+            if (token !== sourceToken) return
+            const rows = loading || state.partial
+                ? [...new Map([...retainedRows, ...state.rows].map((row) => [row.code, row])).values()] : state.rows
+            source.value = { ...state, rows, loading }
+        }
+        source.value = { ...source.value, rows: retainedRows, partial: true, loading: true }
         const result = await loadEnhancementDevices(summary, { ...options, alive: () => token === sourceToken,
-            onProgress: (state) => { if (token === sourceToken) source.value = { ...state, loading: true } },
+            onProgress: (state) => publish(state, true),
         })
-        if (token === sourceToken) source.value = { ...result, loading: false }
+        publish(result, false)
+        if (token !== sourceToken || !selected.value) return
+        const current = result.rows.find((row) => row.code === selected.value.code)
+        if (current) await open({ ...selected.value, ...current }, { ...detailOptions, ...options })
     }
     async function open(device, options = {}) {
         if (!device?.code) return
+        detailOptions = { ...detailOptions, ...options }
         const token = ++detailToken
+        const sameDevice = selected.value?.code === device.code && aggregate.value?.data?.deviceCode === device.code
         selected.value = device
-        aggregate.value = null
+        if (!sameDevice) aggregate.value = null
         loadingDetail.value = true
         let result
-        try { result = await (options.fetchDetail || fetchEquipmentDetailAggregate)(device.code) }
+        try { result = await (detailOptions.fetchDetail || fetchEquipmentDetailAggregate)(device.code, detailOptions) }
         catch { result = { state: 'failed', data: null } }
         if (token !== detailToken) return
-        aggregate.value = result?.data?.deviceCode === device.code ? result : { state: 'failed', data: null }
+        const next = result?.data?.deviceCode === device.code ? result : { state: 'failed', reason: result?.reason || '', data: null }
+        aggregate.value = retainEquipmentRefresh(aggregate.value, next, device.code)
         loadingDetail.value = false
     }
     function closeDetail() {
         detailToken += 1
+        detailOptions = {}
         selected.value = null
         aggregate.value = null
         loadingDetail.value = false
     }
     function reconcile(deviceList) {
-        if (selected.value && !deviceList.some((device) => device.code === selected.value.code)) closeDetail()
+        if (!selected.value) return
+        const current = deviceList.find((device) => device.code === selected.value.code)
+        if (current) selected.value = current
+        // A progressive/failed read is not proof that this device left the scope.
+        else if (!source.value.loading && !source.value.partial) closeDetail()
     }
     function invalidate() {
-        clear()
-        source.value.loading = true
+        sourceToken += 1
+        fingerprint = ''
+        source.value = { ...source.value, partial: true, loading: true }
     }
     function clear() {
         sourceToken += 1