隐患详情重新设计,检查计划重新设计成做题排查式

This commit is contained in:
王利强
2026-06-21 16:30:12 +08:00
parent 1fe87ec438
commit cc94d3e3e9
256 changed files with 12542 additions and 5956 deletions

View File

@@ -0,0 +1,760 @@
<template>
<view class="hazard-detail-panel">
<view class="status-bar">
<view class="hazard-title">{{ detail.title || '-' }}</view>
<view class="status-row">
<view class="status-meta">
<text class="info-label">隐患状态</text>
<view :class="['status-tag', statusClass]">{{ detail.statusName || '-' }}</view>
</view>
<text class="info-item info-item--right">提交日期 {{ detail.createdAt || '-' }}</text>
</view>
</view>
<view v-if="loading" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="historyList.length === 0" class="empty-wrap">
<text class="empty-text">暂无历史记录</text>
</view>
<view v-else class="detail-body">
<!-- 左侧步骤与右侧当前节点联动 -->
<scroll-view
class="steps-column"
scroll-y
:show-scrollbar="false"
:scroll-into-view="stepScrollIntoView"
:scroll-with-animation="true"
>
<view
v-for="(item, index) in historyList"
:key="'step-' + index"
:id="'hazard-step-' + index"
class="step-item"
:class="{ 'step-item--active': activeIndex === index }"
@tap="scrollToNode(index)"
>
<view class="step-track">
<view class="step-dot" :class="{ 'step-dot--active': activeIndex === index }">
<text class="step-dot-text">{{ item.nodeName }}</text>
</view>
<view v-if="index < historyList.length - 1" class="step-line"></view>
</view>
</view>
</scroll-view>
<!-- 右侧卡片连续滚动滚过当前节点后左侧自动切换 -->
<scroll-view
class="content-column content-scroll"
scroll-y
:show-scrollbar="false"
:scroll-into-view="contentScrollIntoView"
:scroll-with-animation="scrollWithAnimation"
@scroll="onContentScroll"
@scrolltolower="onScrollToLower"
>
<view
v-for="(item, index) in historyList"
:key="'node-' + index"
:id="'hazard-node-' + index"
class="node-section"
:class="{ 'node-section--last': index === historyList.length - 1 }"
>
<view class="detail-card">
<view class="card-header">
<!-- <text class="card-node-name">{{ item.nodeName }}</text> -->
<text class="operator">{{ item.titlePrefix }}人员{{ item.operator }}</text>
<text class="time">{{ item.time }}</text>
</view>
<view class="card-body">
<!-- 提交 -->
<block v-if="item.type === 'submit'">
<view class="detail-row">
<text class="label">隐患标题</text>
<text class="value">{{ item.content.title }}</text>
</view>
<view class="detail-row">
<text class="label">检查形式</text>
<text class="value">{{ item.content.source }}</text>
</view>
<view class="detail-row">
<text class="label">隐患区域</text>
<view class="value value--inline">
<view
v-if="item.content.areaColor"
class="area-dot"
:style="{ backgroundColor: item.content.areaColor }"
></view>
<text>{{ item.content.areaName }}</text>
</view>
</view>
<view class="detail-row">
<text class="label">位置描述</text>
<text class="value">{{ item.content.address }}</text>
</view>
<view class="detail-row">
<text class="label">隐患等级</text>
<view class="value">
<view :class="['level-tag', getLevelTagClass(item.content)]">
{{ item.content.levelName || '-' }}
</view>
</view>
</view>
<view class="detail-row">
<text class="label">隐患标签</text>
<text class="value">{{ item.content.tagName }}</text>
</view>
<view class="detail-row">
<text class="label">问题描述</text>
<text class="value">{{ item.content.description }}</text>
</view>
<view
v-if="item.content.attachments && item.content.attachments.length"
class="detail-row detail-row--block"
>
<text class="label">隐患附件</text>
<view class="attachment-list">
<image
v-for="(file, idx) in item.content.attachments"
:key="idx"
class="attachment-img"
:src="resolveFileUrl(file.filePath)"
mode="aspectFill"
@tap="previewImages(item.content.attachments, idx)"
@load="scheduleMeasureLayout"
/>
</view>
</view>
<view class="detail-row">
<text class="label">参考法规</text>
<text class="value">{{ item.content.legalBasis }}</text>
</view>
</block>
<!-- 交办 -->
<block v-else-if="item.type === 'assign'">
<view class="detail-row">
<text class="label">指定整改责任人</text>
<text class="value">{{ item.content.assigneeName }}</text>
</view>
<view class="detail-row">
<text class="label">指定整改截至日期</text>
<text class="value">{{ item.content.deadline }}</text>
</view>
</block>
<!-- 整改 -->
<block v-else-if="item.type === 'rectify'">
<view class="detail-row">
<text class="label">整改状态</text>
<text class="value">{{ item.content.rectifyStatusName }}</text>
</view>
<view class="detail-row">
<text class="label">整改方案</text>
<text class="value">{{ item.content.rectifyPlan }}</text>
</view>
<view class="detail-row">
<text class="label">整改结果</text>
<text class="value">{{ item.content.rectifyResult }}</text>
</view>
<view class="detail-row">
<text class="label">整改措施</text>
<text class="value">{{ item.content.rectificationMeasures }}</text>
</view>
<view class="detail-row">
<text class="label">管控措施</text>
<text class="value">{{ item.content.controlMeasures }}</text>
</view>
<view class="detail-row">
<text class="label">限定整改时间</text>
<text class="value">{{ item.content.deadline }}</text>
</view>
<view class="detail-row">
<text class="label">整改责任人</text>
<text class="value">{{ item.content.rectifierNames }}</text>
</view>
<view class="detail-row">
<text class="label">管理人员</text>
<text class="value">{{ item.content.managerNames }}</text>
</view>
<view class="detail-row">
<text class="label">预计费用</text>
<text class="value">{{ formatCost(item.content.planCost) }}</text>
</view>
<view class="detail-row">
<text class="label">实际费用</text>
<text class="value">{{ formatCost(item.content.actualCost) }}</text>
</view>
<view
v-if="item.content.attachments && item.content.attachments.length"
class="detail-row detail-row--block"
>
<text class="label">整改附件</text>
<view class="attachment-list">
<template v-for="(file, idx) in item.content.attachments" :key="idx">
<image
v-if="file.fileType === 'image'"
class="attachment-img"
:src="resolveFileUrl(file.filePath)"
mode="aspectFill"
@tap="previewImages(item.content.attachments, idx)"
@load="scheduleMeasureLayout"
/>
<text v-else class="file-link">{{ file.fileName || '附件' }}</text>
</template>
</view>
</view>
<view v-if="item.content.signPath" class="detail-row detail-row--block">
<text class="label">整改签字</text>
<image
class="sign-img"
:src="resolveFileUrl(item.content.signPath)"
mode="aspectFit"
@tap="previewSingle(item.content.signPath)"
@load="scheduleMeasureLayout"
/>
</view>
</block>
<!-- 验收 -->
<block v-else-if="item.type === 'verify'">
<view class="detail-row">
<text class="label">验收结果</text>
<text
class="result-tag"
:class="item.content.resultName === '通过' ? 'result-tag--pass' : 'result-tag--fail'"
>
{{ item.content.resultName }}
</text>
</view>
<view v-if="item.content.remark" class="detail-row">
<text class="label">验收备注</text>
<text class="value">{{ item.content.remark }}</text>
</view>
<view
v-if="item.content.attachments && item.content.attachments.length"
class="detail-row detail-row--block"
>
<text class="label">验收附件</text>
<view class="attachment-list">
<template v-for="(file, idx) in item.content.attachments" :key="idx">
<image
v-if="file.fileType === 'image'"
class="attachment-img"
:src="resolveFileUrl(file.filePath)"
mode="aspectFill"
@tap="previewImages(item.content.attachments, idx)"
@load="scheduleMeasureLayout"
/>
<text v-else class="file-link">{{ file.fileName || '附件' }}</text>
</template>
</view>
</view>
<view v-if="item.content.signPath" class="detail-row detail-row--block">
<text class="label">验收签字</text>
<image
class="sign-img"
:src="resolveFileUrl(item.content.signPath)"
mode="aspectFit"
@tap="previewSingle(item.content.signPath)"
@load="scheduleMeasureLayout"
/>
</view>
</block>
<!-- 销号 -->
<block v-else-if="item.type === 'writeoff'">
<view class="detail-row">
<text class="label">销号结果</text>
<text
class="result-tag"
:class="item.content.resultName === '通过' ? 'result-tag--pass' : 'result-tag--fail'"
>
{{ item.content.resultName }}
</text>
</view>
<view v-if="item.content.remark" class="detail-row">
<text class="label">销号备注</text>
<text class="value">{{ item.content.remark }}</text>
</view>
<view
v-if="item.content.attachments && item.content.attachments.length"
class="detail-row detail-row--block"
>
<text class="label">销号附件</text>
<view class="attachment-list">
<template v-for="(file, idx) in item.content.attachments" :key="idx">
<image
v-if="file.fileType === 'image'"
class="attachment-img"
:src="resolveFileUrl(file.filePath)"
mode="aspectFill"
@tap="previewImages(item.content.attachments, idx)"
@load="scheduleMeasureLayout"
/>
<text v-else class="file-link">{{ file.fileName || '附件' }}</text>
</template>
</view>
</view>
<view v-if="item.content.signPath" class="detail-row detail-row--block">
<text class="label">销号签字</text>
<image
class="sign-img"
:src="resolveFileUrl(item.content.signPath)"
mode="aspectFit"
@tap="previewSingle(item.content.signPath)"
@load="scheduleMeasureLayout"
/>
</view>
</block>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { computed, toRefs } from 'vue';
import { toImageUrl } from '@/request/request.js';
import { getStatusClass } from './hazardDetail.js';
import { useHazardDetailScroll } from './useHazardDetailScroll.js';
const props = defineProps({
detail: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
});
const { detail, loading } = toRefs(props);
const {
historyList,
activeIndex,
contentScrollIntoView,
stepScrollIntoView,
scrollWithAnimation,
onContentScroll,
onScrollToLower,
scrollToNode,
scheduleMeasureLayout
} = useHazardDetailScroll(detail, loading);
const statusClass = computed(() => getStatusClass(props.detail?.status, props.detail?.statusName));
const LEVEL_CLASS_MAP = {
2: 'level-normal',
3: 'level-major'
};
const LEVEL_NAME_CLASS_MAP = {
一般: 'level-normal',
一般隐患: 'level-normal',
重大: 'level-major',
重大隐患: 'level-major'
};
const getLevelTagClass = (content) => {
const level = content?.level;
const levelName = content?.levelName;
if (level != null && LEVEL_CLASS_MAP[level]) {
return LEVEL_CLASS_MAP[level];
}
if (levelName && LEVEL_NAME_CLASS_MAP[levelName]) {
return LEVEL_NAME_CLASS_MAP[levelName];
}
return '';
};
const resolveFileUrl = (path) => toImageUrl(path);
const formatCost = (cost) => {
if (cost == null || cost === '') return '-';
const num = Number(cost);
return Number.isNaN(num) ? '-' : `${num}`;
};
const previewSingle = (path) => {
const url = resolveFileUrl(path);
if (!url) return;
uni.previewImage({ current: url, urls: [url] });
};
const previewImages = (attachments, index) => {
const urls = (attachments || [])
.filter((f) => f.fileType === 'image' || !f.fileType)
.map((f) => resolveFileUrl(f.filePath))
.filter(Boolean);
if (!urls.length) return;
uni.previewImage({ current: urls[index] || urls[0], urls });
};
</script>
<style lang="scss" scoped>
.hazard-detail-panel {
display: flex;
flex-direction: column;
height: 100vh;
background: #ebf2fc;
overflow: hidden;
}
.status-bar {
flex-shrink: 0;
background: #f5f7fa;
border-bottom: 1rpx solid #e4e7ed;
padding: 20rpx 24rpx;
}
.hazard-title {
font-size: 32rpx;
font-weight: 600;
color: #303133;
line-height: 1.4;
margin-bottom: 16rpx;
word-break: break-all;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
font-size: 24rpx;
color: #909399;
}
.info-item {
line-height: 1.5;
flex-shrink: 0;
}
.info-item--right {
text-align: right;
}
.status-meta {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.info-label {
line-height: 1.5;
}
.status-tag {
display: inline-flex;
align-items: center;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.status-blue {
background: #ecf5ff;
border: 2rpx solid #b3d8ff;
color: #409eff;
}
.status-green {
background: #f0f9eb;
border: 2rpx solid #c2e7b0;
color: #67c23a;
}
.status-orange {
background: #fff7e6;
border: 2rpx solid #ffd591;
color: #fa8c16;
}
.status-red {
background: #fef0f0;
border: 2rpx solid #fbc4c4;
color: #f56c6c;
}
.status-yellow {
background: #fdf6ec;
border: 2rpx solid #f5dab1;
color: #e6a23c;
}
.status-gray {
background: #f4f4f5;
border: 2rpx solid #d3d4d6;
color: #909399;
}
.level-tag {
display: inline-flex;
align-items: center;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.level-normal {
background: #fffbe6;
border: 2rpx solid #ffe58f;
color: #faad14;
}
.level-major {
background: #fff1f0;
border: 2rpx solid #ffa39e;
color: #f5222d;
}
.loading-wrap,
.empty-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.loading-text,
.empty-text {
font-size: 28rpx;
color: #909399;
}
.detail-body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.steps-column {
width: 168rpx;
flex-shrink: 0;
height: 100%;
background: #fff;
border-right: 1rpx solid #ebeef5;
}
.step-item {
padding: 0 16rpx;
}
.step-track {
display: flex;
flex-direction: column;
align-items: center;
padding: 24rpx 0 0;
}
.step-dot {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #dcdfe6;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
transition: background 0.2s;
}
.step-dot--active {
background: #409eff;
}
.step-dot-text {
font-size: 22rpx;
font-weight: 600;
color: #606266;
text-align: center;
line-height: 1.2;
}
.step-item--active .step-dot-text {
color: #fff;
}
.step-line {
width: 4rpx;
height: 48rpx;
background: #e4e7ed;
margin: 8rpx 0;
}
.content-column {
flex: 1;
min-width: 0;
height: 100%;
box-sizing: border-box;
}
.node-section {
box-sizing: border-box;
padding: 20rpx 20rpx 0;
margin-bottom: 24rpx;
}
.node-section--last {
padding-bottom: 40rpx;
margin-bottom: 0;
}
.detail-card {
background: #fff;
border-radius: 16rpx;
border: 1rpx solid #ebeef5;
overflow: hidden;
}
.card-header {
display: flex;
flex-direction: column;
gap: 8rpx;
padding: 20rpx;
background: linear-gradient(135deg, #4facfe 0%, #2668ea 100%);
}
.card-node-name {
font-size: 30rpx;
font-weight: 600;
color: #fff;
line-height: 1.4;
}
.operator {
font-size: 28rpx;
font-weight: 600;
color: #fff;
line-height: 1.5;
word-break: break-all;
}
.time {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.85);
}
.card-body {
padding: 24rpx;
}
.detail-row {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 24rpx;
&:last-child {
margin-bottom: 0;
}
}
.detail-row--block {
margin-bottom: 24rpx;
}
.label {
display: flex;
align-items: center;
gap: 10rpx;
font-size: 24rpx;
color: #909399;
font-weight: 500;
&::before {
content: '';
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #909399;
flex-shrink: 0;
}
}
.value {
font-size: 28rpx;
color: #303133;
line-height: 1.6;
word-break: break-all;
}
.value--inline {
display: flex;
align-items: center;
}
.area-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
margin-right: 12rpx;
flex-shrink: 0;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 8rpx;
}
.attachment-img {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
background: #f5f7fa;
}
.sign-img {
width: 300rpx;
height: 160rpx;
margin-top: 8rpx;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
background: #fafafa;
}
.file-link {
display: inline-block;
padding: 12rpx 20rpx;
background: #f5f7fa;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
color: #409eff;
font-size: 24rpx;
}
.result-tag {
align-self: flex-start;
padding: 6rpx 16rpx;
border-radius: 6rpx;
font-size: 24rpx;
}
.result-tag--pass {
background: #f0f9eb;
color: #67c23a;
}
.result-tag--fail {
background: #fef0f0;
color: #f56c6c;
}
</style>

View File

@@ -0,0 +1,743 @@
<template>
<view class="hazard-detail-panel-v2">
<view v-if="loading" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="historyList.length === 0" class="empty-wrap">
<text class="empty-text">暂无历史记录</text>
</view>
<view v-else class="detail-body" :style="detailBodyStyle">
<scroll-view
class="steps-card"
scroll-y
:style="scrollAreaStyle"
:show-scrollbar="false"
:scroll-into-view="stepScrollIntoView"
:scroll-with-animation="true"
>
<view
v-for="(item, index) in historyList"
:key="'step-' + index"
:id="'hazard-step-' + index"
class="step-item"
:class="{ 'step-item--active': activeIndex === index }"
@tap="scrollToNode(index)"
>
<view class="step-track">
<view
class="step-dot"
:class="{ 'step-dot--active': activeIndex === index }"
>
<image
class="step-icon"
:src="getStepIconPath(item.type, activeIndex === index)"
mode="aspectFit"
/>
</view>
<text
class="step-label"
:class="{ 'step-label--active': activeIndex === index }"
>{{ item.nodeName }}</text>
<view v-if="index < historyList.length - 1" class="step-line"></view>
</view>
</view>
</scroll-view>
<scroll-view
class="content-column content-scroll"
scroll-y
:style="scrollAreaStyle"
:show-scrollbar="false"
:scroll-into-view="contentScrollIntoView"
:scroll-with-animation="scrollWithAnimation"
@scroll="onContentScroll"
@scrolltolower="onScrollToLower"
>
<view
v-for="(item, index) in historyList"
:key="'node-' + index"
:id="'hazard-node-' + index"
class="node-section"
:class="{ 'node-section--last': index === historyList.length - 1 }"
>
<view class="detail-card">
<view class="card-header-v2">
<view class="card-header-main">
<text class="operator">{{ item.titlePrefix }}人员{{ item.operator }}</text>
<text class="time">{{ item.time }}</text>
</view>
<view
v-if="item.type === 'submit' && item.content.levelName"
:class="['level-badge', getLevelTagClass(item.content)]"
>
{{ item.content.levelName }}
</view>
</view>
<view class="card-divider"></view>
<view class="card-body">
<!-- 提交 -->
<block v-if="item.type === 'submit'">
<view class="detail-row-v2">
<text class="label">隐患标题</text>
<text class="value">{{ item.content.title }}</text>
</view>
<view class="detail-row-v2">
<text class="label">检查形式</text>
<text class="value">{{ item.content.source }}</text>
</view>
<view class="detail-row-v2">
<text class="label">隐患区域</text>
<view class="value value--inline">
<view
v-if="item.content.areaColor"
class="area-dot"
:style="{ backgroundColor: item.content.areaColor }"
></view>
<text>{{ item.content.areaName }}</text>
</view>
</view>
<view class="detail-row-v2">
<text class="label">位置描述</text>
<text class="value">{{ item.content.address }}</text>
</view>
<view class="detail-row-v2">
<text class="label">隐患等级</text>
<view class="value">
<view :class="['level-tag', getLevelTagClass(item.content)]">
{{ item.content.levelName || '-' }}
</view>
</view>
</view>
<view class="detail-row-v2">
<text class="label">隐患标签</text>
<text class="value">{{ item.content.tagName }}</text>
</view>
<view class="detail-row-v2">
<text class="label">问题描述</text>
<text class="value">{{ item.content.description }}</text>
</view>
<view
v-if="item.content.attachments && item.content.attachments.length"
class="detail-row-v2 detail-row-v2--block"
>
<text class="label">隐患附件</text>
<view class="attachment-list">
<image
v-for="(file, idx) in item.content.attachments"
:key="idx"
class="attachment-img"
:src="resolveFileUrl(file.filePath)"
mode="aspectFill"
@tap="previewImages(item.content.attachments, idx)"
@load="scheduleMeasureLayout"
/>
</view>
</view>
<view class="detail-row-v2">
<text class="label">参考法规</text>
<text class="value">{{ item.content.legalBasis }}</text>
</view>
</block>
<!-- 交办 -->
<block v-else-if="item.type === 'assign'">
<view class="detail-row-v2">
<text class="label">指定整改责任人</text>
<text class="value">{{ item.content.assigneeName }}</text>
</view>
<view class="detail-row-v2">
<text class="label">指定整改截至日期</text>
<text class="value">{{ item.content.deadline }}</text>
</view>
</block>
<!-- 整改 -->
<block v-else-if="item.type === 'rectify'">
<view class="detail-row-v2">
<text class="label">整改状态</text>
<text class="value">{{ item.content.rectifyStatusName }}</text>
</view>
<view class="detail-row-v2">
<text class="label">整改方案</text>
<text class="value">{{ item.content.rectifyPlan }}</text>
</view>
<view class="detail-row-v2">
<text class="label">整改结果</text>
<text class="value">{{ item.content.rectifyResult }}</text>
</view>
<view class="detail-row-v2">
<text class="label">整改措施</text>
<text class="value">{{ item.content.rectificationMeasures }}</text>
</view>
<view class="detail-row-v2">
<text class="label">管控措施</text>
<text class="value">{{ item.content.controlMeasures }}</text>
</view>
<view class="detail-row-v2">
<text class="label">限定整改时间</text>
<text class="value">{{ item.content.deadline }}</text>
</view>
<view class="detail-row-v2">
<text class="label">整改责任人</text>
<text class="value">{{ item.content.rectifierNames }}</text>
</view>
<view class="detail-row-v2">
<text class="label">管理人员</text>
<text class="value">{{ item.content.managerNames }}</text>
</view>
<view class="detail-row-v2">
<text class="label">预计费用</text>
<text class="value">{{ formatCost(item.content.planCost) }}</text>
</view>
<view class="detail-row-v2">
<text class="label">实际费用</text>
<text class="value">{{ formatCost(item.content.actualCost) }}</text>
</view>
<view
v-if="item.content.attachments && item.content.attachments.length"
class="detail-row-v2 detail-row-v2--block"
>
<text class="label">整改附件</text>
<view class="attachment-list">
<template v-for="(file, idx) in item.content.attachments" :key="idx">
<image
v-if="file.fileType === 'image'"
class="attachment-img"
:src="resolveFileUrl(file.filePath)"
mode="aspectFill"
@tap="previewImages(item.content.attachments, idx)"
@load="scheduleMeasureLayout"
/>
<text v-else class="file-link">{{ file.fileName || '附件' }}</text>
</template>
</view>
</view>
<view v-if="item.content.signPath" class="detail-row-v2 detail-row-v2--block">
<text class="label">整改签字</text>
<image
class="sign-img"
:src="resolveFileUrl(item.content.signPath)"
mode="aspectFit"
@tap="previewSingle(item.content.signPath)"
@load="scheduleMeasureLayout"
/>
</view>
</block>
<!-- 验收 -->
<block v-else-if="item.type === 'verify'">
<view class="detail-row-v2">
<text class="label">验收结果</text>
<text
class="result-tag"
:class="item.content.resultName === '通过' ? 'result-tag--pass' : 'result-tag--fail'"
>
{{ item.content.resultName }}
</text>
</view>
<view v-if="item.content.remark" class="detail-row-v2">
<text class="label">验收备注</text>
<text class="value">{{ item.content.remark }}</text>
</view>
<view
v-if="item.content.attachments && item.content.attachments.length"
class="detail-row-v2 detail-row-v2--block"
>
<text class="label">验收附件</text>
<view class="attachment-list">
<template v-for="(file, idx) in item.content.attachments" :key="idx">
<image
v-if="file.fileType === 'image'"
class="attachment-img"
:src="resolveFileUrl(file.filePath)"
mode="aspectFill"
@tap="previewImages(item.content.attachments, idx)"
@load="scheduleMeasureLayout"
/>
<text v-else class="file-link">{{ file.fileName || '附件' }}</text>
</template>
</view>
</view>
<view v-if="item.content.signPath" class="detail-row-v2 detail-row-v2--block">
<text class="label">验收签字</text>
<image
class="sign-img"
:src="resolveFileUrl(item.content.signPath)"
mode="aspectFit"
@tap="previewSingle(item.content.signPath)"
@load="scheduleMeasureLayout"
/>
</view>
</block>
<!-- 销号 -->
<block v-else-if="item.type === 'writeoff'">
<view class="detail-row-v2">
<text class="label">销号结果</text>
<text
class="result-tag"
:class="item.content.resultName === '通过' ? 'result-tag--pass' : 'result-tag--fail'"
>
{{ item.content.resultName }}
</text>
</view>
<view v-if="item.content.remark" class="detail-row-v2">
<text class="label">销号备注</text>
<text class="value">{{ item.content.remark }}</text>
</view>
<view
v-if="item.content.attachments && item.content.attachments.length"
class="detail-row-v2 detail-row-v2--block"
>
<text class="label">销号附件</text>
<view class="attachment-list">
<template v-for="(file, idx) in item.content.attachments" :key="idx">
<image
v-if="file.fileType === 'image'"
class="attachment-img"
:src="resolveFileUrl(file.filePath)"
mode="aspectFill"
@tap="previewImages(item.content.attachments, idx)"
@load="scheduleMeasureLayout"
/>
<text v-else class="file-link">{{ file.fileName || '附件' }}</text>
</template>
</view>
</view>
<view v-if="item.content.signPath" class="detail-row-v2 detail-row-v2--block">
<text class="label">销号签字</text>
<image
class="sign-img"
:src="resolveFileUrl(item.content.signPath)"
mode="aspectFit"
@tap="previewSingle(item.content.signPath)"
@load="scheduleMeasureLayout"
/>
</view>
</block>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { computed, toRefs, watch } from 'vue';
import { toImageUrl } from '@/request/request.js';
import { getStepIconPath } from './hazardDetail.js';
import { useHazardDetailScroll } from './useHazardDetailScroll.js';
const props = defineProps({
detail: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
},
bodyHeight: {
type: Number,
default: 0
}
});
const { detail, loading } = toRefs(props);
const {
historyList,
activeIndex,
contentScrollIntoView,
stepScrollIntoView,
scrollWithAnimation,
onContentScroll,
onScrollToLower,
scrollToNode,
scheduleMeasureLayout
} = useHazardDetailScroll(detail, loading);
const scrollAreaStyle = computed(() => {
const height = props.bodyHeight > 0 ? props.bodyHeight : 400;
return { height: `${height}px` };
});
const detailBodyStyle = computed(() => {
const height = props.bodyHeight > 0 ? props.bodyHeight : 400;
return { height: `${height}px` };
});
watch(
() => props.bodyHeight,
(height) => {
if (height > 0) {
scheduleMeasureLayout();
setTimeout(scheduleMeasureLayout, 100);
}
}
);
const LEVEL_CLASS_MAP = {
2: 'level-normal',
3: 'level-major'
};
const LEVEL_NAME_CLASS_MAP = {
一般: 'level-normal',
一般隐患: 'level-normal',
重大: 'level-major',
重大隐患: 'level-major'
};
const getLevelTagClass = (content) => {
const level = content?.level;
const levelName = content?.levelName;
if (level != null && LEVEL_CLASS_MAP[level]) {
return LEVEL_CLASS_MAP[level];
}
if (levelName && LEVEL_NAME_CLASS_MAP[levelName]) {
return LEVEL_NAME_CLASS_MAP[levelName];
}
return '';
};
const resolveFileUrl = (path) => toImageUrl(path);
const formatCost = (cost) => {
if (cost == null || cost === '') return '-';
const num = Number(cost);
return Number.isNaN(num) ? '-' : `${num}`;
};
const previewSingle = (path) => {
const url = resolveFileUrl(path);
if (!url) return;
uni.previewImage({ current: url, urls: [url] });
};
const previewImages = (attachments, index) => {
const urls = (attachments || [])
.filter((f) => f.fileType === 'image' || !f.fileType)
.map((f) => resolveFileUrl(f.filePath))
.filter(Boolean);
if (!urls.length) return;
uni.previewImage({ current: urls[index] || urls[0], urls });
};
</script>
<style lang="scss" scoped>
.hazard-detail-panel-v2 {
flex: 1;
min-height: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.loading-wrap,
.empty-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.loading-text,
.empty-text {
font-size: 28rpx;
color: #909399;
}
.detail-body {
display: flex;
gap: 20rpx;
align-items: stretch;
overflow: hidden;
box-sizing: border-box;
}
.steps-card {
width: 140rpx;
flex-shrink: 0;
background: #fff;
border-radius: 20rpx;
box-sizing: border-box;
}
.step-item {
padding: 0 12rpx;
}
.step-track {
display: flex;
flex-direction: column;
align-items: center;
padding: 28rpx 0 0;
}
.step-dot {
width: 66rpx;
height: 66rpx;
border-radius: 50%;
background: #f3f3f3;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.step-dot--active {
background: #2667e9;
}
.step-icon {
width: 36rpx;
height: 36rpx;
}
.step-label {
margin-top: 12rpx;
font-size: 24rpx;
color: #999;
line-height: 1.2;
}
.step-label--active {
color: #2667e9;
font-weight: 600;
}
.step-line {
width: 0;
height: 40rpx;
margin: 10rpx 0;
border-left: 2rpx dashed #dcdfe6;
}
.content-column {
flex: 1;
width: 0;
min-height: 0;
box-sizing: border-box;
}
.content-scroll {
width: 100%;
box-sizing: border-box;
}
.node-section {
box-sizing: border-box;
padding: 0;
margin-bottom: 24rpx;
}
.node-section--last {
padding-bottom: 40rpx;
margin-bottom: 0;
}
.detail-card {
background: #fff;
border-radius: 20rpx;
overflow: hidden;
padding: 28rpx 38rpx;
box-sizing: border-box;
}
.card-header-v2 {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
padding: 0;
}
.card-header-main {
flex: 1;
min-width: 0;
}
.operator {
font-size: 28rpx;
font-weight: 600;
color: #303133;
line-height: 1.5;
word-break: break-all;
}
.time {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #999;
line-height: 1.4;
}
.level-badge {
flex-shrink: 0;
padding: 6rpx 16rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 500;
white-space: nowrap;
}
.level-badge.level-normal {
background: #fff7e6;
border: 2rpx solid #ffd591;
color: #fa8c16;
}
.level-badge.level-major {
background: #fff1f0;
border: 2rpx solid #ffa39e;
color: #f5222d;
}
.card-divider {
height: 0;
margin: 20rpx 0;
border-top: 2rpx dashed #eee;
}
.card-body {
padding: 0;
}
.detail-row-v2 {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 24rpx;
padding: 18rpx 0;
}
.detail-row-v2--block {
flex-wrap: wrap;
}
.label {
flex-shrink: 0;
// width: 156rpx;
font-size: 26rpx;
color: #999;
line-height: 1.5;
}
.value {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #333;
line-height: 1.6;
text-align: left;
word-break: break-all;
}
.value--inline {
display: flex;
align-items: center;
}
.area-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
margin-right: 12rpx;
flex-shrink: 0;
}
.level-tag {
display: inline-flex;
align-items: center;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.level-normal {
background: #fff7e6;
border: 2rpx solid #ffd591;
color: #fa8c16;
}
.level-major {
background: #fff1f0;
border: 2rpx solid #ffa39e;
color: #f5222d;
}
.tag-badge {
display: inline-flex;
align-items: center;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
background: #eef3ff;
border: 2rpx solid #aac5fc;
color: #2667e9;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
flex: 1;
min-width: 0;
}
.attachment-img {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
background: #f5f7fa;
}
.sign-img {
width: 300rpx;
height: 160rpx;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
background: #fafafa;
flex: 1;
min-width: 0;
max-width: 100%;
}
.file-link {
display: inline-block;
padding: 12rpx 20rpx;
background: #f5f7fa;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
color: #2667e9;
font-size: 24rpx;
}
.result-tag {
padding: 6rpx 16rpx;
border-radius: 6rpx;
font-size: 24rpx;
}
.result-tag--pass {
background: #f0f9eb;
color: #67c23a;
}
.result-tag--fail {
background: #fef0f0;
color: #f56c6c;
}
</style>

View File

@@ -0,0 +1,371 @@
/** 隐患详情历史节点类型 */
export const HAZARD_NODE_TYPES = {
SUBMIT: 'submit',
ASSIGN: 'assign',
RECTIFY: 'rectify',
VERIFY: 'verify',
WRITEOFF: 'writeoff'
};
export const HAZARD_STATUS_CLASS_MAP = {
1: 'status-blue',
2: 'status-orange',
3: 'status-red',
4: 'status-yellow',
5: 'status-green'
};
const STATUS_NAME_CLASS_MAP = {
待交办: 'status-blue',
待整改: 'status-orange',
整改中: 'status-orange',
待验收: 'status-red',
待销号: 'status-yellow',
已完成: 'status-green',
已销号: 'status-green'
};
const LEVEL_NAME_MAP = {
2: '一般隐患',
3: '重大隐患'
};
export function getLevelName(level) {
return LEVEL_NAME_MAP[level] || '未知';
}
export function getStatusClass(status, statusName) {
if (statusName && STATUS_NAME_CLASS_MAP[statusName]) {
return STATUS_NAME_CLASS_MAP[statusName];
}
return HAZARD_STATUS_CLASS_MAP[status] || '';
}
const LEVEL_CLASS_MAP = {
2: 'level-normal',
3: 'level-major'
};
const LEVEL_NAME_CLASS_MAP = {
一般: 'level-normal',
一般隐患: 'level-normal',
重大: 'level-major',
重大隐患: 'level-major'
};
export function getStepIconPath(type, active = false) {
const baseMap = {
submit: 'tijiao',
assign: 'jiaoban',
rectify: 'zhenggai',
verify: 'yanshou',
writeoff: 'xiaohao'
};
const base = baseMap[type] || 'tijiao';
if (base === 'zhenggai') {
return active
? '/static/yinhuan_detail/zhenggai_selected.png'
: '/static/yinhuan_detail/zhenggai__unselected.png';
}
return `/static/yinhuan_detail/${base}__${active ? 'selected' : 'unselected'}.png`;
}
export function getLevelClass(level, levelName) {
if (level != null && LEVEL_CLASS_MAP[level]) {
return LEVEL_CLASS_MAP[level];
}
if (levelName && LEVEL_NAME_CLASS_MAP[levelName]) {
return LEVEL_NAME_CLASS_MAP[levelName];
}
return '';
}
/** 将姓名数组或字符串格式化为「a、b、c」 */
export function formatNameList(names, fallback = '-') {
if (Array.isArray(names) && names.length) {
return names.join('、');
}
if (typeof names === 'string' && names.trim()) {
const parts = names.split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
return parts.length ? parts.join('、') : names;
}
return fallback;
}
/** 判断是否为销号记录type=2 或 typeName=销号) */
export function isWriteoffRecord(record) {
if (!record) return false;
return record.type === 2 || record.typeName === '销号';
}
function buildVerifyNode(record) {
return {
type: HAZARD_NODE_TYPES.VERIFY,
nodeName: '验收',
titlePrefix: '验收',
operator: record.verifierName || '-',
time: record.verifyTime || '-',
content: {
resultName: record.resultName || '-',
remark: record.remark || '',
attachments: record.attachments || [],
signPath: record.signPath || '',
verifyDeptName: record.verifyDeptName || '-'
}
};
}
function buildWriteoffNode(record) {
return {
type: HAZARD_NODE_TYPES.WRITEOFF,
nodeName: '销号',
titlePrefix: '销号',
operator: record.verifierName || '-',
time: record.verifyTime || '-',
content: {
resultName: record.resultName || '-',
remark: record.remark || '',
attachments: record.attachments || [],
signPath: record.signPath || '',
writeoffDeptName: record.writeoffDeptName || '-'
}
};
}
/** 合并验收/销号记录,按 verifyId 去重 */
function appendAuditNodes(list, records, seenIds) {
(records || []).forEach((record) => {
if (!record) return;
const key = record.verifyId ?? record.id;
if (key != null) {
if (seenIds.has(key)) return;
seenIds.add(key);
}
list.push(isWriteoffRecord(record) ? buildWriteoffNode(record) : buildVerifyNode(record));
});
}
function resolveWriteoffRecords(flow) {
return flow.writeOffs || flow.writeoffs || [];
}
/** 解析整改责任人(兼容数组、逗号分隔字符串、单人字段) */
export function resolveRectifierNames(rectify) {
if (!rectify) return '-';
if (Array.isArray(rectify.rectifierNames) && rectify.rectifierNames.length) {
return rectify.rectifierNames.join('、');
}
if (Array.isArray(rectify.memberNames) && rectify.memberNames.length) {
return rectify.memberNames.join('、');
}
return formatNameList(rectify.rectifierName, '-');
}
/**
* 将隐患详情转为步骤历史列表(与后台 HazardDetailDrawer 保持一致)
* @param {Object} data 隐患详情
* @returns {Array}
*/
export function generateHazardHistory(data) {
if (!data) return [];
const list = [];
list.push({
type: HAZARD_NODE_TYPES.SUBMIT,
nodeName: '提交',
titlePrefix: '提交',
operator: data.reporterName || '-',
time: data.createdAt || '-',
content: {
title: data.title || '-',
source: data.source || '-',
areaName: data.areaName || '-',
areaColor: data.areaColor || '',
address: data.address || '-',
level: data.level,
levelName: data.levelName || getLevelName(data.level),
tagName: data.tagName || '-',
description: data.description || '-',
attachments: data.attachments || [],
legalBasis: data.legalBasis || data.regulationName || '-',
reporterPhone: data.reporterPhone || '-',
reportDeptName: data.reportDeptName || '-'
}
});
if (data.assignFlows && data.assignFlows.length > 0) {
data.assignFlows.forEach((flow) => {
if (flow.assignId) {
list.push({
type: HAZARD_NODE_TYPES.ASSIGN,
nodeName: '交办',
titlePrefix: '交办',
operator: flow.assignerName || '-',
time: flow.assignTime || '-',
content: {
assigneeName: flow.assigneeName || '-',
deadline: flow.deadline || '-',
assignDeptName: flow.assignDeptName || '-',
assignRemark: flow.assignRemark || '-',
priorityName: flow.priorityName || '-'
}
});
}
if (flow.rectify) {
list.push({
type: HAZARD_NODE_TYPES.RECTIFY,
nodeName: '整改',
titlePrefix: '整改',
operator: flow.rectify.rectifierName || '-',
time: flow.rectify.rectifyTime || '-',
content: {
rectifyPlan: flow.rectify.rectifyPlan || '-',
rectifyResult: flow.rectify.rectifyResult || '-',
rectificationMeasures: flow.rectify.rectificationMeasures || '-',
controlMeasures: flow.rectify.controlMeasures || '-',
deadline: flow.deadline || '-',
rectifierNames: resolveRectifierNames(flow.rectify),
managerNames: formatNameList(flow.rectify.managerNames, '-'),
planCost: flow.rectify.planCost,
actualCost: flow.rectify.actualCost,
attachments: flow.rectify.attachments || [],
signPath: flow.rectify.signPath || '',
rectifyStatusName: flow.rectify.rectifyStatusName || '-'
}
});
}
const seenAuditIds = new Set();
appendAuditNodes(list, flow.verifies || [], seenAuditIds);
appendAuditNodes(list, resolveWriteoffRecords(flow), seenAuditIds);
});
}
return list;
}
const LONG_DESCRIPTION =
'消防通道内堆放大量纸箱及杂物,影响疏散通道畅通,存在火灾安全隐患。' +
'现场检查发现通道宽度不足1.2米,部分区域被临时物料占用,紧急情况下可能影响人员撤离。' +
'建议立即清理并建立日常巡查机制,防止问题反弹。';
/** 开发联调前使用的 mock 详情数据(各节点均含完整假数据) */
export const MOCK_HAZARD_DETAIL = {
id: 'mock-hazard-001',
status: 2,
statusName: '整改中',
reporterName: '张三',
reporterPhone: '13800138000',
reportDeptName: '安全管理部',
createdAt: '2026-06-01 09:30:00',
title: '消防通道堆放杂物',
level: 2,
levelName: '一般隐患',
source: '部门检查',
areaName: '生产车间A区',
areaColor: '#409EFF',
address: '办公楼3层东侧消防通道',
tagName: '消防安全',
description: LONG_DESCRIPTION,
legalBasis: '《中华人民共和国消防法》第二十八条',
regulationName: '《中华人民共和国消防法》第二十八条',
attachments: [
{
fileName: 'hazard-1.jpg',
filePath: 'https://picsum.photos/seed/hazard1/200/200',
fileType: 'image'
},
{
fileName: 'hazard-2.jpg',
filePath: 'https://picsum.photos/seed/hazard2/200/200',
fileType: 'image'
},
{
fileName: 'hazard-3.jpg',
filePath: 'https://picsum.photos/seed/hazard3/200/200',
fileType: 'image'
}
],
assignFlows: [
{
assignId: 'mock-assign-001',
assignerName: '李主管',
assignTime: '2026-06-02 10:00:00',
assigneeName: '王整改',
assignDeptName: '设备保障部',
assignRemark: '该隐患位于主疏散通道,请优先处理,务必在整改期限内完成闭环。',
priorityName: '高',
deadline: '2026-06-10 18:00:00',
rectify: {
rectifyId: 'mock-rectify-001',
rectifierName: '王整改',
rectifyTime: '2026-06-08 15:20:00',
rectifyStatusName: '已完成',
rectifyPlan:
'立即清理消防通道杂物,并设置警示标识,后续每周巡查一次。' +
'对责任区域进行划分,明确禁止堆放物料的范围,同时组织一次消防安全培训。',
rectifyResult: '已完成清理,通道恢复畅通,现场已张贴禁止堆放标识。',
rectificationMeasures: '组织人员清理杂物,划定禁止堆放区域,增加日常巡检频次。',
controlMeasures: '安装监控并纳入日常巡检清单,发现反弹情况立即上报。',
rectifierNames: ['王整改', '赵协助'],
managerNames: ['李主管'],
planCost: 500,
actualCost: 380,
attachments: [
{
fileName: 'rectify-1.jpg',
filePath: 'https://picsum.photos/seed/rectify1/200/200',
fileType: 'image'
},
{
fileName: 'rectify-2.jpg',
filePath: 'https://picsum.photos/seed/rectify2/200/200',
fileType: 'image'
}
],
signPath: 'https://picsum.photos/seed/sign1/300/120'
},
verifies: [
{
verifierName: '陈验收',
verifyTime: '2026-06-09 11:00:00',
verifyDeptName: '安全监察组',
resultName: '通过',
remark: '现场复查通道畅通,整改到位,未发现反弹迹象。',
attachments: [
{
fileName: 'verify-1.jpg',
filePath: 'https://picsum.photos/seed/verify1/200/200',
fileType: 'image'
},
{
fileName: 'verify-2.jpg',
filePath: 'https://picsum.photos/seed/verify2/200/200',
fileType: 'image'
}
],
signPath: 'https://picsum.photos/seed/sign2/300/120'
}
],
writeOffs: [
{
verifierName: '刘销号',
verifyTime: '2026-06-10 16:30:00',
writeoffDeptName: '公司安委会',
resultName: '通过',
remark: '隐患已消除,整改资料齐全,同意销号。',
attachments: [
{
fileName: 'writeoff-1.jpg',
filePath: 'https://picsum.photos/seed/writeoff1/200/200',
fileType: 'image'
}
],
signPath: 'https://picsum.photos/seed/sign3/300/120'
}
]
}
]
};

View File

@@ -0,0 +1 @@
export * from './hazardDetail.js';

View File

@@ -0,0 +1,164 @@
import { ref, watch, nextTick, getCurrentInstance } from 'vue';
import { onReady } from '@dcloudio/uni-app';
import { generateHazardHistory } from './hazardDetail.js';
/**
* 隐患详情左右联动滚动(与 HazardDetailPanel 保持一致)
* - 右侧滚动时,根据 node-section 位置同步左侧 activeIndex
* - 点击左侧步骤时,右侧 scroll-into-view 定位到对应卡片
*/
export function useHazardDetailScroll(detailSource, loadingSource) {
const instance = getCurrentInstance();
const queryScope = instance?.proxy || instance;
const historyList = ref([]);
const activeIndex = ref(0);
const sectionOffsets = ref([0]);
const contentViewHeight = ref(0);
const contentScrollIntoView = ref('');
const stepScrollIntoView = ref('');
const scrollWithAnimation = ref(true);
const isProgrammaticScroll = ref(false);
let measureLayoutTimer = null;
const measureLayout = () => {
if (!historyList.value.length) return;
nextTick(() => {
const query = uni.createSelectorQuery().in(queryScope);
query.select('.content-scroll').boundingClientRect();
query.select('.content-scroll').scrollOffset();
query.selectAll('.node-section').boundingClientRect();
query.exec((res) => {
const containerRect = res?.[0];
const scrollOffset = res?.[1];
const sections = res?.[2] || [];
if (!containerRect || !sections.length) return;
contentViewHeight.value = containerRect.height || 0;
const baseScrollTop = scrollOffset?.scrollTop || 0;
sectionOffsets.value = sections.map(
(section) => section.top - containerRect.top + baseScrollTop
);
});
});
};
const scheduleMeasureLayout = () => {
clearTimeout(measureLayoutTimer);
measureLayoutTimer = setTimeout(() => {
measureLayout();
}, 80);
};
const rebuildHistory = (data) => {
historyList.value = generateHazardHistory(data);
activeIndex.value = 0;
contentScrollIntoView.value = '';
scheduleMeasureLayout();
setTimeout(scheduleMeasureLayout, 300);
};
const syncActiveIndex = (scrollTop, scrollHeight = 0) => {
const count = historyList.value.length;
if (!count) return;
if (scrollHeight > 0 && contentViewHeight.value > 0) {
if (scrollTop + contentViewHeight.value >= scrollHeight - 60) {
if (activeIndex.value !== count - 1) {
activeIndex.value = count - 1;
}
return;
}
}
const offsets = sectionOffsets.value;
if (!offsets.length) return;
let idx = 0;
for (let i = offsets.length - 1; i >= 0; i--) {
if (scrollTop >= offsets[i] - 80) {
idx = i;
break;
}
}
if (idx !== activeIndex.value) {
activeIndex.value = idx;
}
};
const onContentScroll = (e) => {
if (isProgrammaticScroll.value) return;
const { scrollTop = 0, scrollHeight = 0 } = e.detail || {};
syncActiveIndex(scrollTop, scrollHeight);
scheduleMeasureLayout();
};
const onScrollToLower = () => {
const count = historyList.value.length;
if (count > 0 && activeIndex.value !== count - 1) {
activeIndex.value = count - 1;
}
};
const scrollToNode = (index) => {
if (index < 0 || index >= historyList.value.length) return;
isProgrammaticScroll.value = true;
scrollWithAnimation.value = true;
activeIndex.value = index;
contentScrollIntoView.value = 'hazard-node-' + index;
setTimeout(() => {
contentScrollIntoView.value = '';
isProgrammaticScroll.value = false;
scheduleMeasureLayout();
}, 350);
};
watch(
() => (typeof loadingSource === 'function' ? loadingSource() : loadingSource?.value),
(loading) => {
if (!loading) {
scheduleMeasureLayout();
setTimeout(scheduleMeasureLayout, 300);
}
}
);
watch(
() => (typeof detailSource === 'function' ? detailSource() : detailSource?.value),
(val) => rebuildHistory(val),
{ immediate: true, deep: true }
);
watch(activeIndex, (index) => {
stepScrollIntoView.value = 'hazard-step-' + index;
setTimeout(() => {
stepScrollIntoView.value = '';
}, 300);
});
watch(
() => historyList.value.length,
() => scheduleMeasureLayout()
);
onReady(() => {
scheduleMeasureLayout();
setTimeout(scheduleMeasureLayout, 300);
});
return {
historyList,
activeIndex,
contentScrollIntoView,
stepScrollIntoView,
scrollWithAnimation,
onContentScroll,
onScrollToLower,
scrollToNode,
scheduleMeasureLayout
};
}