隐患详情重新设计,检查计划重新设计成做题排查式
This commit is contained in:
1182
components/hazard/HazardFormPopup.vue
Normal file
1182
components/hazard/HazardFormPopup.vue
Normal file
File diff suppressed because it is too large
Load Diff
72
components/hazard/hazardForm.js
Normal file
72
components/hazard/hazardForm.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { buildAttachmentItem } from '@/utils/upload.js';
|
||||
|
||||
export const LEVEL_OPTIONS = [
|
||||
{ id: 2, title: '一般隐患' },
|
||||
{ id: 3, title: '重大隐患' }
|
||||
];
|
||||
|
||||
export const SOURCE_OPTIONS = [
|
||||
{ id: 1, title: '部门检查' },
|
||||
{ id: 2, title: '督导检查' },
|
||||
{ id: 3, title: '企业自查' },
|
||||
{ id: 4, title: '行业互查' }
|
||||
];
|
||||
|
||||
export const AI_LEVEL_MAP = {
|
||||
轻微: 0,
|
||||
轻微隐患: 0,
|
||||
一般: 0,
|
||||
一般隐患: 0,
|
||||
重大: 1,
|
||||
重大隐患: 1
|
||||
};
|
||||
|
||||
export function createEmptyHazardPayload() {
|
||||
return {
|
||||
formData: {
|
||||
title: '',
|
||||
level: 0,
|
||||
source: 0,
|
||||
description: '',
|
||||
tagIndex: 0,
|
||||
tagId: null,
|
||||
regulationId: null,
|
||||
regulationName: ''
|
||||
},
|
||||
address: '',
|
||||
lng: 0,
|
||||
lat: 0,
|
||||
areaId: '',
|
||||
areaName: '',
|
||||
fileList: []
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHiddenDangerParams(payload, extra = {}) {
|
||||
const { formData, address, lng, lat, areaId, fileList } = payload;
|
||||
const selectedTag = payload.tagOptions?.[formData.tagIndex];
|
||||
const tagId = formData.tagId ?? (selectedTag ? selectedTag.id : null);
|
||||
|
||||
return {
|
||||
title: formData.title,
|
||||
level: LEVEL_OPTIONS[formData.level]?.id || 2,
|
||||
lng: lng || 0,
|
||||
lat: lat || 0,
|
||||
address: address || '',
|
||||
areaId: areaId || null,
|
||||
description: formData.description || '',
|
||||
source: SOURCE_OPTIONS[formData.source]?.title || '',
|
||||
tagId,
|
||||
attachments: (fileList || [])
|
||||
.filter((f) => f.status === 'success')
|
||||
.map((file) => buildAttachmentItem(file)),
|
||||
regulationId: formData.regulationId || null,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
export function hasValidHazardPayload(payload) {
|
||||
if (!payload) return false;
|
||||
const { formData, fileList } = payload;
|
||||
return !!(formData?.title && fileList?.length > 0);
|
||||
}
|
||||
1
components/hazard/index.js
Normal file
1
components/hazard/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './hazardForm.js';
|
||||
760
components/hazardDetail/HazardDetailPanel.vue
Normal file
760
components/hazardDetail/HazardDetailPanel.vue
Normal 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>
|
||||
743
components/hazardDetail/HazardDetailPanelV2.vue
Normal file
743
components/hazardDetail/HazardDetailPanelV2.vue
Normal 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>
|
||||
371
components/hazardDetail/hazardDetail.js
Normal file
371
components/hazardDetail/hazardDetail.js
Normal 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
1
components/hazardDetail/index.js
Normal file
1
components/hazardDetail/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './hazardDetail.js';
|
||||
164
components/hazardDetail/useHazardDetailScroll.js
Normal file
164
components/hazardDetail/useHazardDetailScroll.js
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user