EquipmentDetailSections.vue 54 KB

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