Files
threeonecheck_web/components/hazardDetail/HazardDetailPanel.vue
2026-06-30 09:47:10 +08:00

769 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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.code }}</text>
</view>
<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>
<text class="value">{{ item.content.hazardSourceName }}</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>