EquipmentDetailSections.vue 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294
  1. <!--
  2. 设备详情的分区(票 11):档案 / 指标 / 预警 / 报警 / 工单状态 / 设备图片。
  3. 票 46 返工(维护者 2026-10-15 第 1 条):**弃用六块竖排的长滚动**,改为标签页切换
  4. 设备档案(默认,图片归此页下方)|最新指标|预警信息|报警信息|工单状态;
  5. 预警/报警列表每页最多 10 条。页签计划与分页边界都是
  6. domain 纯函数(`EQUIPMENT_TABS`/`panelsForTab`/`pagedRows`),组件只画结论。
  7. 票 59(统一分页批次一):**三清单(预警/报警/工单)都接共享 `PagerNav.vue`**
  8. (页码直达 + 输入框跳页 + 计数行),翻页窗口计算归 `domain/pagination.js` 单源,
  9. 本组件不再自画 pager、不再有第二份翻页算术;前后页/页码/跳页共用唯一出口
  10. `gotoPage(panel, target)`,越界夹回由 `pagedRows` 做。
  11. **渲染计划仍是六块独立分区**(t11 D1/D9):标签页只改呈现归属,
  12. 每块仍独立成败;指标页改读独立采集记录分区,按当前需求不画状态点/过期说明。
  13. 档案读失败但指标可用仍各自呈现,不能共用一个成败。
  14. 三种「没内容」在界面上的区别(票 16 的词汇,措辞由 domain/equipmentDetailView.js 给):
  15. 空数据 EMPTY → 「该分区当前没有数据」
  16. 失败 FAILED → 「该分区加载失败(其它分区不受影响)」
  17. 不可用 UNAVAILABLE → 「该分区数据不可用(其它分区不受影响)」
  18. 过期 STALE 是**有值**:内容照画,只是标签说「数据已过期,显示最后一次成功数据」。
  19. 事件高亮采用既有四级颜色词表;数值预警黄/报警红与等级提示点分别呈现。
  20. 票 12 在**工单状态这块里**接上了状态时间线(不新增第七块面板):
  21. 每张工单下面挂它自己的那条轨迹(`timelineSource()` 按工单编码配好)。
  22. 轨迹分区**独立于工单列表成败** —— 后端可在工单列表成功时单独把 `timelines` 标 FAILED
  23. (`SOURCE_SECTION_FAILED`),那时界面必须说「轨迹没取到」而不是"没有动作记录"。
  24. -->
  25. <template>
  26. <div ref="sectionsRoot" class="equipment-sections" data-equipment-sections :data-device-code="deviceCode">
  27. <!--
  28. 票 46 返工:标签页条。计数挂在页签上(预警/报警/工单才有;
  29. 维护者要「避免长滚动列表查找困难」——点之前就知道每页后面有多少条)。
  30. -->
  31. <div class="equipment-tabs" role="tablist" aria-label="设备详情分区切换" data-equipment-tabs>
  32. <button
  33. v-for="tab in tabs"
  34. :key="tab.name"
  35. type="button"
  36. role="tab"
  37. class="equipment-tabs__tab"
  38. :class="{ 'is-active': tab.name === activeTab }"
  39. :aria-selected="tab.name === activeTab"
  40. :data-equipment-tab="tab.name"
  41. :data-tab-count="tab.count || ''"
  42. @click="activeTab = tab.name"
  43. >
  44. {{ tab.label }}<b v-if="tab.count" class="equipment-tabs__count">{{ tab.count }}</b>
  45. </button>
  46. </div>
  47. <!--
  48. 当前页签的面板(档案页含两块:档案 + 设备图片,所以 tabpanel 挂在**包装层**
  49. 而不是每块 section 上 —— 一个页签对应一个 tabpanel 才是正确的可访问语义)。
  50. -->
  51. <p v-if="aggregate?.retained" class="equipment-section__hint" data-detail-refresh-retained>本次详情更新未完成,保留上次已加载内容。</p>
  52. <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>
  53. <VideoMonitorEntry v-if="activeTab === 'metrics'" :device-code="deviceCode" :monitor-source="monitorSource" />
  54. <section
  55. v-for="panel in activePanels"
  56. :key="panel.name"
  57. class="equipment-section"
  58. :data-equipment-section="panel.name"
  59. :data-panel-state="panel.section.state || 'empty'"
  60. :data-panel-retained="panel.section.retained ? 'true' : 'false'"
  61. >
  62. <header class="equipment-section__head">
  63. <span class="equipment-section__title">{{ panel.label }}</span>
  64. <SectionStateTag v-if="panel.kind !== 'metrics'" :name="panel.name" :section="panel.section" />
  65. </header>
  66. <!-- 过期分区在内容之上再点一次题(颜色之外的第二种提示,灰度下也读得出) -->
  67. <p v-if="panel.kind !== 'metrics' && panel.section.retained" class="equipment-section__stale" data-section-stale-note>
  68. {{ staleNote }}
  69. </p>
  70. <!-- ---------- 有内容:按 kind 分派 ---------- -->
  71. <template v-if="panel.hasContent">
  72. <!--
  73. 票 46:分类查询条 —— 预警/报警/工单**共用这一块**(同一组绑定只写一处)。
  74. 分类归组在 domain(等级复用票 13 词表、工单复用 0–7 字典),组件只画 chips;
  75. 点分类只筛视图,不发请求、不动选择(筛选变化不丢详情上下文)。
  76. -->
  77. <template v-if="isFilterable(panel) && chipsOf(panel).length > 1">
  78. <div class="equipment-section__chips" role="group" :aria-label="`${panel.label}分类查询`" data-category-bar>
  79. <button
  80. v-for="chip in chipsOf(panel)"
  81. :key="chip.key || 'all'"
  82. type="button"
  83. class="equipment-section__chip"
  84. :class="{ 'is-active': chip.key === activeCategory(panel.name) }"
  85. :aria-pressed="chip.key === activeCategory(panel.name)"
  86. :data-category-chip="`${panel.name}:${chip.key || 'all'}`"
  87. @click="toggleCategory(panel.name, chip.key)"
  88. >
  89. {{ chip.label }} <b class="equipment-section__chip-count" data-category-count>{{ chip.count }}</b>
  90. </button>
  91. </div>
  92. <p v-if="categoryNoteText(panel)" class="equipment-section__note" data-category-note>{{ categoryNoteText(panel) }}</p>
  93. </template>
  94. <!-- 档案:键值行 -->
  95. <dl v-if="panel.kind === 'archive'" class="equipment-section__rows" data-archive-rows>
  96. <div v-for="row in rowsOf(panel)" :key="row.key" class="equipment-section__row" :data-archive-row="row.key">
  97. <dt>{{ row.label }}</dt>
  98. <dd :class="{ 'is-muted': isPlaceholder(row.value), 'equipment-section__mono': row.mono }">{{ row.value }}</dd>
  99. </div>
  100. </dl>
  101. <!-- 采集记录:最近50行;分页后只合并当前页相同采集时间。 -->
  102. <div v-else-if="panel.kind === 'metrics'" class="equipment-section__list" data-collection-records>
  103. <p class="equipment-section__note">采集记录(最近50条)</p>
  104. <div class="collection-table-wrap">
  105. <table v-if="recordTableOf(panel).total" class="collection-table" data-collection-table aria-label="设备最近采集记录">
  106. <thead>
  107. <tr>
  108. <th v-for="column in recordTableOf(panel).columns" :key="column.key" :class="{ 'collection-table__time': column.isTime, 'collection-table__audio-col': column.isAudio }">
  109. <span class="collection-table__heading" :title="column.label">{{ column.label }}</span>
  110. <span v-if="column.unit" class="collection-table__unit">({{ column.unit }})</span>
  111. </th>
  112. </tr>
  113. </thead>
  114. <tbody v-for="group in recordTableOf(panel).groups" :key="group.key"
  115. :class="{ 'collection-table__group--latest': group.isLatest }"
  116. :style="{ '--collection-accent': group.color }"
  117. :data-collection-group="group.key" :data-collection-group-state="group.state">
  118. <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">
  119. <template v-for="(cell, cellIndex) in row.cells" :key="cell.key">
  120. <td
  121. v-if="!recordTableOf(panel).columns[cellIndex].isTime || row.timeRowspan > 0"
  122. data-collection-cell
  123. :data-reading-state="cell.assessment?.state"
  124. :style="cell.assessment ? { '--reading-color': cell.assessment.color } : undefined"
  125. :title="cell.assessment?.label"
  126. :rowspan="recordTableOf(panel).columns[cellIndex].isTime ? row.timeRowspan : undefined"
  127. :class="{ 'collection-table__time': recordTableOf(panel).columns[cellIndex].isTime, 'collection-table__audio-col': recordTableOf(panel).columns[cellIndex].isAudio }"
  128. >
  129. <template v-if="recordTableOf(panel).columns[cellIndex].isTime">
  130. <span v-if="group.isLatest" class="collection-table__latest-badge" data-collection-latest-badge>最新</span>
  131. <span class="collection-table__time-date">{{ row.createDate }}</span>
  132. <span v-if="row.createClock" class="collection-table__time-clock">{{ row.createClock }}</span>
  133. </template>
  134. <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)">
  135. <span aria-hidden="true">▶</span><span>播放</span>
  136. </button>
  137. <!--
  138. long 布局的格内是「指标名:读数」整条:名称走正文样式,
  139. 读数段与其它设备的指标值同一套等宽数字字体。
  140. -->
  141. <span
  142. v-else-if="cell.reading && cell.reading.label"
  143. class="collection-table__reading"
  144. >
  145. <span class="collection-table__reading-label">{{ cell.reading.label }}</span>
  146. <span class="collection-table__reading-result">
  147. <span class="collection-table__reading-value" :class="{ 'is-muted': isPlaceholder(cell.reading.value) }">{{ cell.reading.value }}</span>
  148. <MetricStateHint v-if="cell.assessment?.hint" :color="cell.assessment.hint.color"
  149. :label="cell.assessment.hint.label" :refresh-key="aggregate" />
  150. </span>
  151. </span>
  152. <span v-else class="collection-table__reading-result">
  153. <span :class="{ 'is-muted': isPlaceholder(cell.text), 'collection-table__value': cell.isReading }">{{ cell.text }}</span>
  154. <MetricStateHint v-if="cell.assessment?.hint" :color="cell.assessment.hint.color"
  155. :label="cell.assessment.hint.label" :refresh-key="aggregate" />
  156. </span>
  157. </td>
  158. </template>
  159. </tr>
  160. </tbody>
  161. </table>
  162. </div>
  163. <p v-if="!recordTableOf(panel).total" class="equipment-section__hint" data-section-empty-list>暂无采集记录</p>
  164. <p v-else-if="recordTableOf(panel).assessmentNote" class="equipment-section__hint" data-collection-assessment-note>{{ recordTableOf(panel).assessmentNote }}</p>
  165. <PagerNav
  166. v-if="recordTableOf(panel).total"
  167. :page="recordTableOf(panel).page"
  168. :page-count="recordTableOf(panel).pageCount"
  169. :total="recordTableOf(panel).total"
  170. list-name="采集记录"
  171. @page="(target) => gotoPage(panel, target)"
  172. />
  173. </div>
  174. <!--
  175. 预警 / 报警:条目默认可折叠(票 46)—— 折叠行给时间·名称·等级·状态·量值,
  176. 展开看详情(事件时间 / 状态 / 来源 / 关联设备,缺值给占位符)。
  177. 票 46 返工第 1 条:每页最多 10 条(上一页/下一页 + 页码;先筛后翻)。
  178. 分类 chips 在上面共用那一块。
  179. -->
  180. <div v-else-if="panel.kind === 'events'" class="equipment-section__list" :data-event-list="panel.name">
  181. <details
  182. v-for="(event, index) in pagedOf(panel).rows"
  183. :key="event.code || index"
  184. class="equipment-section__item equipment-section__item--collapsible"
  185. :class="{ 'equipment-section__item--active': event.active }"
  186. :style="{ '--detail-accent': event.activeColor }"
  187. :open="isDetailOpen(panel, event, index)"
  188. @toggle="rememberDetailToggle(panel, event, index, $event)"
  189. :data-event="event.code || index"
  190. :data-event-level-color="event.levelColor"
  191. :data-item-active="event.active ? 'true' : 'false'"
  192. data-history-item
  193. >
  194. <summary class="equipment-section__item-head">
  195. <!-- 标题=单号;内容=描述、时间、等级、状态;右下角提示可展开看详情。 -->
  196. <span class="equipment-section__item-name equipment-section__mono" data-event-title>{{ event.title }}</span>
  197. <span v-if="event.description" class="equipment-section__item-desc" data-event-desc>{{ event.description }}</span>
  198. <span class="equipment-section__meta" data-event-meta>
  199. <span data-event-level>{{ event.levelText }}</span>
  200. <span v-if="event.status" data-event-status>{{ event.status }}</span>
  201. <span v-if="event.active" class="equipment-section__active-badge" data-active-badge>{{ event.activeLabel }}</span>
  202. <span v-if="event.timeText" data-event-time>{{ event.timeText }}</span>
  203. </span>
  204. <span class="equipment-section__item-more" aria-hidden="true" data-event-more>点击查看详情 ›</span>
  205. </summary>
  206. <dl class="equipment-section__detail" data-history-detail>
  207. <div v-for="row in eventDetailRows(event)" :key="row.label" class="equipment-section__detail-row" :data-detail-row="row.label">
  208. <dt>{{ row.label }}</dt>
  209. <dd>{{ row.value }}</dd>
  210. </div>
  211. </dl>
  212. </details>
  213. <p v-if="!pagedOf(panel).total" class="equipment-section__hint" data-section-empty-list>
  214. {{ rowsOf(panel).length ? '当前分类下没有记录(换一个分类或点「全部」)' : emptyList(panel.label) }}
  215. </p>
  216. <!-- 分页控件(票 59 共享 PagerNav,前后页/页码/跳页同一个 @page 出口) -->
  217. <PagerNav
  218. :page="pagedOf(panel).page"
  219. :page-count="pagedOf(panel).pageCount"
  220. :total="pagedOf(panel).total"
  221. :list-name="panel.label"
  222. @page="(target) => gotoPage(panel, target)"
  223. />
  224. </div>
  225. <!--
  226. 工单状态:状态名(缺则按状态字典补)+ 关键时间 + 每张工单自己的状态时间线(票 12)。
  227. 票 46:条目默认可折叠,展开保留事件时间/状态/来源/关联对象 + 计划与实际结案时间。
  228. 「没有动作记录」那句仍由 `timeline.hasContent` 门控(票 12 的 F9 守卫逐字钉住)。
  229. 票 59:接入统一分页(每页 ≤10,先分类后翻页)。
  230. -->
  231. <div v-else-if="panel.kind === 'orders'" class="equipment-section__list" data-work-order-list>
  232. <details
  233. v-for="(order, index) in pagedOf(panel).rows"
  234. :key="order.code || index"
  235. class="equipment-section__item equipment-section__item--collapsible"
  236. :class="{ 'equipment-section__item--active': order.active }"
  237. :style="{ '--detail-accent': order.activeColor }"
  238. :open="isDetailOpen(panel, order, index)"
  239. @toggle="rememberDetailToggle(panel, order, index, $event)"
  240. :data-work-order="order.code || index"
  241. :data-work-order-state="order.stateCode"
  242. :data-item-active="order.active ? 'true' : 'false'"
  243. data-history-item
  244. >
  245. <summary class="equipment-section__item-head">
  246. <span class="equipment-section__item-name">
  247. {{ order.stateName }}
  248. <em v-if="order.typeName"> · {{ order.typeName }}</em>
  249. </span>
  250. <span class="equipment-section__meta">
  251. <span class="equipment-section__mono">{{ order.code }}</span>
  252. <span data-work-order-association>{{ order.associationText }}</span>
  253. <span v-if="order.active" class="equipment-section__active-badge" data-active-badge>{{ order.activeLabel }}</span>
  254. </span>
  255. <span class="equipment-section__meta">
  256. <span v-if="order.timeText">创建 {{ order.timeText }}</span>
  257. <span>结案 {{ order.realFinishTime }}</span>
  258. </span>
  259. </summary>
  260. <!--
  261. 票 12:这张工单自己的状态路径 + 动作轨迹。
  262. · 轨迹分区拿到了数据 → 完整轨迹(状态路径 + 动作流水);
  263. · 轨迹分区失败/为空 → **仍铺状态路径**(当前状态码在工单列表里,
  264. 本来就拿得到),只是没有动作流水。因为"轨迹没取到"而连
  265. "这张工单现在在哪一态"也一起不画,等于丢掉已有的信息。
  266. 分区自己的成败由下面那条整块占位说明如实交代。
  267. -->
  268. <WorkOrderTimeline
  269. v-if="trackOfOrder(order)"
  270. :track="trackOfOrder(order)"
  271. :path-only="!timeline.hasContent"
  272. />
  273. <p v-else-if="timeline.hasContent" class="equipment-section__hint" data-wo-timeline-missing>
  274. 这张工单没有动作记录
  275. </p>
  276. <dl class="equipment-section__detail" data-history-detail>
  277. <div v-for="row in workOrderDetailRows(order)" :key="row.label" class="equipment-section__detail-row" :data-detail-row="row.label">
  278. <dt>{{ row.label }}</dt>
  279. <dd>{{ row.value }}</dd>
  280. </div>
  281. </dl>
  282. </details>
  283. <!-- 轨迹分区自己失败/为空:整块说一句(不按每张工单重复同一句话,那是噪音) -->
  284. <p v-if="timeline.placeholder" class="equipment-section__hint" data-wo-timeline-placeholder>
  285. {{ timeline.placeholder }}
  286. </p>
  287. <p v-if="!pagedOf(panel).total" class="equipment-section__hint" data-section-empty-list>
  288. {{ rowsOf(panel).length ? '当前分类下没有记录(换一个分类或点「全部」)' : emptyList('工单') }}
  289. </p>
  290. <!-- 分页控件(票 59 共享 PagerNav,与预警/报警同一控件、同一出口) -->
  291. <PagerNav
  292. :page="pagedOf(panel).page"
  293. :page-count="pagedOf(panel).pageCount"
  294. :total="pagedOf(panel).total"
  295. :list-name="panel.label"
  296. @page="(target) => gotoPage(panel, target)"
  297. />
  298. </div>
  299. <!-- 设备图片:只画后端指定的那一张(前端不索引、不挑选) -->
  300. <figure v-else-if="panel.kind === 'image'" class="equipment-section__figure" data-latest-image>
  301. <template v-if="image(panel).hasImage">
  302. <button v-if="!imageFailed" type="button" class="equipment-section__image-button" aria-label="放大查看设备图片" @click="imageDialog.showModal()">
  303. <img class="equipment-section__img" :src="image(panel).url" :alt="`${deviceCode} 设备图片`" @load="imageLoading = false" @error="imageFailed = true; imageLoading = false" />
  304. <span>{{ imageLoading ? '图片加载中…' : '点击放大' }}</span>
  305. </button>
  306. <p v-else class="equipment-section__hint" role="status">图片加载失败,请检查图片服务地址或文件是否可访问。<button type="button" @click="imageFailed = false; imageLoading = true">重试</button></p>
  307. <figcaption class="equipment-section__meta">拍摄于 {{ image(panel).capturedAt }}</figcaption>
  308. </template>
  309. <p v-else class="equipment-section__hint" data-latest-image-empty>后端未提供该设备的图片</p>
  310. </figure>
  311. </template>
  312. <!-- ---------- 没内容:如实说,不画空框冒充数据 ---------- -->
  313. <p v-else class="equipment-section__hint" data-section-placeholder>{{ panel.placeholder }}</p>
  314. <!-- 字段缺失:只说条数,具体路径进 tooltip(不漏接口字段名给值班人员) -->
  315. <p
  316. v-if="panel.missing.count"
  317. class="equipment-section__missing"
  318. data-section-missing
  319. :data-missing-count="panel.missing.count"
  320. :title="panel.missing.tooltip"
  321. >
  322. {{ panel.missing.text }}
  323. </p>
  324. </section>
  325. </div>
  326. <dialog ref="imageDialog" class="equipment-image-viewer" aria-label="设备图片放大查看" @click="closeImageBackdrop">
  327. <button type="button" autofocus class="equipment-image-viewer__close" @click="imageDialog.close()">关闭</button>
  328. <img :src="currentImageUrl" :alt="`${deviceCode} 设备图片`" />
  329. </dialog>
  330. <!--
  331. 音频播放条:贴在播放键上方渐入的小浮层(Teleport 到 body,避免被滚动容器裁剪)。
  332. 不再用居中大弹窗/背景虚化;点击外部、滚动或 Esc 都会收起。
  333. -->
  334. <Teleport to="body">
  335. <div
  336. v-if="activeAudioPath"
  337. ref="audioPlayer"
  338. class="collection-audio-player"
  339. :class="{ 'is-below': audioPlacement.below }"
  340. :style="audioPlayerStyle"
  341. data-collection-audio-player
  342. >
  343. <div class="collection-audio-player__head">
  344. <span class="collection-audio-player__label">{{ activeAudioLabel }}</span>
  345. <button type="button" class="collection-audio-player__close" aria-label="关闭播放条" @click="closeAudioPlayer">×</button>
  346. </div>
  347. <audio v-if="activeAudioUrl" class="collection-audio-player__audio" controls preload="metadata" :src="activeAudioUrl" />
  348. </div>
  349. </Teleport>
  350. </div>
  351. </template>
  352. <script setup>
  353. import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
  354. import { resolveDeviceImageUrl } from '../services/deviceImage.js'
  355. import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
  356. import {
  357. DEFAULT_EQUIPMENT_TAB,
  358. EVENT_CATEGORY_ORDER,
  359. EQUIPMENT_TABS,
  360. WORK_ORDER_CATEGORY_ORDER,
  361. archiveRows,
  362. categoryChips,
  363. categoryNote,
  364. emptyListText,
  365. equipmentPanels,
  366. eventDetailRows,
  367. eventRows,
  368. filterByCategory,
  369. imageView,
  370. collectionRecordTable,
  371. pagedRows,
  372. panelsForTab,
  373. prioritizeActiveRows,
  374. tabCount,
  375. timelineSource,
  376. workOrderDetailRows,
  377. workOrderRows,
  378. } from '../domain/equipmentDetailView.js'
  379. import { pathOnlyTrack } from '../domain/workOrderTimeline.js'
  380. import VideoMonitorEntry from './VideoMonitorEntry.vue'
  381. import SectionStateTag from './SectionStateTag.vue'
  382. import WorkOrderTimeline from './WorkOrderTimeline.vue'
  383. import PagerNav from './PagerNav.vue'
  384. import MetricStateHint from './MetricStateHint.vue'
  385. const props = defineProps({
  386. /** `stores/detailStore.js` 的 `sections.equipmentAggregate`。 */
  387. aggregate: { type: Object, default: null },
  388. /** 当前选中的设备编码(分区头与图片 alt 用)。 */
  389. deviceCode: { type: String, default: '' },
  390. monitorSource: { type: String, default: 'detail' },
  391. })
  392. const sectionsRoot = ref(null)
  393. const panels = computed(() => equipmentPanels(props.aggregate))
  394. // ---- 票 46 返工第 1 条:标签页切换(弃用六块竖排的长滚动)----
  395. /** 页签清单(domain 给顺序与背后的分区归属;计数给预警/报警/工单三页)。 */
  396. const tabs = computed(() =>
  397. EQUIPMENT_TABS.map((tab) => ({ ...tab, count: tabCount(panels.value, tab.name) })),
  398. )
  399. /** 当前页签(默认「设备档案」;换设备复位,不把 A 的页签停在 B 上)。 */
  400. const activeTab = ref(DEFAULT_EQUIPMENT_TAB)
  401. // 同设备刷新只更新内容;DOM 重排后保留内部及外层详情的滚动位置与可见条目。
  402. let scrollRevision = 0
  403. watch([() => props.aggregate, () => props.deviceCode], (_current, previous) => {
  404. const revision = ++scrollRevision
  405. const device = props.deviceCode
  406. const sameDevice = previous[1] === device
  407. const tab = activeTab.value
  408. const positions = []
  409. const items = Array.from(sectionsRoot.value?.querySelectorAll?.('[data-collection-record-key], [data-history-item]') || [])
  410. for (let element = sectionsRoot.value; element; element = element.parentElement) {
  411. if (typeof document !== 'undefined' && (element === document.body || element === document.documentElement)) break
  412. if (typeof element.scrollTop === 'number' && (element.scrollTop || element.scrollHeight > element.clientHeight)) {
  413. const rect = element.getBoundingClientRect?.()
  414. const anchor = rect && items.find((item) => {
  415. const bounds = item.getBoundingClientRect()
  416. return bounds.bottom > rect.top && bounds.top < rect.bottom
  417. })
  418. const key = anchor && (anchor.getAttribute('data-collection-record-key') || anchor.getAttribute('data-event') || anchor.getAttribute('data-work-order'))
  419. positions.push({ element, top: element.scrollTop, left: element.scrollLeft,
  420. key: key && key !== DETAIL_PLACEHOLDER ? key : '',
  421. offset: anchor ? anchor.getBoundingClientRect().top - rect.top : 0,
  422. })
  423. }
  424. }
  425. nextTick(() => {
  426. if (revision !== scrollRevision || device !== props.deviceCode || (sameDevice && tab !== activeTab.value)) return
  427. if (!sameDevice) {
  428. for (const { element } of positions) { element.scrollTop = 0; element.scrollLeft = 0 }
  429. return
  430. }
  431. const currentItems = Array.from(sectionsRoot.value?.querySelectorAll?.('[data-collection-record-key], [data-history-item]') || [])
  432. for (const { element, top, left, key, offset } of positions) {
  433. if (element.isConnected === false) continue
  434. const anchor = key && currentItems.find((item) =>
  435. [item.getAttribute('data-collection-record-key'), item.getAttribute('data-event'), item.getAttribute('data-work-order')].includes(key))
  436. // 新记录插到前面时仍跟住原来正在看的条目;条目已移除才退回像素位置。
  437. element.scrollTop = anchor ? element.scrollTop + anchor.getBoundingClientRect().top - element.getBoundingClientRect().top - offset : top
  438. element.scrollLeft = left
  439. }
  440. })
  441. }, { flush: 'pre' })
  442. onBeforeUnmount(() => { scrollRevision += 1 })
  443. /** 当前页签要渲染的面板(保留渲染计划自己的次序;图片随档案页出现)。 */
  444. const activePanels = computed(() => panelsForTab(panels.value, activeTab.value))
  445. /**
  446. * 时间线来源(票 12):按工单编码配好的轨迹 + 轨迹分区自己的状态。
  447. * 只算一次 —— 每张工单渲染时都去重算会把 O(n²) 的判断塞进模板。
  448. */
  449. const timeline = computed(() => timelineSource(props.aggregate))
  450. const staleNote = '数据已过期 · 显示最后一次成功数据,不是实时值'
  451. // ---- 票 46:分类查询与历史折叠(**视图本地状态**)----
  452. //
  453. // 为什么不进 store:分类/页码/折叠是「这一屏怎么看」,不是业务事实,也不是全局筛选条件。
  454. // 放进 `filterStore` 会让它跨设备、跨模式生效并触发重新取数 —— 那是把视图偏好
  455. // 冒充成数据条件(票 46 非目标明写「不改变后端事实与只读边界」)。
  456. // 组件卸载即消失;换设备时下面 `watch` 把它清掉,不残留成"A 的分类挂在 B 上"。
  457. /** 各分区当前生效的分类键(`{ [分区名]: 分类键 }`;空串/缺失=全部)。 */
  458. const categories = ref({})
  459. /** 各分区当前页码(1 起;预警/报警/工单三块都接入分页)。 */
  460. const pages = ref({})
  461. const recordTables = computed(() => Object.fromEntries(
  462. panels.value.filter((panel) => panel.kind === 'metrics').map((panel) => [panel.name, collectionRecordTable(panel.section.data, pages.value[panel.name] || 1)]),
  463. ))
  464. function recordTableOf(panel) {
  465. return recordTables.value[panel.name]
  466. }
  467. function recordKey(row) {
  468. if (!row.timeKey) return undefined
  469. return JSON.stringify([row.timeKey, row.cells.filter((cell) => cell.isReading).map((cell) => [cell.key, cell.reading?.label])])
  470. }
  471. const audioPlayer = ref(null)
  472. const activeAudioPath = ref('')
  473. const activeAudioLabel = ref('音频记录')
  474. const activeAudioUrl = computed(() => activeAudioPath.value ? resolveDeviceImageUrl(activeAudioPath.value) : '')
  475. const audioPlacement = ref({ left: 0, top: 0, below: false })
  476. const audioPlayerStyle = computed(() => ({
  477. left: `${audioPlacement.value.left}px`,
  478. top: `${audioPlacement.value.top}px`,
  479. }))
  480. let audioTriggerEl = null
  481. /** 打开播放条:以触发按钮为锚,默认在按钮上方渐入;顶部空间不足时翻到按钮下方。 */
  482. function openRecordAudio(cell, event) {
  483. if (!cell?.audioPath) return
  484. const rect = event?.currentTarget?.getBoundingClientRect?.()
  485. if (rect) {
  486. const margin = 132
  487. const left = Math.min(Math.max(rect.left + rect.width / 2, margin), Math.max(margin, window.innerWidth - margin))
  488. const below = rect.top < 96
  489. audioPlacement.value = { left: Math.round(left), top: Math.round(below ? rect.bottom : rect.top), below }
  490. }
  491. audioTriggerEl = event?.currentTarget || null
  492. activeAudioPath.value = cell.audioPath
  493. activeAudioLabel.value = cell.audioLabel || '音频记录'
  494. }
  495. function closeAudioPlayer() {
  496. if (!activeAudioPath.value) return
  497. activeAudioPath.value = ''
  498. audioTriggerEl = null
  499. }
  500. function audioOutsideHandler(event) {
  501. if (audioPlayer.value?.contains(event.target) || audioTriggerEl?.contains?.(event.target)) return
  502. closeAudioPlayer()
  503. }
  504. function audioEscapeHandler(event) {
  505. if (event.key === 'Escape') closeAudioPlayer()
  506. }
  507. function attachAudioGuards() {
  508. document.addEventListener('pointerdown', audioOutsideHandler, true)
  509. // 滚动时按钮会离开锚点,直接收起(capture 才能收到各滚动容器不冒泡的 scroll)。
  510. window.addEventListener('scroll', closeAudioPlayer, true)
  511. window.addEventListener('keydown', audioEscapeHandler, true)
  512. }
  513. function detachAudioGuards() {
  514. if (typeof document === 'undefined' || typeof window === 'undefined') return
  515. document.removeEventListener('pointerdown', audioOutsideHandler, true)
  516. window.removeEventListener('scroll', closeAudioPlayer, true)
  517. window.removeEventListener('keydown', audioEscapeHandler, true)
  518. }
  519. watch(activeAudioPath, (open) => { if (open) attachAudioGuards(); else detachAudioGuards() })
  520. onBeforeUnmount(detachAudioGuards)
  521. /** 某分区的行视图(未过滤)。 */
  522. function rowsOf(panel) {
  523. const data = panel.section.data
  524. if (panel.kind === 'archive') return archiveRows(data)
  525. if (panel.kind === 'orders') return workOrderRows(data)
  526. return eventRows(data)
  527. }
  528. /** 这一块有没有分类查询(预警/报警/工单有;档案/指标/图片没有列表语义)。 */
  529. function isFilterable(panel) {
  530. return panel.kind === 'events' || panel.kind === 'orders'
  531. }
  532. /** 某分区的分类 chips(只有 events/orders 两类有;其它块回空数组,模板据此不画 chip 条)。 */
  533. function chipsOf(panel) {
  534. if (panel.kind !== 'events' && panel.kind !== 'orders') return []
  535. const order = panel.kind === 'orders' ? WORK_ORDER_CATEGORY_ORDER : EVENT_CATEGORY_ORDER
  536. return categoryChips(rowsOf(panel), order)
  537. }
  538. function activeCategory(panelName) {
  539. return categories.value[panelName] || ''
  540. }
  541. /** 点同一项取消(与票 37 的类型筛选同款交互:不需要额外的"清除"按钮就能撤)。 */
  542. function toggleCategory(panelName, key) {
  543. categories.value = { ...categories.value, [panelName]: activeCategory(panelName) === key ? '' : key }
  544. // 换分类 = 换结果集,页码回第 1 页(停在第 3 页看新分类会直接显示"没有记录")。
  545. pages.value = { ...pages.value, [panelName]: 1 }
  546. }
  547. /** 过滤后的行(chip 只筛视图,不动数据、不发请求)。 */
  548. function visibleRows(panel) {
  549. const rows = filterByCategory(rowsOf(panel), activeCategory(panel.name))
  550. if (panel.kind !== 'events' && panel.kind !== 'orders') return rows
  551. return prioritizeActiveRows(rows)
  552. }
  553. /** 过滤 + 分页后的窗口(domain 单源 `pagedRows` 收边界;票 59 三清单同一条路径,组件不再备第二份兜底形状)。 */
  554. function pagedOf(panel) {
  555. return pagedRows(visibleRows(panel), pages.value[panel.name] || 1)
  556. }
  557. /**
  558. * 翻页唯一出口(票 59):PagerNav 的前后页/页码直达/跳页都带**目标页码(1 起)**回来。
  559. * 这里只存状态,不另做夹取——越界统一由 domain `pagedRows` 在下一帧渲染时夹回(条文 6)。
  560. */
  561. function gotoPage(panel, target) {
  562. pages.value = { ...pages.value, [panel.name]: Number(target) || 1 }
  563. }
  564. function categoryNoteText(panel) {
  565. return categoryNote(chipsOf(panel), activeCategory(panel.name))
  566. }
  567. /**
  568. * 票 115 优化:未解除/未结案条目的展开状态。
  569. *
  570. * 切换页签时清空手动状态 ⇒ 进入对应页签时,active 条目默认展开;
  571. * 用户手动收起/展开后以手动状态为准(manualOpen[key]),不会每帧被默认值顶回去。
  572. */
  573. const manualOpen = ref({})
  574. function detailKey(panel, item, index) {
  575. return panel.name + '|' + (item.code || index)
  576. }
  577. function isDetailOpen(panel, item, index) {
  578. const key = detailKey(panel, item, index)
  579. return Object.hasOwn(manualOpen.value, key) ? manualOpen.value[key] : item.active === true
  580. }
  581. function rememberDetailToggle(panel, item, index, event) {
  582. const key = detailKey(panel, item, index)
  583. manualOpen.value = { ...manualOpen.value, [key]: event.target.open }
  584. }
  585. watch(activeTab, () => {
  586. manualOpen.value = {}
  587. })
  588. // 换设备:页签、分类条件与页码全部复位(不同设备的分布互不相干,留着就是拿 A 的状态看 B)。
  589. watch(
  590. () => props.deviceCode,
  591. () => {
  592. categories.value = {}
  593. pages.value = {}
  594. manualOpen.value = {}
  595. activeTab.value = DEFAULT_EQUIPMENT_TAB
  596. },
  597. )
  598. /**
  599. * 取某张工单的轨迹。
  600. *
  601. * 轨迹分区有数据 → 用它那条**完整**轨迹(按工单编码配,票 12 验收第 2 条);
  602. * 分区没数据(失败/空)→ 退回**只有状态路径**的那份(`pathOnlyTrack`),
  603. * 因为"这张工单现在在哪一态"来自工单列表,与轨迹成败无关,不该一起丢掉。
  604. */
  605. function trackOfOrder(order) {
  606. if (!order.code) return null
  607. const full = timeline.value.hasContent ? timeline.value.tracks[order.code] : null
  608. return full || pathOnlyTrack(order)
  609. }
  610. function image(panel) {
  611. const view = imageView(panel.section.data)
  612. return { ...view, url: resolveDeviceImageUrl(view.url) }
  613. }
  614. const imageDialog = ref(null)
  615. const imageFailed = ref(false)
  616. const imageLoading = ref(true)
  617. const currentImageUrl = computed(() => {
  618. const panel = panels.value.find((item) => item.kind === 'image')
  619. return panel ? image(panel).url : ''
  620. })
  621. watch([() => props.deviceCode, currentImageUrl], () => {
  622. imageDialog.value?.close()
  623. imageFailed.value = false
  624. imageLoading.value = true
  625. })
  626. watch([() => props.deviceCode, activeTab], () => {
  627. closeAudioPlayer()
  628. })
  629. function closeImageBackdrop(event) {
  630. if (event.target !== imageDialog.value) return
  631. const rect = imageDialog.value.getBoundingClientRect()
  632. if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) imageDialog.value.close()
  633. }
  634. function emptyList(label) {
  635. return emptyListText(label)
  636. }
  637. function isPlaceholder(text) {
  638. return text === DETAIL_PLACEHOLDER
  639. }
  640. </script>
  641. <style scoped>
  642. .collection-table-wrap { min-width: 0; overflow-x: hidden; }
  643. .collection-table {
  644. width: 100%;
  645. table-layout: fixed;
  646. border-collapse: collapse;
  647. color: var(--text-primary, #e5f4ff);
  648. font-size: calc(11 * var(--u, 1px));
  649. }
  650. .collection-table th,
  651. .collection-table td {
  652. min-width: 0;
  653. padding: calc(4 * var(--u, 1px)) calc(3 * var(--u, 1px));
  654. border: 1px solid rgba(69, 161, 226, .16);
  655. text-align: left;
  656. vertical-align: middle;
  657. }
  658. .collection-table th {
  659. color: #a6d8ff;
  660. background: rgba(29, 83, 124, .25);
  661. font-size: calc(10 * var(--u, 1px));
  662. letter-spacing: .02em;
  663. line-height: 1.2;
  664. }
  665. .collection-table__heading {
  666. display: inline-block;
  667. max-width: 100%;
  668. overflow: hidden;
  669. color: inherit;
  670. font-weight: 600;
  671. text-overflow: ellipsis;
  672. vertical-align: bottom;
  673. white-space: nowrap;
  674. word-break: keep-all;
  675. }
  676. .collection-table__unit {
  677. white-space: normal;
  678. word-break: break-word;
  679. overflow-wrap: anywhere;
  680. font-size: .92em;
  681. }
  682. .collection-table td { background: rgba(7, 27, 48, .35); font-variant-numeric: tabular-nums; line-height: 1.35; overflow-wrap: anywhere; }
  683. .collection-table tbody:not(.collection-table__group--latest):nth-of-type(even) td { background: rgba(7, 27, 48, .22); }
  684. .collection-table tbody:not(.collection-table__group--latest) tr:hover td { background: rgba(1, 171, 234, .12); }
  685. /* 渐变只画在整次采集的行组上,td 透明;长表 rowspan 的时间格也共用同一张背景。 */
  686. .collection-table__group--latest {
  687. --reading-color: var(--lv4, #27d17a);
  688. background: linear-gradient(90deg, color-mix(in srgb, var(--collection-accent) 24%, transparent), rgba(7, 27, 48, .28) 100%);
  689. }
  690. .collection-table .collection-table__group--latest td {
  691. background: transparent;
  692. border-color: color-mix(in srgb, var(--collection-accent) 30%, transparent);
  693. }
  694. .collection-table__group--latest tr:first-child td:first-child { box-shadow: inset 3px 0 0 var(--collection-accent); }
  695. .collection-table__group--latest:hover {
  696. background: linear-gradient(90deg, color-mix(in srgb, var(--collection-accent) 30%, transparent), rgba(7, 27, 48, .34) 100%);
  697. }
  698. .collection-table__latest-badge {
  699. display: inline-flex;
  700. align-items: center;
  701. margin-bottom: calc(2 * var(--u, 1px));
  702. padding: 0 calc(5 * var(--u, 1px));
  703. border: 1px solid color-mix(in srgb, var(--collection-accent) 65%, transparent);
  704. border-radius: 999px;
  705. background: color-mix(in srgb, var(--collection-accent) 16%, transparent);
  706. color: var(--collection-accent);
  707. font-size: var(--fs-caption, 10.5px);
  708. line-height: calc(16 * var(--u, 1px));
  709. letter-spacing: .04em;
  710. white-space: nowrap;
  711. }
  712. /* 采集时间已拆两行(日期 10 字符是最宽一行),定宽按内容收紧,
  713. 余量让给右侧指标列(维护者 2026-11-06 反馈:125u 约剩一半空白)。 */
  714. .collection-table__time { width: calc(82 * var(--u, 1px)); }
  715. .collection-table__time-date,
  716. .collection-table__time-clock { display: block; white-space: nowrap; }
  717. .collection-table__audio-col { width: calc(72 * var(--u, 1px)); }
  718. .collection-table__audio-button {
  719. display: inline-flex;
  720. align-items: center;
  721. justify-content: center;
  722. gap: calc(3 * var(--u, 1px));
  723. width: 100%;
  724. min-width: 0;
  725. padding: calc(3 * var(--u, 1px)) calc(4 * var(--u, 1px));
  726. border: 1px solid rgba(1, 171, 234, .5);
  727. border-radius: 999px;
  728. background: rgba(1, 91, 150, .26);
  729. color: #b8e8ff;
  730. font: inherit;
  731. white-space: nowrap;
  732. cursor: pointer;
  733. }
  734. .collection-table__audio-button:hover,
  735. .collection-table__audio-button:focus-visible { border-color: #ffb020; color: #ffd08a; outline: none; }
  736. /* 指标读数排版(wide / long 两种布局共用这一套,不因列名不同而脱节):
  737. 字体与左右侧面板 KPI 数值(.panel-kpi__value)同档 --num(Rajdhani / Share Tech Mono…,
  738. 单源 tokens.css);字号按表格密度自 22u KPI 档收到 13.5u;满不透明度的 --text-strong
  739. 高亮白并加粗,读数在大屏上一眼可读;读数相对单元格左边界略微缩进。
  740. long 布局的「指标名:读数」整条,名称段用正文样式、读数段复用本套字体(见下)。
  741. 报警行以更高优先级抢回红色,占位灰由后置 is-muted 覆盖。 */
  742. .collection-table__value,
  743. .collection-table__reading-value {
  744. color: var(--reading-color, var(--text-strong, #fff));
  745. opacity: 1;
  746. font-family: var(--num, monospace);
  747. font-size: calc(13.5 * var(--u, 1px));
  748. font-weight: 700;
  749. letter-spacing: .02em;
  750. text-shadow: 0 0 8px color-mix(in srgb, currentColor 24%, transparent);
  751. }
  752. .collection-table__value {
  753. padding-left: calc(4 * var(--u, 1px));
  754. }
  755. .collection-table__reading-label {
  756. color: var(--muted, #5680b0);
  757. font-size: calc(11 * var(--u, 1px));
  758. font-weight: 400;
  759. }
  760. .collection-table__reading {
  761. display: grid;
  762. grid-template-columns: minmax(0, 48%) minmax(0, 1fr);
  763. column-gap: calc(14 * var(--u, 1px));
  764. align-items: baseline;
  765. padding-left: calc(4 * var(--u, 1px));
  766. text-align: left;
  767. }
  768. .collection-table__reading-result {
  769. display: inline-flex;
  770. min-width: 0;
  771. align-items: baseline;
  772. justify-content: flex-start;
  773. gap: calc(6 * var(--u, 1px));
  774. }
  775. .collection-table__reading-result > span:first-child { min-width: 0; }
  776. .collection-table__value.is-muted,
  777. .collection-table__reading-value.is-muted { color: var(--muted, #5680b0); font-weight: 400; text-shadow: none; }
  778. .collection-table__label {
  779. color: var(--muted, #5680b0);
  780. font-size: calc(10.5 * var(--u, 1px));
  781. }
  782. .equipment-sections,
  783. .equipment-sections *,
  784. .equipment-sections *::before,
  785. .equipment-sections *::after {
  786. box-sizing: border-box;
  787. }
  788. .equipment-sections {
  789. display: grid;
  790. gap: calc(6 * var(--u, 1px));
  791. }
  792. /*
  793. 票 46 返工第 1 条:标签页条(与票 13 的分段切换、票 46 的分类 chips 同一套视觉语言,
  794. 但它是**分区切换**不是筛选)。计数挂在页签右上角;默认页 = 设备档案。
  795. */
  796. .equipment-tabs {
  797. display: flex;
  798. flex-wrap: wrap;
  799. gap: calc(3 * var(--u, 1px));
  800. }
  801. .equipment-tabs__tab {
  802. display: inline-flex;
  803. align-items: center;
  804. gap: calc(3 * var(--u, 1px));
  805. padding: calc(2 * var(--u, 1px)) calc(9 * var(--u, 1px));
  806. border: 1px solid rgba(1, 171, 234, 0.32);
  807. border-radius: calc(8 * var(--u, 1px));
  808. background: rgba(0, 20, 72, 0.6);
  809. color: var(--text-2);
  810. font: inherit;
  811. font-size: var(--fs-caption, 10.5px);
  812. letter-spacing: 0.04em;
  813. cursor: pointer;
  814. }
  815. .equipment-tabs__tab:hover,
  816. .equipment-tabs__tab:focus-visible {
  817. border-color: rgba(1, 171, 234, 0.6);
  818. color: var(--text-strong);
  819. outline: none;
  820. }
  821. .equipment-tabs__tab.is-active {
  822. border-color: rgba(0, 184, 255, 0.85);
  823. background: linear-gradient(180deg, rgba(1, 171, 234, 0.85), rgba(0, 82, 168, 0.85));
  824. color: var(--text-strong);
  825. }
  826. .equipment-tabs__count {
  827. font-family: var(--num, monospace);
  828. color: var(--lv2, #ffb020);
  829. }
  830. .equipment-tabs__tab.is-active .equipment-tabs__count {
  831. color: var(--text-strong);
  832. }
  833. .equipment-tab__body {
  834. display: grid;
  835. gap: calc(6 * var(--u, 1px));
  836. }
  837. /* 票 59:分页控件与样式移交共享 PagerNav.vue,组件不再自画 pager。 */
  838. /* 每块分区:自己的边框与抬头 —— 失败的那一块缩成一行提示,成功的照常展开。 */
  839. .equipment-section {
  840. display: grid;
  841. gap: calc(3 * var(--u, 1px));
  842. padding: calc(5 * var(--u, 1px)) var(--space-2, 8px);
  843. border: 1px solid rgba(1, 171, 234, 0.24);
  844. border-radius: calc(8 * var(--u, 1px));
  845. background: var(--inset, rgba(2, 10, 20, 0.55));
  846. }
  847. .equipment-section[data-panel-state='failed'],
  848. .equipment-section[data-panel-state='unavailable'] {
  849. border-color: rgba(255, 59, 82, 0.4);
  850. }
  851. .equipment-section[data-panel-state='stale'] {
  852. border-color: rgba(255, 176, 32, 0.45);
  853. }
  854. .equipment-section__head {
  855. display: flex;
  856. align-items: center;
  857. justify-content: space-between;
  858. gap: var(--space-2, 8px);
  859. }
  860. .equipment-section__title {
  861. min-width: 0;
  862. overflow: hidden;
  863. color: var(--text);
  864. font-size: var(--fs-body, 13px);
  865. font-weight: 700;
  866. letter-spacing: 0.06em;
  867. white-space: nowrap;
  868. text-overflow: ellipsis;
  869. }
  870. .equipment-section__stale {
  871. margin: 0;
  872. color: var(--lv2, #ffb020);
  873. font-size: var(--fs-caption, 10.5px);
  874. letter-spacing: 0.04em;
  875. }
  876. .equipment-section__rows {
  877. display: grid;
  878. gap: calc(2 * var(--u, 1px));
  879. margin: 0;
  880. }
  881. .equipment-section__row {
  882. display: grid;
  883. grid-template-columns: calc(58 * var(--u, 1px)) minmax(0, 1fr);
  884. align-items: baseline;
  885. gap: var(--space-2, 8px);
  886. border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
  887. }
  888. .equipment-section__row dt {
  889. color: var(--muted, #5680b0);
  890. font-size: var(--fs-caption, 10.5px);
  891. letter-spacing: 0.04em;
  892. }
  893. .equipment-section__row dd {
  894. margin: 0;
  895. color: var(--text, #d7ecff);
  896. min-width: 0;
  897. overflow-wrap: anywhere;
  898. word-break: normal;
  899. }
  900. .equipment-section__list {
  901. display: grid;
  902. gap: calc(4 * var(--u, 1px));
  903. }
  904. .equipment-section__item {
  905. display: grid;
  906. gap: 1px;
  907. padding-bottom: calc(3 * var(--u, 1px));
  908. border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
  909. }
  910. /*
  911. 票 46:分类 chips 条(预警/报警按等级、工单按状态)。
  912. 与票 13 的分段切换同一套视觉语言(胶囊 + 选中高亮),但它是**视图过滤**不是模式切换。
  913. 计数直接挂在 chip 上 —— 分类前有多少条要看得见,筛完剩几条由列表自己说话。
  914. */
  915. .equipment-section__chips {
  916. display: flex;
  917. flex-wrap: wrap;
  918. gap: calc(3 * var(--u, 1px));
  919. }
  920. .equipment-section__chip {
  921. display: inline-flex;
  922. align-items: center;
  923. gap: calc(3 * var(--u, 1px));
  924. padding: 1px calc(7 * var(--u, 1px));
  925. border: 1px solid rgba(1, 171, 234, 0.32);
  926. border-radius: calc(8 * var(--u, 1px));
  927. background: rgba(0, 20, 72, 0.6);
  928. color: var(--text-2);
  929. font: inherit;
  930. font-size: var(--fs-caption, 10.5px);
  931. letter-spacing: 0.04em;
  932. cursor: pointer;
  933. }
  934. .equipment-section__chip:hover,
  935. .equipment-section__chip:focus-visible {
  936. border-color: rgba(1, 171, 234, 0.6);
  937. color: var(--text-strong);
  938. outline: none;
  939. }
  940. .equipment-section__chip.is-active {
  941. border-color: rgba(0, 184, 255, 0.85);
  942. background: linear-gradient(180deg, rgba(1, 171, 234, 0.85), rgba(0, 82, 168, 0.85));
  943. color: var(--text-strong);
  944. }
  945. .equipment-section__chip-count {
  946. font-family: var(--num, monospace);
  947. }
  948. /*
  949. 票 46:历史条目默认折叠(`<details>` 原生语义:键盘可达、展开态自带 aria-expanded)。
  950. summary 一行读完关键信息;展开后的详情走 dl 键值行。去掉原生三角,换成边框反馈。
  951. */
  952. .equipment-section__item--collapsible {
  953. gap: calc(2 * var(--u, 1px));
  954. /* 每条数据一个线框:普通项也由整块持有边框,与高亮项同一层,不再出现错位/重叠。 */
  955. padding: calc(4 * var(--u, 1px)) calc(6 * var(--u, 1px));
  956. border: 1px solid rgba(1, 171, 234, 0.22);
  957. border-radius: calc(6 * var(--u, 1px));
  958. }
  959. .equipment-section__item--collapsible[open] {
  960. border-color: rgba(1, 171, 234, 0.5);
  961. }
  962. .equipment-section__item--collapsible[open] {
  963. border-bottom-color: rgba(1, 171, 234, 0.35);
  964. padding-bottom: calc(5 * var(--u, 1px));
  965. }
  966. /*
  967. 事件按等级色强调;未分级预警黄、报警红;非终态工单天蓝。
  968. 2026-11-12 维护者反馈「高亮框和普通框对不上、边框重叠」:原来整块用 inset box-shadow
  969. 画了一圈,头部又各带一条 border + 背景,两层框叠在一起。现在**每条只保留一个线框**:
  970. 普通项用整块 border,高亮项把同一个 border 换成等级色,头部不再画框。
  971. */
  972. .equipment-section__item--active {
  973. padding-left: calc(6 * var(--u, 1px));
  974. border: 1px solid color-mix(in srgb, var(--detail-accent) 55%, transparent);
  975. border-radius: calc(6 * var(--u, 1px));
  976. background: linear-gradient(90deg, color-mix(in srgb, var(--detail-accent) 20%, transparent), rgba(7, 27, 48, .12) 78%);
  977. box-shadow: inset 3px 0 0 var(--detail-accent), 0 0 12px color-mix(in srgb, var(--detail-accent) 12%, transparent);
  978. }
  979. .equipment-section__item--active[open] {
  980. border-color: color-mix(in srgb, var(--detail-accent) 78%, transparent);
  981. background: linear-gradient(90deg, color-mix(in srgb, var(--detail-accent) 26%, transparent), rgba(7, 27, 48, .16) 82%);
  982. }
  983. /* 高亮项的头部不另画框,避免与整块边框形成双层。 */
  984. .equipment-section__item--active .equipment-section__item-head,
  985. .equipment-section__item--collapsible.equipment-section__item--active[open] .equipment-section__item-head {
  986. border-color: transparent;
  987. background: transparent;
  988. }
  989. .equipment-section__active-badge {
  990. display: inline-flex;
  991. align-items: center;
  992. padding: 0 calc(6 * var(--u, 1px));
  993. border: 1px solid var(--detail-accent);
  994. border-radius: 999px;
  995. background: color-mix(in srgb, var(--detail-accent) 24%, transparent);
  996. color: color-mix(in srgb, var(--detail-accent) 55%, white);
  997. font-size: var(--fs-caption, 10.5px);
  998. line-height: calc(16 * var(--u, 1px));
  999. letter-spacing: .04em;
  1000. white-space: nowrap;
  1001. }
  1002. .equipment-section__item-desc {
  1003. color: var(--text-2, #88b7e8);
  1004. font-size: var(--fs-caption, 10.5px);
  1005. line-height: 1.5;
  1006. overflow-wrap: anywhere;
  1007. }
  1008. .equipment-section__item-more {
  1009. justify-self: end;
  1010. color: var(--muted, #5680b0);
  1011. font-size: var(--fs-caption, 10.5px);
  1012. letter-spacing: .02em;
  1013. }
  1014. .equipment-section__item-head:hover .equipment-section__item-more { color: var(--cyan-hi, #00b8ff); }
  1015. .equipment-section__item-head {
  1016. display: grid;
  1017. gap: 1px;
  1018. cursor: pointer;
  1019. list-style: none;
  1020. padding: calc(1 * var(--u, 1px)) calc(3 * var(--u, 1px));
  1021. margin: calc(-1 * var(--u, 1px)) calc(-3 * var(--u, 1px)) 0;
  1022. border: 1px solid transparent;
  1023. border-radius: calc(8 * var(--u, 1px));
  1024. }
  1025. .equipment-section__item-head::-webkit-details-marker {
  1026. display: none;
  1027. }
  1028. /* hover / 展开只改背景,不再给头部加第二条框——整块边框已经是这一行的唯一线框。 */
  1029. .equipment-section__item-head:hover {
  1030. background: rgba(0, 53, 156, 0.28);
  1031. }
  1032. .equipment-section__item--collapsible[open] .equipment-section__item-head {
  1033. background: rgba(0, 53, 156, 0.22);
  1034. }
  1035. .equipment-section__detail {
  1036. display: grid;
  1037. gap: calc(1 * var(--u, 1px));
  1038. margin: calc(2 * var(--u, 1px)) 0 0;
  1039. padding: calc(3 * var(--u, 1px)) var(--space-2, 8px);
  1040. border-left: 2px solid rgba(1, 171, 234, 0.4);
  1041. background: rgba(2, 10, 20, 0.4);
  1042. }
  1043. .equipment-section__detail-row {
  1044. display: grid;
  1045. grid-template-columns: calc(58 * var(--u, 1px)) minmax(0, 1fr);
  1046. align-items: baseline;
  1047. gap: var(--space-2, 8px);
  1048. }
  1049. .equipment-section__detail-row dt {
  1050. color: var(--muted, #5680b0);
  1051. font-size: var(--fs-caption, 10.5px);
  1052. letter-spacing: 0.04em;
  1053. }
  1054. .equipment-section__detail-row dd {
  1055. margin: 0;
  1056. min-width: 0;
  1057. color: var(--text, #d7ecff);
  1058. font-size: var(--fs-caption, 10.5px);
  1059. overflow-wrap: anywhere;
  1060. }
  1061. .equipment-section__item-name {
  1062. color: var(--text, #d7ecff);
  1063. font-size: var(--fs-caption, 10.5px);
  1064. }
  1065. .equipment-section__item-name em {
  1066. color: var(--muted, #5680b0);
  1067. font-style: normal;
  1068. }
  1069. .equipment-section__value {
  1070. color: var(--text);
  1071. font-family: var(--num, monospace);
  1072. font-size: var(--fs-body, 13px);
  1073. }
  1074. .equipment-section__value em {
  1075. color: var(--muted, #5680b0);
  1076. font-style: normal;
  1077. font-size: var(--fs-caption, 10.5px);
  1078. }
  1079. .equipment-section__meta {
  1080. display: flex;
  1081. flex-wrap: wrap;
  1082. gap: var(--space-2, 8px);
  1083. color: var(--text-2, #88b7e8);
  1084. font-size: var(--fs-caption, 10.5px);
  1085. }
  1086. .equipment-section__note {
  1087. margin: 0;
  1088. color: var(--muted, #5680b0);
  1089. font-size: var(--fs-caption, 10.5px);
  1090. }
  1091. .equipment-section__figure {
  1092. display: grid;
  1093. gap: calc(3 * var(--u, 1px));
  1094. margin: 0;
  1095. }
  1096. .equipment-section__img {
  1097. display: block;
  1098. width: 100%;
  1099. max-height: calc(140 * var(--u, 1px));
  1100. object-fit: cover;
  1101. border: 1px solid rgba(1, 171, 234, 0.35);
  1102. border-radius: calc(8 * var(--u, 1px));
  1103. }
  1104. .equipment-section__hint,
  1105. .equipment-section__missing,
  1106. .is-muted {
  1107. margin: 0;
  1108. color: var(--muted, #5680b0);
  1109. font-size: var(--fs-caption, 10.5px);
  1110. letter-spacing: 0.04em;
  1111. }
  1112. .equipment-section__missing {
  1113. color: var(--lv2, #ffb020);
  1114. }
  1115. .equipment-section__mono {
  1116. font-family: var(--num, monospace);
  1117. letter-spacing: 0.04em;
  1118. }
  1119. .equipment-section__image-button {
  1120. display: grid;
  1121. gap: 6px;
  1122. width: 100%;
  1123. padding: 0;
  1124. border: 0;
  1125. background: transparent;
  1126. color: var(--cyan-hi);
  1127. font: inherit;
  1128. cursor: zoom-in;
  1129. }
  1130. .equipment-image-viewer {
  1131. width: min(90vw, 1200px);
  1132. max-height: 90vh;
  1133. padding: 16px;
  1134. border: 1px solid var(--cyan);
  1135. border-radius: calc(8 * var(--u, 1px));
  1136. background: var(--bg-0);
  1137. color: var(--text);
  1138. }
  1139. .equipment-image-viewer::backdrop { background: rgba(1, 5, 20, .8); backdrop-filter: blur(6px); }
  1140. .equipment-image-viewer img { display: block; width: 100%; height: auto; max-height: 78vh; object-fit: contain; }
  1141. .equipment-image-viewer__close { display: block; margin-left: auto; margin-bottom: 8px; cursor: pointer; }
  1142. /* 音频播放条:贴播放键上方渐入的小浮层,不遮罩、不虚化整屏 UI。 */
  1143. .collection-audio-player {
  1144. position: fixed;
  1145. z-index: calc(var(--z-columns, 30) + 5);
  1146. width: min(264px, 88vw);
  1147. padding: calc(6 * var(--u, 1px)) calc(8 * var(--u, 1px)) calc(8 * var(--u, 1px));
  1148. border: 1px solid var(--cyan, #01abea);
  1149. border-radius: calc(8 * var(--u, 1px));
  1150. background: rgba(4, 20, 38, .97);
  1151. box-shadow: 0 8px 22px rgba(0, 0, 0, .45);
  1152. color: var(--text, #e5f4ff);
  1153. transform: translate(-50%, calc(-100% - 8px));
  1154. animation: collection-audio-pop-above .18s ease-out;
  1155. }
  1156. .collection-audio-player.is-below {
  1157. transform: translate(-50%, 8px);
  1158. animation-name: collection-audio-pop-below;
  1159. }
  1160. @keyframes collection-audio-pop-above {
  1161. from { opacity: 0; transform: translate(-50%, calc(-100% - 2px)); }
  1162. }
  1163. @keyframes collection-audio-pop-below {
  1164. from { opacity: 0; transform: translate(-50%, 2px); }
  1165. }
  1166. .collection-audio-player__head {
  1167. display: flex;
  1168. align-items: center;
  1169. justify-content: space-between;
  1170. gap: calc(6 * var(--u, 1px));
  1171. margin-bottom: calc(4 * var(--u, 1px));
  1172. }
  1173. .collection-audio-player__label {
  1174. min-width: 0;
  1175. overflow: hidden;
  1176. color: #a6d8ff;
  1177. font-size: var(--fs-caption, 10.5px);
  1178. text-overflow: ellipsis;
  1179. white-space: nowrap;
  1180. }
  1181. .collection-audio-player__close {
  1182. flex: none;
  1183. width: calc(20 * var(--u, 1px));
  1184. height: calc(20 * var(--u, 1px));
  1185. padding: 0;
  1186. border: 1px solid rgba(1, 171, 234, .5);
  1187. border-radius: 999px;
  1188. background: rgba(1, 91, 150, .26);
  1189. color: inherit;
  1190. font-size: calc(12 * var(--u, 1px));
  1191. line-height: 1;
  1192. cursor: pointer;
  1193. }
  1194. .collection-audio-player__audio { display: block; width: 100%; max-width: 100%; height: calc(32 * var(--u, 1px)); }
  1195. </style>