| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225 |
- <!--
- 设备详情的分区(票 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 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 才是正确的可访问语义)。
- -->
- <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>
- <VideoMonitorEntry v-if="activeTab === 'metrics'" :device-code="deviceCode" />
- <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>
- <span v-if="cell.alert" class="collection-table__alert-dot" tabindex="0" role="img"
- :style="{ color: cell.alert.dotColor }" :title="cell.alert.label" :aria-label="cell.alert.label" />
- </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>
- <span v-if="cell.alert" class="collection-table__alert-dot" tabindex="0" role="img"
- :style="{ color: cell.alert.dotColor }" :title="cell.alert.label" :aria-label="cell.alert.label" />
- </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, 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'
- const props = defineProps({
- /** `stores/detailStore.js` 的 `sections.equipmentAggregate`。 */
- aggregate: { type: Object, default: null },
- /** 当前选中的设备编码(分区头与图片 alt 用)。 */
- deviceCode: { type: String, default: '' },
- })
- 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)
- /** 当前页签要渲染的面板(保留渲染计划自己的次序;图片随档案页出现)。 */
- 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]
- }
- 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__alert-dot {
- display: inline-block;
- flex: none;
- width: calc(7 * var(--u, 1px));
- height: calc(7 * var(--u, 1px));
- border-radius: 50%;
- background: currentColor;
- box-shadow: 0 0 6px currentColor;
- cursor: help;
- }
- .collection-table__alert-dot:focus-visible { outline: 1px solid currentColor; outline-offset: 3px; }
- .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>
|