EquipmentDetailSections.vue 57 KB

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