| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265 |
- <!--
- 设备详情的分区(票 11):档案 / 指标 / 预警 / 报警 / 工单状态 / 设备图片。
- 票 46 返工(维护者 2026-10-15 第 1 条):**弃用六块竖排的长滚动**,改为标签页切换
- 设备档案(默认,图片归此页下方)|最新指标|预警信息|报警信息|工单状态;
- 预警/报警列表每页最多 10 条。页签计划与分页边界都是
- domain 纯函数(`EQUIPMENT_TABS`/`panelsForTab`/`pagedRows`),组件只画结论。
- 票 59(统一分页批次一):**三清单(预警/报警/工单)都接共享 `PagerNav.vue`**
- (页码直达 + 输入框跳页 + 计数行),翻页窗口计算归 `domain/pagination.js` 单源,
- 本组件不再自画 pager、不再有第二份翻页算术;前后页/页码/跳页共用唯一出口
- `gotoPage(panel, target)`,越界夹回由 `pagedRows` 做。
- **渲染计划仍是六块独立分区**(t11 D1/D9):标签页只改呈现归属,
- 每块仍独立成败;指标页改读独立采集记录分区,按当前需求不画状态点/过期说明。
- 档案读失败但指标可用仍各自呈现,不能共用一个成败。
- 三种「没内容」在界面上的区别(票 16 的词汇,措辞由 domain/equipmentDetailView.js 给):
- 空数据 EMPTY → 「该分区当前没有数据」
- 失败 FAILED → 「该分区加载失败(其它分区不受影响)」
- 不可用 UNAVAILABLE → 「该分区数据不可用(其它分区不受影响)」
- 过期 STALE 是**有值**:内容照画,只是标签说「数据已过期,显示最后一次成功数据」。
- 事件高亮采用既有四级颜色词表;数值预警黄/报警红与等级提示点分别呈现。
- 票 12 在**工单状态这块里**接上了状态时间线(不新增第七块面板):
- 每张工单下面挂它自己的那条轨迹(`timelineSource()` 按工单编码配好)。
- 轨迹分区**独立于工单列表成败** —— 后端可在工单列表成功时单独把 `timelines` 标 FAILED
- (`SOURCE_SECTION_FAILED`),那时界面必须说「轨迹没取到」而不是"没有动作记录"。
- -->
- <template>
- <div ref="sectionsRoot" class="equipment-sections" data-equipment-sections :data-device-code="deviceCode">
- <!--
- 票 46 返工:标签页条。计数挂在页签上(预警/报警/工单才有;
- 维护者要「避免长滚动列表查找困难」——点之前就知道每页后面有多少条)。
- -->
- <div class="equipment-tabs" role="tablist" aria-label="设备详情分区切换" data-equipment-tabs>
- <button
- v-for="tab in tabs"
- :key="tab.name"
- type="button"
- role="tab"
- class="equipment-tabs__tab"
- :class="{ 'is-active': tab.name === activeTab }"
- :aria-selected="tab.name === activeTab"
- :data-equipment-tab="tab.name"
- :data-tab-count="tab.count || ''"
- @click="activeTab = tab.name"
- >
- {{ tab.label }}<b v-if="tab.count" class="equipment-tabs__count">{{ tab.count }}</b>
- </button>
- </div>
- <!--
- 当前页签的面板(档案页含两块:档案 + 设备图片,所以 tabpanel 挂在**包装层**
- 而不是每块 section 上 —— 一个页签对应一个 tabpanel 才是正确的可访问语义)。
- -->
- <p v-if="aggregate?.retained" class="equipment-section__hint" data-detail-refresh-retained>本次详情更新未完成,保留上次已加载内容。</p>
- <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>
- <VideoMonitorEntry v-if="activeTab === 'metrics'" :device-code="deviceCode" :monitor-source="monitorSource" />
- <section
- v-for="panel in activePanels"
- :key="panel.name"
- class="equipment-section"
- :data-equipment-section="panel.name"
- :data-panel-state="panel.section.state || 'empty'"
- :data-panel-retained="panel.section.retained ? 'true' : 'false'"
- >
- <header class="equipment-section__head">
- <span class="equipment-section__title">{{ panel.label }}</span>
- <SectionStateTag v-if="panel.kind !== 'metrics'" :name="panel.name" :section="panel.section" />
- </header>
- <!-- 过期分区在内容之上再点一次题(颜色之外的第二种提示,灰度下也读得出) -->
- <p v-if="panel.kind !== 'metrics' && panel.section.retained" class="equipment-section__stale" data-section-stale-note>
- {{ staleNote }}
- </p>
- <!-- ---------- 有内容:按 kind 分派 ---------- -->
- <template v-if="panel.hasContent">
- <!--
- 票 46:分类查询条 —— 预警/报警/工单**共用这一块**(同一组绑定只写一处)。
- 分类归组在 domain(等级复用票 13 词表、工单复用 0–7 字典),组件只画 chips;
- 点分类只筛视图,不发请求、不动选择(筛选变化不丢详情上下文)。
- -->
- <template v-if="isFilterable(panel) && chipsOf(panel).length > 1">
- <div class="equipment-section__chips" role="group" :aria-label="`${panel.label}分类查询`" data-category-bar>
- <button
- v-for="chip in chipsOf(panel)"
- :key="chip.key || 'all'"
- type="button"
- class="equipment-section__chip"
- :class="{ 'is-active': chip.key === activeCategory(panel.name) }"
- :aria-pressed="chip.key === activeCategory(panel.name)"
- :data-category-chip="`${panel.name}:${chip.key || 'all'}`"
- @click="toggleCategory(panel.name, chip.key)"
- >
- {{ chip.label }} <b class="equipment-section__chip-count" data-category-count>{{ chip.count }}</b>
- </button>
- </div>
- <p v-if="categoryNoteText(panel)" class="equipment-section__note" data-category-note>{{ categoryNoteText(panel) }}</p>
- </template>
- <!-- 档案:键值行 -->
- <dl v-if="panel.kind === 'archive'" class="equipment-section__rows" data-archive-rows>
- <div v-for="row in rowsOf(panel)" :key="row.key" class="equipment-section__row" :data-archive-row="row.key">
- <dt>{{ row.label }}</dt>
- <dd :class="{ 'is-muted': isPlaceholder(row.value), 'equipment-section__mono': row.mono }">{{ row.value }}</dd>
- </div>
- </dl>
- <!-- 采集记录:最近50行;分页后只合并当前页相同采集时间。 -->
- <div v-else-if="panel.kind === 'metrics'" class="equipment-section__list" data-collection-records>
- <p class="equipment-section__note">采集记录(最近50条)</p>
- <div class="collection-table-wrap">
- <table v-if="recordTableOf(panel).total" class="collection-table" data-collection-table aria-label="设备最近采集记录">
- <thead>
- <tr>
- <th v-for="column in recordTableOf(panel).columns" :key="column.key" :class="{ 'collection-table__time': column.isTime, 'collection-table__audio-col': column.isAudio }">
- <span class="collection-table__heading" :title="column.label">{{ column.label }}</span>
- <span v-if="column.unit" class="collection-table__unit">({{ column.unit }})</span>
- </th>
- </tr>
- </thead>
- <tbody v-for="group in recordTableOf(panel).groups" :key="group.key"
- :class="{ 'collection-table__group--latest': group.isLatest }"
- :style="{ '--collection-accent': group.color }"
- :data-collection-group="group.key" :data-collection-group-state="group.state">
- <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">
- <template v-for="(cell, cellIndex) in row.cells" :key="cell.key">
- <td
- v-if="!recordTableOf(panel).columns[cellIndex].isTime || row.timeRowspan > 0"
- data-collection-cell
- :data-reading-state="cell.assessment?.state"
- :style="cell.assessment ? { '--reading-color': cell.assessment.color } : undefined"
- :title="cell.assessment?.label"
- :rowspan="recordTableOf(panel).columns[cellIndex].isTime ? row.timeRowspan : undefined"
- :class="{ 'collection-table__time': recordTableOf(panel).columns[cellIndex].isTime, 'collection-table__audio-col': recordTableOf(panel).columns[cellIndex].isAudio }"
- >
- <template v-if="recordTableOf(panel).columns[cellIndex].isTime">
- <span v-if="group.isLatest" class="collection-table__latest-badge" data-collection-latest-badge>最新</span>
- <span class="collection-table__time-date">{{ row.createDate }}</span>
- <span v-if="row.createClock" class="collection-table__time-clock">{{ row.createClock }}</span>
- </template>
- <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)">
- <span aria-hidden="true">▶</span><span>播放</span>
- </button>
- <!--
- long 布局的格内是「指标名:读数」整条:名称走正文样式,
- 读数段与其它设备的指标值同一套等宽数字字体。
- -->
- <span
- v-else-if="cell.reading && cell.reading.label"
- class="collection-table__reading"
- >
- <span class="collection-table__reading-label">{{ cell.reading.label }}</span>
- <span class="collection-table__reading-result">
- <span class="collection-table__reading-value" :class="{ 'is-muted': isPlaceholder(cell.reading.value) }">{{ cell.reading.value }}</span>
- <MetricStateHint v-if="cell.assessment?.hint" :color="cell.assessment.hint.color"
- :label="cell.assessment.hint.label" :refresh-key="aggregate" />
- </span>
- </span>
- <span v-else class="collection-table__reading-result">
- <span :class="{ 'is-muted': isPlaceholder(cell.text), 'collection-table__value': cell.isReading }">{{ cell.text }}</span>
- <MetricStateHint v-if="cell.assessment?.hint" :color="cell.assessment.hint.color"
- :label="cell.assessment.hint.label" :refresh-key="aggregate" />
- </span>
- </td>
- </template>
- </tr>
- </tbody>
- </table>
- </div>
- <p v-if="!recordTableOf(panel).total" class="equipment-section__hint" data-section-empty-list>暂无采集记录</p>
- <p v-else-if="recordTableOf(panel).assessmentNote" class="equipment-section__hint" data-collection-assessment-note>{{ recordTableOf(panel).assessmentNote }}</p>
- <PagerNav
- v-if="recordTableOf(panel).total"
- :page="recordTableOf(panel).page"
- :page-count="recordTableOf(panel).pageCount"
- :total="recordTableOf(panel).total"
- list-name="采集记录"
- @page="(target) => gotoPage(panel, target)"
- />
- </div>
- <!--
- 预警 / 报警:条目默认可折叠(票 46)—— 折叠行给时间·名称·等级·状态·量值,
- 展开看详情(事件时间 / 状态 / 来源 / 关联设备,缺值给占位符)。
- 票 46 返工第 1 条:每页最多 10 条(上一页/下一页 + 页码;先筛后翻)。
- 分类 chips 在上面共用那一块。
- -->
- <div v-else-if="panel.kind === 'events'" class="equipment-section__list" :data-event-list="panel.name">
- <details
- v-for="(event, index) in pagedOf(panel).rows"
- :key="event.code || index"
- class="equipment-section__item equipment-section__item--collapsible"
- :class="{ 'equipment-section__item--active': event.active }"
- :style="{ '--detail-accent': event.activeColor }"
- :open="isDetailOpen(panel, event, index)"
- @toggle="rememberDetailToggle(panel, event, index, $event)"
- :data-event="event.code || index"
- :data-event-level-color="event.levelColor"
- :data-item-active="event.active ? 'true' : 'false'"
- data-history-item
- >
- <summary class="equipment-section__item-head">
- <span class="equipment-section__item-name">{{ event.name }}</span>
- <span class="equipment-section__meta">
- <span data-event-level>{{ event.levelText }}</span>
- <span v-if="event.status">{{ event.status }}</span>
- <span v-if="event.active" class="equipment-section__active-badge" data-active-badge>{{ event.activeLabel }}</span>
- </span>
- <span class="equipment-section__meta">
- <span v-if="event.timeText">{{ event.timeText }}</span>
- <span class="equipment-section__mono">{{ event.codeText }}</span>
- </span>
- <span class="equipment-section__value" :class="{ 'is-muted': isPlaceholder(event.quantity) }" data-event-value>
- {{ event.quantity }}
- </span>
- </summary>
- <dl class="equipment-section__detail" data-history-detail>
- <div v-for="row in eventDetailRows(event)" :key="row.label" class="equipment-section__detail-row" :data-detail-row="row.label">
- <dt>{{ row.label }}</dt>
- <dd>{{ row.value }}</dd>
- </div>
- </dl>
- </details>
- <p v-if="!pagedOf(panel).total" class="equipment-section__hint" data-section-empty-list>
- {{ rowsOf(panel).length ? '当前分类下没有记录(换一个分类或点「全部」)' : emptyList(panel.label) }}
- </p>
- <!-- 分页控件(票 59 共享 PagerNav,前后页/页码/跳页同一个 @page 出口) -->
- <PagerNav
- :page="pagedOf(panel).page"
- :page-count="pagedOf(panel).pageCount"
- :total="pagedOf(panel).total"
- :list-name="panel.label"
- @page="(target) => gotoPage(panel, target)"
- />
- </div>
- <!--
- 工单状态:状态名(缺则按状态字典补)+ 关键时间 + 每张工单自己的状态时间线(票 12)。
- 票 46:条目默认可折叠,展开保留事件时间/状态/来源/关联对象 + 计划与实际结案时间。
- 「没有动作记录」那句仍由 `timeline.hasContent` 门控(票 12 的 F9 守卫逐字钉住)。
- 票 59:接入统一分页(每页 ≤10,先分类后翻页)。
- -->
- <div v-else-if="panel.kind === 'orders'" class="equipment-section__list" data-work-order-list>
- <details
- v-for="(order, index) in pagedOf(panel).rows"
- :key="order.code || index"
- class="equipment-section__item equipment-section__item--collapsible"
- :class="{ 'equipment-section__item--active': order.active }"
- :style="{ '--detail-accent': order.activeColor }"
- :open="isDetailOpen(panel, order, index)"
- @toggle="rememberDetailToggle(panel, order, index, $event)"
- :data-work-order="order.code || index"
- :data-work-order-state="order.stateCode"
- :data-item-active="order.active ? 'true' : 'false'"
- data-history-item
- >
- <summary class="equipment-section__item-head">
- <span class="equipment-section__item-name">
- {{ order.stateName }}
- <em v-if="order.typeName"> · {{ order.typeName }}</em>
- </span>
- <span class="equipment-section__meta">
- <span class="equipment-section__mono">{{ order.code }}</span>
- <span data-work-order-association>{{ order.associationText }}</span>
- <span v-if="order.active" class="equipment-section__active-badge" data-active-badge>{{ order.activeLabel }}</span>
- </span>
- <span class="equipment-section__meta">
- <span v-if="order.timeText">创建 {{ order.timeText }}</span>
- <span>结案 {{ order.realFinishTime }}</span>
- </span>
- </summary>
- <!--
- 票 12:这张工单自己的状态路径 + 动作轨迹。
- · 轨迹分区拿到了数据 → 完整轨迹(状态路径 + 动作流水);
- · 轨迹分区失败/为空 → **仍铺状态路径**(当前状态码在工单列表里,
- 本来就拿得到),只是没有动作流水。因为"轨迹没取到"而连
- "这张工单现在在哪一态"也一起不画,等于丢掉已有的信息。
- 分区自己的成败由下面那条整块占位说明如实交代。
- -->
- <WorkOrderTimeline
- v-if="trackOfOrder(order)"
- :track="trackOfOrder(order)"
- :path-only="!timeline.hasContent"
- />
- <p v-else-if="timeline.hasContent" class="equipment-section__hint" data-wo-timeline-missing>
- 这张工单没有动作记录
- </p>
- <dl class="equipment-section__detail" data-history-detail>
- <div v-for="row in workOrderDetailRows(order)" :key="row.label" class="equipment-section__detail-row" :data-detail-row="row.label">
- <dt>{{ row.label }}</dt>
- <dd>{{ row.value }}</dd>
- </div>
- </dl>
- </details>
- <!-- 轨迹分区自己失败/为空:整块说一句(不按每张工单重复同一句话,那是噪音) -->
- <p v-if="timeline.placeholder" class="equipment-section__hint" data-wo-timeline-placeholder>
- {{ timeline.placeholder }}
- </p>
- <p v-if="!pagedOf(panel).total" class="equipment-section__hint" data-section-empty-list>
- {{ rowsOf(panel).length ? '当前分类下没有记录(换一个分类或点「全部」)' : emptyList('工单') }}
- </p>
- <!-- 分页控件(票 59 共享 PagerNav,与预警/报警同一控件、同一出口) -->
- <PagerNav
- :page="pagedOf(panel).page"
- :page-count="pagedOf(panel).pageCount"
- :total="pagedOf(panel).total"
- :list-name="panel.label"
- @page="(target) => gotoPage(panel, target)"
- />
- </div>
- <!-- 设备图片:只画后端指定的那一张(前端不索引、不挑选) -->
- <figure v-else-if="panel.kind === 'image'" class="equipment-section__figure" data-latest-image>
- <template v-if="image(panel).hasImage">
- <button v-if="!imageFailed" type="button" class="equipment-section__image-button" aria-label="放大查看设备图片" @click="imageDialog.showModal()">
- <img class="equipment-section__img" :src="image(panel).url" :alt="`${deviceCode} 设备图片`" @load="imageLoading = false" @error="imageFailed = true; imageLoading = false" />
- <span>{{ imageLoading ? '图片加载中…' : '点击放大' }}</span>
- </button>
- <p v-else class="equipment-section__hint" role="status">图片加载失败,请检查图片服务地址或文件是否可访问。<button type="button" @click="imageFailed = false; imageLoading = true">重试</button></p>
- <figcaption class="equipment-section__meta">拍摄于 {{ image(panel).capturedAt }}</figcaption>
- </template>
- <p v-else class="equipment-section__hint" data-latest-image-empty>后端未提供该设备的图片</p>
- </figure>
- </template>
- <!-- ---------- 没内容:如实说,不画空框冒充数据 ---------- -->
- <p v-else class="equipment-section__hint" data-section-placeholder>{{ panel.placeholder }}</p>
- <!-- 字段缺失:只说条数,具体路径进 tooltip(不漏接口字段名给值班人员) -->
- <p
- v-if="panel.missing.count"
- class="equipment-section__missing"
- data-section-missing
- :data-missing-count="panel.missing.count"
- :title="panel.missing.tooltip"
- >
- {{ panel.missing.text }}
- </p>
- </section>
- </div>
- <dialog ref="imageDialog" class="equipment-image-viewer" aria-label="设备图片放大查看" @click="closeImageBackdrop">
- <button type="button" autofocus class="equipment-image-viewer__close" @click="imageDialog.close()">关闭</button>
- <img :src="currentImageUrl" :alt="`${deviceCode} 设备图片`" />
- </dialog>
- <!--
- 音频播放条:贴在播放键上方渐入的小浮层(Teleport 到 body,避免被滚动容器裁剪)。
- 不再用居中大弹窗/背景虚化;点击外部、滚动或 Esc 都会收起。
- -->
- <Teleport to="body">
- <div
- v-if="activeAudioPath"
- ref="audioPlayer"
- class="collection-audio-player"
- :class="{ 'is-below': audioPlacement.below }"
- :style="audioPlayerStyle"
- data-collection-audio-player
- >
- <div class="collection-audio-player__head">
- <span class="collection-audio-player__label">{{ activeAudioLabel }}</span>
- <button type="button" class="collection-audio-player__close" aria-label="关闭播放条" @click="closeAudioPlayer">×</button>
- </div>
- <audio v-if="activeAudioUrl" class="collection-audio-player__audio" controls preload="metadata" :src="activeAudioUrl" />
- </div>
- </Teleport>
- </div>
- </template>
- <script setup>
- import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
- import { resolveDeviceImageUrl } from '../services/deviceImage.js'
- import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
- import {
- DEFAULT_EQUIPMENT_TAB,
- EVENT_CATEGORY_ORDER,
- EQUIPMENT_TABS,
- WORK_ORDER_CATEGORY_ORDER,
- archiveRows,
- categoryChips,
- categoryNote,
- emptyListText,
- equipmentPanels,
- eventDetailRows,
- eventRows,
- filterByCategory,
- imageView,
- collectionRecordTable,
- pagedRows,
- panelsForTab,
- prioritizeActiveRows,
- tabCount,
- timelineSource,
- workOrderDetailRows,
- workOrderRows,
- } from '../domain/equipmentDetailView.js'
- import { pathOnlyTrack } from '../domain/workOrderTimeline.js'
- import VideoMonitorEntry from './VideoMonitorEntry.vue'
- import SectionStateTag from './SectionStateTag.vue'
- import WorkOrderTimeline from './WorkOrderTimeline.vue'
- import PagerNav from './PagerNav.vue'
- import MetricStateHint from './MetricStateHint.vue'
- const props = defineProps({
- /** `stores/detailStore.js` 的 `sections.equipmentAggregate`。 */
- aggregate: { type: Object, default: null },
- /** 当前选中的设备编码(分区头与图片 alt 用)。 */
- deviceCode: { type: String, default: '' },
- monitorSource: { type: String, default: 'detail' },
- })
- const sectionsRoot = ref(null)
- const panels = computed(() => equipmentPanels(props.aggregate))
- // ---- 票 46 返工第 1 条:标签页切换(弃用六块竖排的长滚动)----
- /** 页签清单(domain 给顺序与背后的分区归属;计数给预警/报警/工单三页)。 */
- const tabs = computed(() =>
- EQUIPMENT_TABS.map((tab) => ({ ...tab, count: tabCount(panels.value, tab.name) })),
- )
- /** 当前页签(默认「设备档案」;换设备复位,不把 A 的页签停在 B 上)。 */
- const activeTab = ref(DEFAULT_EQUIPMENT_TAB)
- // 同设备刷新只更新内容;DOM 重排后保留内部及外层详情的滚动位置与可见条目。
- let scrollRevision = 0
- watch([() => props.aggregate, () => props.deviceCode], (_current, previous) => {
- const revision = ++scrollRevision
- const device = props.deviceCode
- const sameDevice = previous[1] === device
- const tab = activeTab.value
- const positions = []
- const items = Array.from(sectionsRoot.value?.querySelectorAll?.('[data-collection-record-key], [data-history-item]') || [])
- for (let element = sectionsRoot.value; element; element = element.parentElement) {
- if (typeof document !== 'undefined' && (element === document.body || element === document.documentElement)) break
- if (typeof element.scrollTop === 'number' && (element.scrollTop || element.scrollHeight > element.clientHeight)) {
- const rect = element.getBoundingClientRect?.()
- const anchor = rect && items.find((item) => {
- const bounds = item.getBoundingClientRect()
- return bounds.bottom > rect.top && bounds.top < rect.bottom
- })
- const key = anchor && (anchor.getAttribute('data-collection-record-key') || anchor.getAttribute('data-event') || anchor.getAttribute('data-work-order'))
- positions.push({ element, top: element.scrollTop, left: element.scrollLeft,
- key: key && key !== DETAIL_PLACEHOLDER ? key : '',
- offset: anchor ? anchor.getBoundingClientRect().top - rect.top : 0,
- })
- }
- }
- nextTick(() => {
- if (revision !== scrollRevision || device !== props.deviceCode || (sameDevice && tab !== activeTab.value)) return
- if (!sameDevice) {
- for (const { element } of positions) { element.scrollTop = 0; element.scrollLeft = 0 }
- return
- }
- const currentItems = Array.from(sectionsRoot.value?.querySelectorAll?.('[data-collection-record-key], [data-history-item]') || [])
- for (const { element, top, left, key, offset } of positions) {
- if (element.isConnected === false) continue
- const anchor = key && currentItems.find((item) =>
- [item.getAttribute('data-collection-record-key'), item.getAttribute('data-event'), item.getAttribute('data-work-order')].includes(key))
- // 新记录插到前面时仍跟住原来正在看的条目;条目已移除才退回像素位置。
- element.scrollTop = anchor ? element.scrollTop + anchor.getBoundingClientRect().top - element.getBoundingClientRect().top - offset : top
- element.scrollLeft = left
- }
- })
- }, { flush: 'pre' })
- onBeforeUnmount(() => { scrollRevision += 1 })
- /** 当前页签要渲染的面板(保留渲染计划自己的次序;图片随档案页出现)。 */
- const activePanels = computed(() => panelsForTab(panels.value, activeTab.value))
- /**
- * 时间线来源(票 12):按工单编码配好的轨迹 + 轨迹分区自己的状态。
- * 只算一次 —— 每张工单渲染时都去重算会把 O(n²) 的判断塞进模板。
- */
- const timeline = computed(() => timelineSource(props.aggregate))
- const staleNote = '数据已过期 · 显示最后一次成功数据,不是实时值'
- // ---- 票 46:分类查询与历史折叠(**视图本地状态**)----
- //
- // 为什么不进 store:分类/页码/折叠是「这一屏怎么看」,不是业务事实,也不是全局筛选条件。
- // 放进 `filterStore` 会让它跨设备、跨模式生效并触发重新取数 —— 那是把视图偏好
- // 冒充成数据条件(票 46 非目标明写「不改变后端事实与只读边界」)。
- // 组件卸载即消失;换设备时下面 `watch` 把它清掉,不残留成"A 的分类挂在 B 上"。
- /** 各分区当前生效的分类键(`{ [分区名]: 分类键 }`;空串/缺失=全部)。 */
- const categories = ref({})
- /** 各分区当前页码(1 起;预警/报警/工单三块都接入分页)。 */
- const pages = ref({})
- const recordTables = computed(() => Object.fromEntries(
- panels.value.filter((panel) => panel.kind === 'metrics').map((panel) => [panel.name, collectionRecordTable(panel.section.data, pages.value[panel.name] || 1)]),
- ))
- function recordTableOf(panel) {
- return recordTables.value[panel.name]
- }
- function recordKey(row) {
- if (!row.timeKey) return undefined
- return JSON.stringify([row.timeKey, row.cells.filter((cell) => cell.isReading).map((cell) => [cell.key, cell.reading?.label])])
- }
- const audioPlayer = ref(null)
- const activeAudioPath = ref('')
- const activeAudioLabel = ref('音频记录')
- const activeAudioUrl = computed(() => activeAudioPath.value ? resolveDeviceImageUrl(activeAudioPath.value) : '')
- const audioPlacement = ref({ left: 0, top: 0, below: false })
- const audioPlayerStyle = computed(() => ({
- left: `${audioPlacement.value.left}px`,
- top: `${audioPlacement.value.top}px`,
- }))
- let audioTriggerEl = null
- /** 打开播放条:以触发按钮为锚,默认在按钮上方渐入;顶部空间不足时翻到按钮下方。 */
- function openRecordAudio(cell, event) {
- if (!cell?.audioPath) return
- const rect = event?.currentTarget?.getBoundingClientRect?.()
- if (rect) {
- const margin = 132
- const left = Math.min(Math.max(rect.left + rect.width / 2, margin), Math.max(margin, window.innerWidth - margin))
- const below = rect.top < 96
- audioPlacement.value = { left: Math.round(left), top: Math.round(below ? rect.bottom : rect.top), below }
- }
- audioTriggerEl = event?.currentTarget || null
- activeAudioPath.value = cell.audioPath
- activeAudioLabel.value = cell.audioLabel || '音频记录'
- }
- function closeAudioPlayer() {
- if (!activeAudioPath.value) return
- activeAudioPath.value = ''
- audioTriggerEl = null
- }
- function audioOutsideHandler(event) {
- if (audioPlayer.value?.contains(event.target) || audioTriggerEl?.contains?.(event.target)) return
- closeAudioPlayer()
- }
- function audioEscapeHandler(event) {
- if (event.key === 'Escape') closeAudioPlayer()
- }
- function attachAudioGuards() {
- document.addEventListener('pointerdown', audioOutsideHandler, true)
- // 滚动时按钮会离开锚点,直接收起(capture 才能收到各滚动容器不冒泡的 scroll)。
- window.addEventListener('scroll', closeAudioPlayer, true)
- window.addEventListener('keydown', audioEscapeHandler, true)
- }
- function detachAudioGuards() {
- if (typeof document === 'undefined' || typeof window === 'undefined') return
- document.removeEventListener('pointerdown', audioOutsideHandler, true)
- window.removeEventListener('scroll', closeAudioPlayer, true)
- window.removeEventListener('keydown', audioEscapeHandler, true)
- }
- watch(activeAudioPath, (open) => { if (open) attachAudioGuards(); else detachAudioGuards() })
- onBeforeUnmount(detachAudioGuards)
- /** 某分区的行视图(未过滤)。 */
- function rowsOf(panel) {
- const data = panel.section.data
- if (panel.kind === 'archive') return archiveRows(data)
- if (panel.kind === 'orders') return workOrderRows(data)
- return eventRows(data)
- }
- /** 这一块有没有分类查询(预警/报警/工单有;档案/指标/图片没有列表语义)。 */
- function isFilterable(panel) {
- return panel.kind === 'events' || panel.kind === 'orders'
- }
- /** 某分区的分类 chips(只有 events/orders 两类有;其它块回空数组,模板据此不画 chip 条)。 */
- function chipsOf(panel) {
- if (panel.kind !== 'events' && panel.kind !== 'orders') return []
- const order = panel.kind === 'orders' ? WORK_ORDER_CATEGORY_ORDER : EVENT_CATEGORY_ORDER
- return categoryChips(rowsOf(panel), order)
- }
- function activeCategory(panelName) {
- return categories.value[panelName] || ''
- }
- /** 点同一项取消(与票 37 的类型筛选同款交互:不需要额外的"清除"按钮就能撤)。 */
- function toggleCategory(panelName, key) {
- categories.value = { ...categories.value, [panelName]: activeCategory(panelName) === key ? '' : key }
- // 换分类 = 换结果集,页码回第 1 页(停在第 3 页看新分类会直接显示"没有记录")。
- pages.value = { ...pages.value, [panelName]: 1 }
- }
- /** 过滤后的行(chip 只筛视图,不动数据、不发请求)。 */
- function visibleRows(panel) {
- const rows = filterByCategory(rowsOf(panel), activeCategory(panel.name))
- if (panel.kind !== 'events' && panel.kind !== 'orders') return rows
- return prioritizeActiveRows(rows)
- }
- /** 过滤 + 分页后的窗口(domain 单源 `pagedRows` 收边界;票 59 三清单同一条路径,组件不再备第二份兜底形状)。 */
- function pagedOf(panel) {
- return pagedRows(visibleRows(panel), pages.value[panel.name] || 1)
- }
- /**
- * 翻页唯一出口(票 59):PagerNav 的前后页/页码直达/跳页都带**目标页码(1 起)**回来。
- * 这里只存状态,不另做夹取——越界统一由 domain `pagedRows` 在下一帧渲染时夹回(条文 6)。
- */
- function gotoPage(panel, target) {
- pages.value = { ...pages.value, [panel.name]: Number(target) || 1 }
- }
- function categoryNoteText(panel) {
- return categoryNote(chipsOf(panel), activeCategory(panel.name))
- }
- /**
- * 票 115 优化:未解除/未结案条目的展开状态。
- *
- * 切换页签时清空手动状态 ⇒ 进入对应页签时,active 条目默认展开;
- * 用户手动收起/展开后以手动状态为准(manualOpen[key]),不会每帧被默认值顶回去。
- */
- const manualOpen = ref({})
- function detailKey(panel, item, index) {
- return panel.name + '|' + (item.code || index)
- }
- function isDetailOpen(panel, item, index) {
- const key = detailKey(panel, item, index)
- return Object.hasOwn(manualOpen.value, key) ? manualOpen.value[key] : item.active === true
- }
- function rememberDetailToggle(panel, item, index, event) {
- const key = detailKey(panel, item, index)
- manualOpen.value = { ...manualOpen.value, [key]: event.target.open }
- }
- watch(activeTab, () => {
- manualOpen.value = {}
- })
- // 换设备:页签、分类条件与页码全部复位(不同设备的分布互不相干,留着就是拿 A 的状态看 B)。
- watch(
- () => props.deviceCode,
- () => {
- categories.value = {}
- pages.value = {}
- manualOpen.value = {}
- activeTab.value = DEFAULT_EQUIPMENT_TAB
- },
- )
- /**
- * 取某张工单的轨迹。
- *
- * 轨迹分区有数据 → 用它那条**完整**轨迹(按工单编码配,票 12 验收第 2 条);
- * 分区没数据(失败/空)→ 退回**只有状态路径**的那份(`pathOnlyTrack`),
- * 因为"这张工单现在在哪一态"来自工单列表,与轨迹成败无关,不该一起丢掉。
- */
- function trackOfOrder(order) {
- if (!order.code) return null
- const full = timeline.value.hasContent ? timeline.value.tracks[order.code] : null
- return full || pathOnlyTrack(order)
- }
- function image(panel) {
- const view = imageView(panel.section.data)
- return { ...view, url: resolveDeviceImageUrl(view.url) }
- }
- const imageDialog = ref(null)
- const imageFailed = ref(false)
- const imageLoading = ref(true)
- const currentImageUrl = computed(() => {
- const panel = panels.value.find((item) => item.kind === 'image')
- return panel ? image(panel).url : ''
- })
- watch([() => props.deviceCode, currentImageUrl], () => {
- imageDialog.value?.close()
- imageFailed.value = false
- imageLoading.value = true
- })
- watch([() => props.deviceCode, activeTab], () => {
- closeAudioPlayer()
- })
- function closeImageBackdrop(event) {
- if (event.target !== imageDialog.value) return
- const rect = imageDialog.value.getBoundingClientRect()
- if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) imageDialog.value.close()
- }
- function emptyList(label) {
- return emptyListText(label)
- }
- function isPlaceholder(text) {
- return text === DETAIL_PLACEHOLDER
- }
- </script>
- <style scoped>
- .collection-table-wrap { min-width: 0; overflow-x: hidden; }
- .collection-table {
- width: 100%;
- table-layout: fixed;
- border-collapse: collapse;
- color: var(--text-primary, #e5f4ff);
- font-size: calc(11 * var(--u, 1px));
- }
- .collection-table th,
- .collection-table td {
- min-width: 0;
- padding: calc(4 * var(--u, 1px)) calc(3 * var(--u, 1px));
- border: 1px solid rgba(69, 161, 226, .16);
- text-align: left;
- vertical-align: middle;
- }
- .collection-table th {
- color: #a6d8ff;
- background: rgba(29, 83, 124, .25);
- font-size: calc(10 * var(--u, 1px));
- letter-spacing: .02em;
- line-height: 1.2;
- }
- .collection-table__heading {
- display: inline-block;
- max-width: 100%;
- overflow: hidden;
- color: inherit;
- font-weight: 600;
- text-overflow: ellipsis;
- vertical-align: bottom;
- white-space: nowrap;
- word-break: keep-all;
- }
- .collection-table__unit {
- white-space: normal;
- word-break: break-word;
- overflow-wrap: anywhere;
- font-size: .92em;
- }
- .collection-table td { background: rgba(7, 27, 48, .35); font-variant-numeric: tabular-nums; line-height: 1.35; overflow-wrap: anywhere; }
- .collection-table tbody:not(.collection-table__group--latest):nth-of-type(even) td { background: rgba(7, 27, 48, .22); }
- .collection-table tbody:not(.collection-table__group--latest) tr:hover td { background: rgba(1, 171, 234, .12); }
- /* 渐变只画在整次采集的行组上,td 透明;长表 rowspan 的时间格也共用同一张背景。 */
- .collection-table__group--latest {
- --reading-color: var(--lv4, #27d17a);
- background: linear-gradient(90deg, color-mix(in srgb, var(--collection-accent) 24%, transparent), rgba(7, 27, 48, .28) 100%);
- }
- .collection-table .collection-table__group--latest td {
- background: transparent;
- border-color: color-mix(in srgb, var(--collection-accent) 30%, transparent);
- }
- .collection-table__group--latest tr:first-child td:first-child { box-shadow: inset 3px 0 0 var(--collection-accent); }
- .collection-table__group--latest:hover {
- background: linear-gradient(90deg, color-mix(in srgb, var(--collection-accent) 30%, transparent), rgba(7, 27, 48, .34) 100%);
- }
- .collection-table__latest-badge {
- display: inline-flex;
- align-items: center;
- margin-bottom: calc(2 * var(--u, 1px));
- padding: 0 calc(5 * var(--u, 1px));
- border: 1px solid color-mix(in srgb, var(--collection-accent) 65%, transparent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--collection-accent) 16%, transparent);
- color: var(--collection-accent);
- font-size: var(--fs-caption, 10.5px);
- line-height: calc(16 * var(--u, 1px));
- letter-spacing: .04em;
- white-space: nowrap;
- }
- /* 采集时间已拆两行(日期 10 字符是最宽一行),定宽按内容收紧,
- 余量让给右侧指标列(维护者 2026-11-06 反馈:125u 约剩一半空白)。 */
- .collection-table__time { width: calc(82 * var(--u, 1px)); }
- .collection-table__time-date,
- .collection-table__time-clock { display: block; white-space: nowrap; }
- .collection-table__audio-col { width: calc(72 * var(--u, 1px)); }
- .collection-table__audio-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: calc(3 * var(--u, 1px));
- width: 100%;
- min-width: 0;
- padding: calc(3 * var(--u, 1px)) calc(4 * var(--u, 1px));
- border: 1px solid rgba(1, 171, 234, .5);
- border-radius: 999px;
- background: rgba(1, 91, 150, .26);
- color: #b8e8ff;
- font: inherit;
- white-space: nowrap;
- cursor: pointer;
- }
- .collection-table__audio-button:hover,
- .collection-table__audio-button:focus-visible { border-color: #ffb020; color: #ffd08a; outline: none; }
- /* 指标读数排版(wide / long 两种布局共用这一套,不因列名不同而脱节):
- 字体与左右侧面板 KPI 数值(.panel-kpi__value)同档 --num(Rajdhani / Share Tech Mono…,
- 单源 tokens.css);字号按表格密度自 22u KPI 档收到 13.5u;满不透明度的 --text-strong
- 高亮白并加粗,读数在大屏上一眼可读;读数相对单元格左边界略微缩进。
- long 布局的「指标名:读数」整条,名称段用正文样式、读数段复用本套字体(见下)。
- 报警行以更高优先级抢回红色,占位灰由后置 is-muted 覆盖。 */
- .collection-table__value,
- .collection-table__reading-value {
- color: var(--reading-color, var(--text-strong, #fff));
- opacity: 1;
- font-family: var(--num, monospace);
- font-size: calc(13.5 * var(--u, 1px));
- font-weight: 700;
- letter-spacing: .02em;
- text-shadow: 0 0 8px color-mix(in srgb, currentColor 24%, transparent);
- }
- .collection-table__value {
- padding-left: calc(4 * var(--u, 1px));
- }
- .collection-table__reading-label {
- color: var(--muted, #5680b0);
- font-size: calc(11 * var(--u, 1px));
- font-weight: 400;
- }
- .collection-table__reading {
- display: grid;
- grid-template-columns: minmax(0, 48%) minmax(0, 1fr);
- column-gap: calc(14 * var(--u, 1px));
- align-items: baseline;
- padding-left: calc(4 * var(--u, 1px));
- text-align: left;
- }
- .collection-table__reading-result {
- display: inline-flex;
- min-width: 0;
- align-items: baseline;
- justify-content: flex-start;
- gap: calc(6 * var(--u, 1px));
- }
- .collection-table__reading-result > span:first-child { min-width: 0; }
- .collection-table__value.is-muted,
- .collection-table__reading-value.is-muted { color: var(--muted, #5680b0); font-weight: 400; text-shadow: none; }
- .collection-table__label {
- color: var(--muted, #5680b0);
- font-size: calc(10.5 * var(--u, 1px));
- }
- .equipment-sections,
- .equipment-sections *,
- .equipment-sections *::before,
- .equipment-sections *::after {
- box-sizing: border-box;
- }
- .equipment-sections {
- display: grid;
- gap: calc(6 * var(--u, 1px));
- }
- /*
- 票 46 返工第 1 条:标签页条(与票 13 的分段切换、票 46 的分类 chips 同一套视觉语言,
- 但它是**分区切换**不是筛选)。计数挂在页签右上角;默认页 = 设备档案。
- */
- .equipment-tabs {
- display: flex;
- flex-wrap: wrap;
- gap: calc(3 * var(--u, 1px));
- }
- .equipment-tabs__tab {
- display: inline-flex;
- align-items: center;
- gap: calc(3 * var(--u, 1px));
- padding: calc(2 * var(--u, 1px)) calc(9 * var(--u, 1px));
- border: 1px solid rgba(1, 171, 234, 0.32);
- border-radius: calc(8 * var(--u, 1px));
- background: rgba(0, 20, 72, 0.6);
- color: var(--text-2);
- font: inherit;
- font-size: var(--fs-caption, 10.5px);
- letter-spacing: 0.04em;
- cursor: pointer;
- }
- .equipment-tabs__tab:hover,
- .equipment-tabs__tab:focus-visible {
- border-color: rgba(1, 171, 234, 0.6);
- color: var(--text-strong);
- outline: none;
- }
- .equipment-tabs__tab.is-active {
- border-color: rgba(0, 184, 255, 0.85);
- background: linear-gradient(180deg, rgba(1, 171, 234, 0.85), rgba(0, 82, 168, 0.85));
- color: var(--text-strong);
- }
- .equipment-tabs__count {
- font-family: var(--num, monospace);
- color: var(--lv2, #ffb020);
- }
- .equipment-tabs__tab.is-active .equipment-tabs__count {
- color: var(--text-strong);
- }
- .equipment-tab__body {
- display: grid;
- gap: calc(6 * var(--u, 1px));
- }
- /* 票 59:分页控件与样式移交共享 PagerNav.vue,组件不再自画 pager。 */
- /* 每块分区:自己的边框与抬头 —— 失败的那一块缩成一行提示,成功的照常展开。 */
- .equipment-section {
- display: grid;
- gap: calc(3 * var(--u, 1px));
- padding: calc(5 * var(--u, 1px)) var(--space-2, 8px);
- border: 1px solid rgba(1, 171, 234, 0.24);
- border-radius: calc(8 * var(--u, 1px));
- background: var(--inset, rgba(2, 10, 20, 0.55));
- }
- .equipment-section[data-panel-state='failed'],
- .equipment-section[data-panel-state='unavailable'] {
- border-color: rgba(255, 59, 82, 0.4);
- }
- .equipment-section[data-panel-state='stale'] {
- border-color: rgba(255, 176, 32, 0.45);
- }
- .equipment-section__head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--space-2, 8px);
- }
- .equipment-section__title {
- min-width: 0;
- overflow: hidden;
- color: var(--text);
- font-size: var(--fs-body, 13px);
- font-weight: 700;
- letter-spacing: 0.06em;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- .equipment-section__stale {
- margin: 0;
- color: var(--lv2, #ffb020);
- font-size: var(--fs-caption, 10.5px);
- letter-spacing: 0.04em;
- }
- .equipment-section__rows {
- display: grid;
- gap: calc(2 * var(--u, 1px));
- margin: 0;
- }
- .equipment-section__row {
- display: grid;
- grid-template-columns: calc(58 * var(--u, 1px)) minmax(0, 1fr);
- align-items: baseline;
- gap: var(--space-2, 8px);
- border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
- }
- .equipment-section__row dt {
- color: var(--muted, #5680b0);
- font-size: var(--fs-caption, 10.5px);
- letter-spacing: 0.04em;
- }
- .equipment-section__row dd {
- margin: 0;
- color: var(--text, #d7ecff);
- min-width: 0;
- overflow-wrap: anywhere;
- word-break: normal;
- }
- .equipment-section__list {
- display: grid;
- gap: calc(4 * var(--u, 1px));
- }
- .equipment-section__item {
- display: grid;
- gap: 1px;
- padding-bottom: calc(3 * var(--u, 1px));
- border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
- }
- /*
- 票 46:分类 chips 条(预警/报警按等级、工单按状态)。
- 与票 13 的分段切换同一套视觉语言(胶囊 + 选中高亮),但它是**视图过滤**不是模式切换。
- 计数直接挂在 chip 上 —— 分类前有多少条要看得见,筛完剩几条由列表自己说话。
- */
- .equipment-section__chips {
- display: flex;
- flex-wrap: wrap;
- gap: calc(3 * var(--u, 1px));
- }
- .equipment-section__chip {
- display: inline-flex;
- align-items: center;
- gap: calc(3 * var(--u, 1px));
- padding: 1px calc(7 * var(--u, 1px));
- border: 1px solid rgba(1, 171, 234, 0.32);
- border-radius: calc(8 * var(--u, 1px));
- background: rgba(0, 20, 72, 0.6);
- color: var(--text-2);
- font: inherit;
- font-size: var(--fs-caption, 10.5px);
- letter-spacing: 0.04em;
- cursor: pointer;
- }
- .equipment-section__chip:hover,
- .equipment-section__chip:focus-visible {
- border-color: rgba(1, 171, 234, 0.6);
- color: var(--text-strong);
- outline: none;
- }
- .equipment-section__chip.is-active {
- border-color: rgba(0, 184, 255, 0.85);
- background: linear-gradient(180deg, rgba(1, 171, 234, 0.85), rgba(0, 82, 168, 0.85));
- color: var(--text-strong);
- }
- .equipment-section__chip-count {
- font-family: var(--num, monospace);
- }
- /*
- 票 46:历史条目默认折叠(`<details>` 原生语义:键盘可达、展开态自带 aria-expanded)。
- summary 一行读完关键信息;展开后的详情走 dl 键值行。去掉原生三角,换成边框反馈。
- */
- .equipment-section__item--collapsible {
- gap: calc(2 * var(--u, 1px));
- }
- .equipment-section__item--collapsible[open] {
- border-bottom-color: rgba(1, 171, 234, 0.35);
- padding-bottom: calc(5 * var(--u, 1px));
- }
- /* 事件按等级色强调;未分级预警黄、报警红;非终态工单天蓝。 */
- .equipment-section__item--active {
- padding-left: calc(6 * var(--u, 1px));
- border-bottom-color: color-mix(in srgb, var(--detail-accent) 65%, transparent);
- border-radius: calc(6 * var(--u, 1px));
- background: linear-gradient(90deg, color-mix(in srgb, var(--detail-accent) 20%, transparent), rgba(7, 27, 48, .12) 78%);
- 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);
- }
- .equipment-section__item--active[open] {
- border-bottom-color: color-mix(in srgb, var(--detail-accent) 78%, transparent);
- background: linear-gradient(90deg, color-mix(in srgb, var(--detail-accent) 26%, transparent), rgba(7, 27, 48, .16) 82%);
- }
- .equipment-section__item--active .equipment-section__item-head,
- .equipment-section__item--collapsible.equipment-section__item--active[open] .equipment-section__item-head {
- border-color: color-mix(in srgb, var(--detail-accent) 26%, transparent);
- background: color-mix(in srgb, var(--detail-accent) 6%, transparent);
- }
- .equipment-section__active-badge {
- display: inline-flex;
- align-items: center;
- padding: 0 calc(6 * var(--u, 1px));
- border: 1px solid var(--detail-accent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--detail-accent) 24%, transparent);
- color: color-mix(in srgb, var(--detail-accent) 55%, white);
- font-size: var(--fs-caption, 10.5px);
- line-height: calc(16 * var(--u, 1px));
- letter-spacing: .04em;
- white-space: nowrap;
- }
- .equipment-section__item-head {
- display: grid;
- gap: 1px;
- cursor: pointer;
- list-style: none;
- padding: calc(1 * var(--u, 1px)) calc(3 * var(--u, 1px));
- margin: calc(-1 * var(--u, 1px)) calc(-3 * var(--u, 1px)) 0;
- border: 1px solid transparent;
- border-radius: calc(8 * var(--u, 1px));
- }
- .equipment-section__item-head::-webkit-details-marker {
- display: none;
- }
- .equipment-section__item-head:hover,
- .equipment-section__item--collapsible[open] .equipment-section__item-head {
- border-color: rgba(1, 171, 234, 0.35);
- background: rgba(0, 53, 156, 0.28);
- }
- .equipment-section__detail {
- display: grid;
- gap: calc(1 * var(--u, 1px));
- margin: calc(2 * var(--u, 1px)) 0 0;
- padding: calc(3 * var(--u, 1px)) var(--space-2, 8px);
- border-left: 2px solid rgba(1, 171, 234, 0.4);
- background: rgba(2, 10, 20, 0.4);
- }
- .equipment-section__detail-row {
- display: grid;
- grid-template-columns: calc(58 * var(--u, 1px)) minmax(0, 1fr);
- align-items: baseline;
- gap: var(--space-2, 8px);
- }
- .equipment-section__detail-row dt {
- color: var(--muted, #5680b0);
- font-size: var(--fs-caption, 10.5px);
- letter-spacing: 0.04em;
- }
- .equipment-section__detail-row dd {
- margin: 0;
- min-width: 0;
- color: var(--text, #d7ecff);
- font-size: var(--fs-caption, 10.5px);
- overflow-wrap: anywhere;
- }
- .equipment-section__item-name {
- color: var(--text, #d7ecff);
- font-size: var(--fs-caption, 10.5px);
- }
- .equipment-section__item-name em {
- color: var(--muted, #5680b0);
- font-style: normal;
- }
- .equipment-section__value {
- color: var(--text);
- font-family: var(--num, monospace);
- font-size: var(--fs-body, 13px);
- }
- .equipment-section__value em {
- color: var(--muted, #5680b0);
- font-style: normal;
- font-size: var(--fs-caption, 10.5px);
- }
- .equipment-section__meta {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2, 8px);
- color: var(--text-2, #88b7e8);
- font-size: var(--fs-caption, 10.5px);
- }
- .equipment-section__note {
- margin: 0;
- color: var(--muted, #5680b0);
- font-size: var(--fs-caption, 10.5px);
- }
- .equipment-section__figure {
- display: grid;
- gap: calc(3 * var(--u, 1px));
- margin: 0;
- }
- .equipment-section__img {
- display: block;
- width: 100%;
- max-height: calc(140 * var(--u, 1px));
- object-fit: cover;
- border: 1px solid rgba(1, 171, 234, 0.35);
- border-radius: calc(8 * var(--u, 1px));
- }
- .equipment-section__hint,
- .equipment-section__missing,
- .is-muted {
- margin: 0;
- color: var(--muted, #5680b0);
- font-size: var(--fs-caption, 10.5px);
- letter-spacing: 0.04em;
- }
- .equipment-section__missing {
- color: var(--lv2, #ffb020);
- }
- .equipment-section__mono {
- font-family: var(--num, monospace);
- letter-spacing: 0.04em;
- }
- .equipment-section__image-button {
- display: grid;
- gap: 6px;
- width: 100%;
- padding: 0;
- border: 0;
- background: transparent;
- color: var(--cyan-hi);
- font: inherit;
- cursor: zoom-in;
- }
- .equipment-image-viewer {
- width: min(90vw, 1200px);
- max-height: 90vh;
- padding: 16px;
- border: 1px solid var(--cyan);
- border-radius: calc(8 * var(--u, 1px));
- background: var(--bg-0);
- color: var(--text);
- }
- .equipment-image-viewer::backdrop { background: rgba(1, 5, 20, .8); backdrop-filter: blur(6px); }
- .equipment-image-viewer img { display: block; width: 100%; height: auto; max-height: 78vh; object-fit: contain; }
- .equipment-image-viewer__close { display: block; margin-left: auto; margin-bottom: 8px; cursor: pointer; }
- /* 音频播放条:贴播放键上方渐入的小浮层,不遮罩、不虚化整屏 UI。 */
- .collection-audio-player {
- position: fixed;
- z-index: calc(var(--z-columns, 30) + 5);
- width: min(264px, 88vw);
- padding: calc(6 * var(--u, 1px)) calc(8 * var(--u, 1px)) calc(8 * var(--u, 1px));
- border: 1px solid var(--cyan, #01abea);
- border-radius: calc(8 * var(--u, 1px));
- background: rgba(4, 20, 38, .97);
- box-shadow: 0 8px 22px rgba(0, 0, 0, .45);
- color: var(--text, #e5f4ff);
- transform: translate(-50%, calc(-100% - 8px));
- animation: collection-audio-pop-above .18s ease-out;
- }
- .collection-audio-player.is-below {
- transform: translate(-50%, 8px);
- animation-name: collection-audio-pop-below;
- }
- @keyframes collection-audio-pop-above {
- from { opacity: 0; transform: translate(-50%, calc(-100% - 2px)); }
- }
- @keyframes collection-audio-pop-below {
- from { opacity: 0; transform: translate(-50%, 2px); }
- }
- .collection-audio-player__head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: calc(6 * var(--u, 1px));
- margin-bottom: calc(4 * var(--u, 1px));
- }
- .collection-audio-player__label {
- min-width: 0;
- overflow: hidden;
- color: #a6d8ff;
- font-size: var(--fs-caption, 10.5px);
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .collection-audio-player__close {
- flex: none;
- width: calc(20 * var(--u, 1px));
- height: calc(20 * var(--u, 1px));
- padding: 0;
- border: 1px solid rgba(1, 171, 234, .5);
- border-radius: 999px;
- background: rgba(1, 91, 150, .26);
- color: inherit;
- font-size: calc(12 * var(--u, 1px));
- line-height: 1;
- cursor: pointer;
- }
- .collection-audio-player__audio { display: block; width: 100%; max-width: 100%; height: calc(32 * var(--u, 1px)); }
- </style>
|