Kaynağa Gözat

feat(cockpit): 预警报警卡片状态中文化与终态过滤、等级语义色、报警编号上屏(票65)

ChenJZ 19 saat önce
ebeveyn
işleme
bc1a20aeb1

+ 24 - 12
src/modules/cockpit/components/EventListPane.vue

@@ -45,7 +45,7 @@
             未筛(note 为空)不占行 —— 放在状态文案之前,读者先知道这批数是哪个范围的。
         -->
         <p v-if="view.gap" class="event-list-pane__note" data-panel-gap>{{ view.gap }}</p>
-        <p v-if="view.scopeEcho && view.scopeEcho.note" class="event-list-pane__note" data-scope-echo>{{ view.scopeEcho.note }}</p>
+        <!-- 维护者 2026-10-30 第 3 条:片区回显改走标题栏「?」摘要(见 domain/eventList 的 scopeNote)。 -->
 
         <!--
             G11(票 07):从「预警研判 · 类型排行」点进来的**本地类型筛选**回显。
@@ -87,17 +87,20 @@
                     @keydown.enter="canOpen(row) ? openEvent(row) : null"
                 >
                     <div class="event-list-pane__card-main">
+                        <!-- 等级与状态各带语义色 + 文字(维护者 2026-10-29:状态也用颜色区分)。
+                             等级只在这里出现一次 —— 不重复画第二处。 -->
                         <StatusTag :tone="row.level.tone" :label="row.level.label" data-event-level />
                         <strong data-event-name :title="row.name">{{ row.name }}</strong>
-                        <span class="event-list-pane__status" data-event-status>{{ row.statusText || '状态未发布' }}</span>
-                    </div>
-                    <div class="event-list-pane__card-reading">
-                        <span class="event-list-pane__value" data-event-value :title="row.quantity">{{ row.quantity }}</span>
-                        <span data-event-time :title="row.eventTime">{{ row.eventTimeText || row.eventTime || '时间未发布' }}</span>
+                        <StatusTag :tone="row.statusTone" :label="row.statusText || '状态未发布'" data-event-status />
                     </div>
+                    <!-- 卡片上直接说清"发生了什么":指标 + 实际值 + 阈值(缺哪段少哪段) -->
+                    <p v-if="row.summaryText" class="event-list-pane__card-summary" data-event-summary :title="row.summaryText">
+                        {{ row.summaryText }}
+                    </p>
                     <div class="event-list-pane__card-meta">
-                        <span data-event-device :title="row.deviceLabel">{{ row.deviceLabel }}</span>
-                        <span data-event-code :title="row.code">{{ row.code }}</span>
+                        <span data-event-device :title="row.deviceLabel">设备 {{ row.deviceLabel }}</span>
+                        <span data-event-code :title="row.codeText">{{ row.codeText }}</span>
+                        <span data-event-time :title="row.eventTime">{{ row.eventTimeText || row.eventTime || '时间未发布' }}</span>
                         <span v-if="row.workOrderStateName" data-event-work-order>工单{{ row.workOrderStateName }}</span>
                     </div>
                 </article>
@@ -108,6 +111,8 @@
                 当前已加载列表中没有该类型的预警;这不是"全量无预警",覆盖范围见上方说明。
             </p>
 
+            <!-- 维护者 2026-10-30 第 3 条:「已隐藏 N 条」与快照时间统一收进标题栏「?」摘要(见 domain/eventList 的 filterNote / snapshotNote)。 -->
+
             <!--
                 票 61:服务端页码分页(共享 PagerNav,规范条文 2/3/4/9 + 跳页节)。
                 翻页带 page/pageSize 发请求(页码状态在 eventStore,组件只转发目标页);
@@ -123,9 +128,6 @@
                     @page="(target) => eventStore.setCurrentPage(target)"
                 />
             </div>
-            <p v-if="view.updatedAt" class="event-list-pane__note" data-event-updated>
-                {{ view.snapshotLabel }} {{ view.updatedAt }}
-            </p>
         </template>
 
         <!-- 没内容:如实说(空/失败/不可用各有措辞,来自 domain) -->
@@ -134,12 +136,13 @@
 </template>
 
 <script setup>
-import { computed } from 'vue'
+import { computed, onBeforeUnmount, watch } from 'vue'
 import { eventListSwitchOptions } from '../domain/eventList.js'
 import { sectionPending } from '../domain/panelBlocks.js'
 import { useEventStore } from '../stores/eventStore.js'
 import { panelSideFor } from '../domain/panelCatalog.js'
 import { useEntityDetailStore } from '../stores/entityDetailStore.js'
+import { registerPanelSummary, unregisterPanelSummary } from '../stores/panelSummaryStore.js'
 import PagerNav from './PagerNav.vue'
 import StatusTag from './StatusTag.vue'
 
@@ -147,6 +150,13 @@ const eventStore = useEventStore()
 const side = panelSideFor('warningResponse', 'currentEvents') || 'left'
 const entityDetailStore = useEntityDetailStore()
 const view = computed(() => eventStore.view)
+
+/**
+ * 维护者 2026-10-30 第 3 条:本面板的「快照时间」「已隐藏 N 条已了结事件」不再画在面板体里,
+ * 统一收进标题栏的「?」摘要。标题栏(CockpitPanel)与面板体是兄弟节点,经 panelSummaryStore 桥接。
+ */
+watch(view, (current) => registerPanelSummary('currentEvents', current), { immediate: true })
+onBeforeUnmount(() => unregisterPanelSummary('currentEvents'))
 const switchOptions = eventListSwitchOptions()
 
 /** 还没取过数(界面说"正在取数",不说"没有数据")。 */
@@ -158,6 +168,7 @@ function canOpen(row) {
 
 function openEvent(row) {
     eventStore.closeEvent()
+    // 取数用内部键;展示与详情里的「事件编码」用业务编号(definition 在 domain 层)。
     return entityDetailStore.show({ kind: 'event', id: row.code, family: row.family || eventStore.mode }, row, { side })
 }
 
@@ -237,6 +248,7 @@ function setMode(next) {
 .event-list-pane__status { padding: 0 calc(5 * var(--u, 1px)); border-radius: 8px; background: rgba(1,171,234,.12); color: var(--cyan-hi); white-space: nowrap; }
 .event-list-pane__card-reading { justify-content: space-between; color: var(--text-2); font-family: var(--num, monospace); }
 .event-list-pane__value { color: var(--text-strong); font-size: var(--fs-body, 13px); font-weight: 700; }
+.event-list-pane__card-summary { margin: 0; overflow: hidden; color: var(--text-2); font-size: var(--fs-caption, 10.5px); line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
 .event-list-pane__card-meta { justify-content: space-between; color: var(--muted); font-size: var(--fs-caption, 10.5px); }
 .event-list-pane__card-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 </style>

+ 112 - 6
src/modules/cockpit/domain/eventList.js

@@ -37,7 +37,7 @@ import { EVENT_FAMILY, LEVEL_COLORS } from './events.js'
 import { LEVEL_TAGS, DEFAULT_TONE } from './statusTones.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { workOrderStateName } from './workOrder.js'
-import { serverPageView } from './pagination.js'
+import { pagedRows } from './pagination.js'
 // 票 67:本面板"不随当前范围变化"的措辞(单一来源,面板不各写一份)。
 import { districtEchoView } from './filterCaliber.js'
 
@@ -47,6 +47,17 @@ export const EVENT_STATUS_TEXT = {
     PENDING: '待处理',
     PROCESSING: '处理中',
     HANDLING: '处置中',
+    // 实测取值(维护者 2026-10-29:卡片状态原样显示英文):
+    // 报警侧用 UNHANDLED/HANDLED,预警侧另有 RECEIVED/CLOSED/DRAFT 等字典值。
+    UNHANDLED: '未处理',
+    HANDLED: '已处理',
+    RECEIVED: '已接收',
+    CLEARED: '已解除',
+    CLOSED: '已关闭',
+    DRAFT: '草稿',
+    CANCELLED: '已取消',
+    MISREPORT: '误报',
+    RESOLVED: '已解除',
 }
 
 /**
@@ -258,6 +269,19 @@ export function eventLevelTag(level) {
  * 事件 `status` → 展示文本。已知取值直译;**认不出的回原码**(数据值照原样显示才是
  * 诚实的,与 `CODE_TEXT` 同一立场);空串回空串(不把"没有状态"画成某个状态)。
  */
+/**
+ * 终态(已处理完)判定(维护者 2026-10-29 第 2 条:当前预警/报警不展示已处置/已取消)。
+ *
+ * 「当前」列表的语义是**还在进行中的**,已经处理完的不该混进来。
+ * 判定用词表而非猜:命中终态词就过滤;认不出的状态**保留**(宁可多显示,
+ * 也不能因为认不出把还在进行中的事件藏掉)。
+ */
+const TERMINAL_STATUS = new Set(['HANDLED', 'RESOLVED', 'CLEARED', 'CLOSED', 'CANCELLED', 'MISREPORT'])
+export function isTerminalEventStatus(status) {
+    const value = typeof status === 'string' ? status.trim().toUpperCase() : ''
+    return TERMINAL_STATUS.has(value)
+}
+
 export function eventStatusText(status) {
     const value = typeof status === 'string' ? status.trim() : ''
     if (!value) return ''
@@ -296,8 +320,11 @@ export function eventSectionHasContent(section) {
 export function toEventItem(raw) {
     const reader = fieldReader(raw, 'eventItem')
     return {
-        // 统一键:预警与报警都用 eventNo(契约里两族同一字段名,不存在同名冲突)。
+        // 内部键:预警与报警都用 eventNo(契约里两族同一字段名,不存在同名冲突)。
+        // 注意报警的 eventNo 是**主键 UUID**,上屏必须用 eventCode(业务编号 alarmNo)
+        // —— 维护者 2026-10-29:「报警详情的事件编码应该用 code 而非 id」。
         code: reader.str('eventNo'),
+        codeText: reader.optionalStr('eventCode') || reader.str('eventNo'),
         name: reader.optionalStr('eventName'),
         type: reader.optionalStr('eventType'),
         level: reader.optionalNum('level'),
@@ -413,6 +440,47 @@ export function toCurrentEvents(raw) {
  * 等级标签与状态文本都从领域行现算 —— 组件里不再出现第二份映射。
  * @param {object} eventItem `toEventItem()` 的结果
  */
+/**
+ * 事件处理状态 → 语义色键(维护者 2026-10-29:状态也用颜色区分,不只靠文字)。
+ *
+ * 只登记**已知**状态;认不出的走中性灰。状态码是后端未冻结的自由串,
+ * 所以这里按语义归一(进行中/待处理/处理中/已解除…),不硬绑定单一拼写。
+ */
+export function eventStatusTone(status) {
+    const value = typeof status === 'string' ? status.trim().toUpperCase() : ''
+    if (!value) return DEFAULT_TONE
+    if (/^(ACTIVE|PENDING|UNHANDLED|WAIT|待处理|进行中|待核实|待确认)$/.test(value)) return 'lv2'
+    if (/^(PROCESSING|HANDLING|HANDLED|DEALING|处理中|处置中|已接单)$/.test(value)) return 'lv3'
+    if (/^(RESOLVED|CLEAR|CLEARED|CLOSED|DONE|已解除|已关闭|已处置|已结案)$/.test(value)) return 'lv4'
+    if (/^(CANCELLED|MISREPORT|作废|已取消|误报)$/.test(value)) return DEFAULT_TONE
+    return DEFAULT_TONE
+}
+
+/**
+ * 卡片上的一句话摘要:**这件事到底发生了什么**(维护者 2026-10-29)。
+ *
+ * 组合顺序有意:指标名 → 实际值(带单位)→ 阈值。缺哪段少哪段,
+ * 一段都没有时回空串(渲染层据此不画那一行),绝不编造一句通用话术。
+ */
+export function eventSummaryText(item, quantity) {
+    const current = item || {}
+    // 指标名一律**中文化**:后端下发的是采集协议码(WARN-WATER-LEVEL 等),
+    // 直接印码等于把接口词汇漏上大屏(维护者 2026-10-29 第 2 条)。
+    const rawMetric = current.type || current.warningType || current.eventType || ''
+    const metric = rawMetric ? eventTypeText(rawMetric, current.typeName || current.warningTypeName || current.eventTypeName) : ''
+    const parts = []
+    if (metric) parts.push(metric)
+    if (quantity && quantity !== DETAIL_PLACEHOLDER) parts.push(`实际值 ${quantity}`)
+    const min = current.thresholdMinValue
+    const max = current.thresholdMaxValue
+    if (typeof min === 'number' || typeof max === 'number') {
+        const range = typeof min === 'number' && typeof max === 'number' ? `${min} ~ ${max}`
+            : typeof min === 'number' ? `≥ ${min}` : `≤ ${max}`
+        parts.push(`阈值 ${current.unit ? range + ' ' + current.unit : range}`)
+    }
+    return parts.join(' · ')
+}
+
 export function eventRowView(eventItem) {
     const item = eventItem || {}
     const tag = eventLevelTag(item.level)
@@ -427,7 +495,9 @@ export function eventRowView(eventItem) {
         key: item.code || DETAIL_PLACEHOLDER,
         family: item.family || '',
         // 切换后的字段标签(预警/报警各自一套,来自切换视图 —— 与状态标签同步变化)。
+        // `code` 是内部键(取数用);`codeText` 是上屏的业务编号(报警为 alarmNo)。
         code: item.code,
+        codeText: item.codeText || item.code || '',
         identityCode: item.code || '',
         identityFamily: String(item.family || '').toUpperCase(),
         identityReason: item.code ? '' : '缺少事件编号,无法展开',
@@ -435,6 +505,11 @@ export function eventRowView(eventItem) {
         type: item.type || '',
         level: { tone: tag.tone, label: tag.label },
         statusText: eventStatusText(item.status),
+        // 处理状态也要能按颜色区分(维护者 2026-10-29):色键与文字同源,
+        // 认不出的状态落中性灰 + 原码 —— 不猜成"处理中"。
+        statusTone: eventStatusTone(item.status),
+        // 卡片上"到底发生了什么"的一句话:指标 + 实际值/阈值,缺什么就少说什么。
+        summaryText: eventSummaryText(item, quantity),
         eventTime: item.eventTime || '',
         // 列表列的短读法(2026-10-13 维护者反馈:ISO 全串把右侧列挤出可视区被裁)。
         // 只截断显示、不改排序键(排序仍用 eventTime 原值);解析不了回原串。
@@ -531,7 +606,12 @@ export function currentEventsView(domain, eventsSection, options = {}) {
     // 票 61:**行=后端返回的这一页**,前端不排、不再截 12 条(服务端分页按后端返回序,
     // 总数读 `pagination.total`,条文 7)。空/失败时仍给安全的一页视图(pageCount=1)。
     const family = switchView.key
-    const allRows = items.map((item) => ({
+    // 维护者 2026-10-29 第 2 条:**当前**预警/报警只展示还在进行中的事件。
+    // 已处理完(已处理/已解除/已关闭/已取消/误报)的不进这张表;
+    // 认不出的状态保留(宁可多显示,也不能把进行中的藏掉)。过滤条数如实回显。
+    const visibleItems = items.filter((item) => !isTerminalEventStatus(item && item.status))
+    const hiddenTerminalCount = items.length - visibleItems.length
+    const allRows = visibleItems.map((item) => ({
         ...eventRowView(item),
         family,
         identityFamily: family,
@@ -554,7 +634,21 @@ export function currentEventsView(domain, eventsSection, options = {}) {
               note: `已按预警类型「${typeLabel}」本地筛选:命中 ${rows.length} / 当前页已加载 ${allRows.length} 条;本列表只含可解析设备的当前预警、且筛选不跨页,不代表全部未解除预警(缺口见票 07/G11)`,
           }
         : { active: false, code: '', label: '', loaded: allRows.length, matched: allRows.length, note: '' }
-    const pagination = serverPageView(current && current.pagination, items.length)
+    // 维护者 2026-10-29:**分页必须按「过滤后的可见行」算**。
+    // 旧做法把后端 pagination(含已终态事件)当页码,而行已经滤掉终态 ——
+    // 报警 92 条里 91 条已处理,屏上只剩 1 行却仍画出 10 页控件,还能翻到空页。
+    // 现在:事件一次性取全(见 store 的 CURRENT_EVENTS_FETCH_SIZE),这里对可见行前端分页,
+    // 「共 N 条 / 共 Y 页」与用户真的看得到的行严格一致。
+    const requestedPage = Number.isInteger(options.page) && options.page > 0 ? options.page : 1
+    const pageView = pagedRows(rows, requestedPage)
+    // 取全被截断(后端总数 > 已加载条数)时如实标注,不假装是全部。
+    const rawTotal = Number(current && current.pagination && current.pagination.total)
+    const truncated = Number.isFinite(rawTotal) && rawTotal > items.length
+    const pagination = { page: pageView.page, pageCount: pageView.pageCount, total: pageView.total, pageSize: pageView.pageSize }
+    // 维护者 2026-10-30 第 3 条:这两句原先画在面板体里,现在只作为「?」摘要的条目。
+    const hiddenText = hiddenTerminalCount > 0 ? `已隐藏 ${hiddenTerminalCount} 条已了结事件(本表只列进行中的)` : ''
+    const truncatedText = truncated ? '事件过多,只加载了部分(已按可见行分页)' : ''
+    const filterNote = [hiddenText, truncatedText].filter(Boolean).join(' · ')
     const placeholder = hasContent
         ? ''
         : pending
@@ -571,8 +665,9 @@ export function currentEventsView(domain, eventsSection, options = {}) {
         switchView,
         section: sectionValue,
         hasContent,
-        rows,
-        total: pagination.total,
+        // 行=**当前页**的可见行(过滤后前端分页);`total` 是可见行总数。
+        rows: pageView.rows,
+        total: pageView.total,
         placeholder,
         pagination: hasContent ? pagination : null,
         updatedAt: (current && current.updatedAt) || '',
@@ -588,5 +683,16 @@ export function currentEventsView(domain, eventsSection, options = {}) {
         gap: '',
         // G11:类型本地筛选的回显与覆盖范围(组件画选中态 + 「清除筛选」入口)。
         filterView,
+        // 「当前」表已过滤掉终态:过滤了几条如实说,免得读成「后端只回了这些」。
+        // 维护者 2026-10-30 第 3 条:这句话不再画在面板体里,改成面板标题栏「?」摘要的一条
+        //(`filterNote`)—— 面板体只留数据本身。
+        hiddenTerminalCount,
+        // 取全被截断时说清(后端总数 > 已加载),不假装是全部。
+        truncated,
+        filterNote,
+// 维护者 2026-10-30 第 3 条:片区回显也改走「?」摘要(面板体只留数据与操作)。
+        scopeNote: (current && current.filters ? districtEchoView(current.filters).note : '') || '',
+        // 信封快照时间也改走摘要(原来占面板体一整行)。
+        snapshotNote: [TIME_LABELS.snapshot, (current && current.updatedAt) || ''].filter(Boolean).join(' '),
     }
 }