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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,284 @@
<template>
<view class="page">
<u-navbar
title="检查列表"
:placeholder="true"
:safeAreaInsetTop="true"
bgColor="#3375e6"
titleColor="#ffffff"
leftIconColor="#ffffff"
:autoBack="false"
@leftClick="goHome"
/>
<view class="page-content padding">
<view v-if="loading" class="empty-tip text-gray text-center padding">加载中...</view>
<view v-else-if="!tableId" class="empty-tip text-gray text-center padding">
请从首页检查计划进入
</view>
<view v-else-if="checkPlanData.length === 0" class="empty-tip text-gray text-center padding">
暂无检查任务
</view>
<view
v-else
class="plan-card margin-bottom"
v-for="item in checkPlanData"
:key="item.id || item.taskDate"
>
<view class="plan-header">
<text class="plan-header-title">{{ item.name || planName || '检查任务' }}</text>
</view>
<view class="plan-body">
<view class="flex">
<view class="border-border margin-right-xs">{{ item.runModeName }}完成</view>
<view class="border-border">{{ item.cycle }}</view>
</view>
<view v-if="item.taskDate" class="flex text-gray margin-top">
<view>任务日期</view>
<view style="color: #333333;">{{ formatDate(item.taskDate) }}</view>
</view>
<view class="flex text-gray margin-top">
<view>计划时间</view>
<view style="color: #333333;">
{{ formatDate(item.planStartTime) }}{{ formatDate(item.planEndTime) }}
</view>
</view>
<view class="flex margin-top align-center">
<view style="color: #B5B5B5;">完成进度</view>
<view class="flex align-center margin-left-sm">
<view class="cu-progress round">
<view class="bg-green" :style="{ width: formatProgress(item.progress) + '%' }"></view>
</view>
<text class="margin-left-sm">{{ formatProgress(item.progress) }}%</text>
</view>
</view>
<view class="plan-stats margin-top">
<view class="plan-stat-item">
<view class="plan-stat-num text-orange">{{ item.totalCount ?? 0 }}</view>
<view class="plan-stat-label">总检项</view>
</view>
<view class="plan-stat-item">
<view class="plan-stat-num text-yellow">{{ item.pendingCount ?? 0 }}</view>
<view class="plan-stat-label">待排查</view>
</view>
<view class="plan-stat-item">
<view class="plan-stat-num text-red">{{ item.unusualNum ?? 0 }}</view>
<view class="plan-stat-label">异常数</view>
</view>
<view class="plan-stat-item">
<view class="plan-stat-num text-blue">{{ item.finishedCount ?? 0 }}</view>
<view class="plan-stat-label">已完成</view>
</view>
</view>
<view class="margin-top margin-bottom flex justify-end">
<!-- <button class="cu-btn round lg light bg-blue margin-right" @click.stop="viewDetails(item)">
查看详情
</button> -->
<button
class="cu-btn round lg bg-blue"
@click.stop="startCheck(item)"
>
检查处置
</button>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad, onShow, onBackPress } from '@dcloudio/uni-app';
import { getPlanTableDetail } from '@/request/api.js';
const loading = ref(true);
const checkPlanData = ref([]);
const tableId = ref('');
const planName = ref('');
const listParams = {
pageNum: 1,
pageSize: 100,
name: ''
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return String(dateStr).split(' ')[0];
};
const formatProgress = (progress) => {
const num = Number(progress);
if (Number.isNaN(num)) return 0;
return Math.min(100, Math.max(0, num));
};
const fetchList = async () => {
if (!tableId.value) {
loading.value = false;
checkPlanData.value = [];
return;
}
loading.value = true;
try {
const res = await getPlanTableDetail({
...listParams,
tableId: tableId.value
});
if (res.code === 0) {
checkPlanData.value = res.data?.records || [];
} else {
uni.showToast({ title: res.msg || '获取检查清单失败', icon: 'none' });
}
} catch (error) {
console.error('获取检查清单失败:', error);
uni.showToast({ title: '获取检查清单失败', icon: 'none' });
} finally {
loading.value = false;
}
};
const viewDetails = (item) => {
uni.navigateTo({
url: `/pages/plandetail/plandetail?id=${item.id || tableId.value}`
});
};
const startCheck = (item) => {
const oneTableId = item.id;
const taskDate = formatDate(item.taskDate);
if (!oneTableId || !taskDate) {
uni.showToast({ title: '缺少任务参数', icon: 'none' });
return;
}
const name = item.name || planName.value || '';
const tableIdQuery = tableId.value ? `&tableId=${tableId.value}` : '';
uni.navigateTo({
url: `/pages/Inspectionresult/detail?oneTableId=${oneTableId}&taskDate=${encodeURIComponent(taskDate)}&name=${encodeURIComponent(name)}${tableIdQuery}`
});
};
onLoad((options) => {
if (options.tableId) {
tableId.value = options.tableId;
}
if (options.name) {
planName.value = decodeURIComponent(options.name);
}
});
onShow(() => {
fetchList();
});
const goHome = () => {
uni.switchTab({
url: '/pages/index/index'
});
};
onBackPress(() => {
goHome();
return true;
});
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #EBF2FC;
}
.empty-tip {
font-size: 28rpx;
}
.plan-card {
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0rpx 2rpx 6rpx 2rpx rgba(0, 0, 0, 0.06);
}
.plan-header {
background: linear-gradient(135deg, #4A90E2 0%, #2667E9 100%);
padding: 24rpx 30rpx;
display: flex;
align-items: center;
.plan-header-title {
color: #fff;
font-size: 30rpx;
font-weight: bold;
}
}
.plan-body {
padding: 24rpx 30rpx 10rpx 30rpx;
background: #fff;
}
.plan-stats {
display: flex;
background: #F5F7FA;
border-radius: 12rpx;
border: 1rpx solid #E8ECF0;
overflow: hidden;
.plan-stat-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
border-right: 1rpx solid #E8ECF0;
&:last-child {
border-right: none;
}
}
.plan-stat-num {
font-size: 36rpx;
font-weight: bold;
}
.plan-stat-label {
font-size: 24rpx;
color: #666;
margin-top: 8rpx;
}
}
.cu-progress {
width: 300rpx;
height: 20rpx;
background: #ebeef5;
border-radius: 100rpx;
overflow: hidden;
view {
height: 100%;
border-radius: 100rpx;
transition: width 0.3s ease;
}
}
.bg-green {
background: #2667E9;
}
.border-border {
padding: 10rpx;
background: #EEF3FF;
border-radius: 4rpx;
border: 2rpx solid #AAC5FC;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
font-size: 28rpx;
color: #2667E9;
}
</style>

View File

@@ -129,7 +129,7 @@
<view class="form-label">
<text>开始时间</text>
</view>
<view class="picker-input" @click="showStartDatePicker = true">
<view class="picker-input" @click="openStartDatePicker">
<text :class="formData.startDate ? 'picker-value' : 'picker-placeholder'">
{{ formData.startDate || '请选择开始时间' }}
</text>
@@ -137,8 +137,9 @@
</view>
<up-datetime-picker
:show="showStartDatePicker"
mode="datetime"
mode="date"
v-model="startDateValue"
:minDate="todayMinDate"
@confirm="onStartDateConfirm"
@cancel="showStartDatePicker = false"
@close="showStartDatePicker = false"
@@ -150,7 +151,7 @@
<view class="form-label">
<text>结束时间</text>
</view>
<view class="picker-input" @click="showEndDatePicker = true">
<view class="picker-input" @click="openEndDatePicker">
<text :class="formData.endDate ? 'picker-value' : 'picker-placeholder'">
{{ formData.endDate || '请选择结束时间' }}
</text>
@@ -158,8 +159,9 @@
</view>
<up-datetime-picker
:show="showEndDatePicker"
mode="datetime"
mode="date"
v-model="endDateValue"
:minDate="endDateMinDate"
@confirm="onEndDateConfirm"
@cancel="showEndDatePicker = false"
@close="showEndDatePicker = false"
@@ -426,8 +428,23 @@ const onSwitchChange = (e) => {
};
// 日期选择器值
const startDateValue = ref(Number(new Date()));
const endDateValue = ref(Number(new Date()));
const getTodayTimestamp = () => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
};
const todayMinDate = computed(() => getTodayTimestamp());
const endDateMinDate = computed(() => {
if (formData.startDate) {
const [year, month, day] = formData.startDate.split('-').map(Number);
return new Date(year, month - 1, day).getTime();
}
return getTodayTimestamp();
});
const startDateValue = ref(getTodayTimestamp());
const endDateValue = ref(getTodayTimestamp());
// 选择器显示控制
const showDeptPicker = ref(false);
@@ -619,22 +636,46 @@ const onCycleConfirm = (e) => {
showCyclePicker.value = false;
};
// 日期时间格式化(精确到时分秒
const formatDateTime = (timestamp) => {
// 日期格式化(年月日
const formatDate = (timestamp) => {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
return `${year}-${month}-${day}`;
};
const parseDateValue = (dateStr) => {
if (!dateStr) return 0;
const [datePart] = dateStr.split(' ');
const [year, month, day] = datePart.split('-').map(Number);
return new Date(year, month - 1, day).getTime();
};
const openStartDatePicker = () => {
const today = getTodayTimestamp();
if (startDateValue.value < today) {
startDateValue.value = today;
}
showStartDatePicker.value = true;
};
const openEndDatePicker = () => {
const minDate = endDateMinDate.value;
if (endDateValue.value < minDate) {
endDateValue.value = minDate;
}
showEndDatePicker.value = true;
};
const onStartDateConfirm = (e) => {
const selectedDate = formatDateTime(e.value);
// 如果已经选了结束时间,校验开始时间不能晚于结束时间
if (formData.endDate && new Date(selectedDate) > new Date(formData.endDate)) {
const selectedDate = formatDate(e.value);
const todayStr = formatDate(getTodayTimestamp());
if (selectedDate < todayStr) {
uni.showToast({ title: '开始时间不能早于今天', icon: 'none' });
return;
}
if (formData.endDate && parseDateValue(selectedDate) > parseDateValue(formData.endDate)) {
uni.showToast({ title: '开始时间不能晚于结束时间', icon: 'none' });
return;
}
@@ -643,9 +684,8 @@ const onStartDateConfirm = (e) => {
};
const onEndDateConfirm = (e) => {
const selectedDate = formatDateTime(e.value);
// 如果已经选了开始时间,校验结束时间不能早于开始时间
if (formData.startDate && new Date(selectedDate) < new Date(formData.startDate)) {
const selectedDate = formatDate(e.value);
if (formData.startDate && parseDateValue(selectedDate) < parseDateValue(formData.startDate)) {
uni.showToast({ title: '结束时间不能早于开始时间', icon: 'none' });
return;
}
@@ -1101,7 +1141,7 @@ const handleSave = async () => {
uni.showToast({ title: '请选择计划时间', icon: 'none' });
return;
}
if (new Date(formData.endDate) < new Date(formData.startDate)) {
if (parseDateValue(formData.endDate) < parseDateValue(formData.startDate)) {
uni.showToast({ title: '结束时间不能早于开始时间', icon: 'none' });
return;
}
@@ -1157,8 +1197,8 @@ const handleSave = async () => {
itemIds: itemIds, // 从检查库选择的库id数组
cycle: cycleMap[formData.cycleName] || 1,
isWeekend: workdaySwitch.value ? 1 : 2,
planStartTime: formData.startDate,
planEndTime: formData.endDate
planStartTime: `${formData.startDate} 00:00:00`,
planEndTime: `${formData.endDate} 23:59:59`
};
// 如果是指定人员模式添加执行人员id

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,14 @@
</view>
<view class="signature-box margin-bottom">
<view v-if="!showCanvas" class="signature-display flex align-center justify-center" style="width: 100%; height: 160px; background-color: #f8f8f8; display: flex; align-items: center; justify-content: center;">
<image :src="signatureUrl" class="signature-img" mode="aspectFit" style="width: 100%; height: 100%;"></image>
<image
v-if="signatureUrl"
:src="signatureUrl"
class="signature-img"
mode="aspectFit"
style="width: 100%; height: 100%;"
></image>
<text v-else class="signature-placeholder">暂无签名请点击重新签名</text>
</view>
<!-- 改为 v-if 解决小程序原生 canvas 真机渲染与生命周期挂载残留问题 -->
<view v-if="showCanvas" class="signature-pad-wrap" style="border: 1px dashed #dcdfe6; border-radius: 8rpx; overflow: hidden; background-color: #f8f8f8;">
@@ -87,9 +94,10 @@
:lineWidth="3"
:enableHistory="false"
@confirm="(res) => onSignatureConfirm(res.tempFilePath)"
@start="isSignatureEmpty = false"
@signing="isSignatureEmpty = false"
@clear="isSignatureEmpty = true"
@start="onSignatureStart"
@signing="onSignatureSigning"
@end="onSignatureEnd"
@clear="onSignatureClear"
>
<template #footer></template>
</wd-signature>
@@ -105,14 +113,17 @@
</template>
<script setup>
import { ref, reactive, watch, nextTick, getCurrentInstance } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { ref, reactive, nextTick, getCurrentInstance } from 'vue';
import { onLoad, onHide } from '@dcloudio/uni-app';
import { acceptanceRectification, getHiddenDangerDetail } from '@/request/api.js';
import { toImageUrl } from '@/request/request.js';
import { buildDraftKey, buildDraftKeyCompact, DRAFT_NS } from '@/utils/draftCache.js';
import { useDraftCache } from '@/utils/useDraftCache.js';
import {
createUploadListHandlers,
buildAttachmentItem,
uploadToCloud
uploadToCloud,
toSubmitFileUrl
} from '@/utils/upload.js';
// 页面参数
@@ -166,14 +177,133 @@
const signatureRef = ref(null); // 签名组件 ref
const isSignatureEmpty = ref(true); // 签名是否为空
const isSubmitting = ref(false); // 是否正在提交表单
const hasDraft = ref(false);
const showRestoreBanner = ref(false); // 独立控制提示 Banner
const isRestoring = ref(false); // 正在恢复标志
const isInitialized = ref(false); // 初始化标识
const signaturePaths = ref([]); // 缓存手写签名的绘制路径
const getDraftKey = () => `draft_accept_${rectifyId.value || ''}`;
const signatureLocalPath = ref(''); // 未上传云端的本地签名临时图
const isDraftExporting = ref(false); // 是否为草稿导出(非提交上传)
let signatureExportTimer = null;
/** 将已上传的签名地址应用到预览区 */
const applySignatureFromServer = (signPath) => {
const url = signPath ? toSubmitFileUrl(signPath) : '';
if (!url) {
showCanvas.value = true;
signatureServerPath.value = '';
signatureUrl.value = '';
signatureLocalPath.value = '';
isSignatureEmpty.value = true;
return;
}
signatureServerPath.value = url;
signatureUrl.value = url;
signatureLocalPath.value = '';
showCanvas.value = false;
isSignatureEmpty.value = false;
};
/** 将本地临时签名图应用到预览区(草稿回显) */
const applySignatureFromLocal = (localPath) => {
if (!localPath) return;
signatureLocalPath.value = localPath;
signatureUrl.value = localPath;
signatureServerPath.value = '';
showCanvas.value = false;
isSignatureEmpty.value = false;
};
const acceptDraftHasContent = (data) => {
const form = data.formData || {};
return !!(
form.verifyRemark ||
(data.fileList1 && data.fileList1.length > 0) ||
data.signatureServerPath ||
data.signatureLocalPath ||
(data.signaturePaths && data.signaturePaths.length > 0)
);
};
const {
showRestoreBanner,
clear: clearDraft,
restore: restoreDraft,
save: saveDraft,
bindAutoSave
} = useDraftCache({
getKey: () => buildDraftKey(DRAFT_NS.ACCEPT, rectifyId.value),
getFallbackKeys: () => {
const primary = buildDraftKey(DRAFT_NS.ACCEPT, rectifyId.value);
const compact = buildDraftKeyCompact(DRAFT_NS.ACCEPT, rectifyId.value);
return compact !== primary ? [compact] : [];
},
getPayload: () => ({
formData: {
result: formData.result,
verifyRemark: formData.verifyRemark
},
fileList1: fileList1.value,
signatureServerPath: signatureServerPath.value,
signatureUrl: signatureUrl.value,
signatureLocalPath: signatureLocalPath.value,
showCanvas: showCanvas.value,
signaturePaths: signaturePaths.value
}),
hasContent: acceptDraftHasContent,
applyPayload: (data) => {
const form = data.formData || {};
formData.result = form.result !== undefined ? form.result : 1;
formData.verifyRemark = form.verifyRemark || '';
fileList1.value = data.fileList1 || [];
signaturePaths.value = data.signaturePaths || [];
if (data.signatureServerPath || data.signatureUrl) {
applySignatureFromServer(data.signatureServerPath || data.signatureUrl);
} else if (data.signatureLocalPath) {
applySignatureFromLocal(data.signatureLocalPath);
} else {
showCanvas.value = data.showCanvas !== undefined ? data.showCanvas : true;
signatureServerPath.value = '';
signatureUrl.value = '';
signatureLocalPath.value = '';
isSignatureEmpty.value = true;
}
},
clearForm: () => {
formData.result = 1;
formData.verifyRemark = '';
fileList1.value = [];
signatureServerPath.value = '';
signatureUrl.value = '';
signatureLocalPath.value = '';
showCanvas.value = true;
signaturePaths.value = [];
if (signatureRef.value) {
signatureRef.value.clear();
}
},
canSave: () => !!rectifyId.value,
requireInitialized: true,
onAfterRestore: (data) => {
if (data.signatureServerPath || data.signatureUrl || data.signatureLocalPath) {
return;
}
if (data.signaturePaths?.length > 0) {
setTimeout(() => {
if (signatureRef.value) {
isSignatureEmpty.value = false;
}
}, 450);
}
}
});
bindAutoSave(() => [
formData.result,
formData.verifyRemark,
fileList1.value,
signatureServerPath.value,
signatureUrl.value,
signatureLocalPath.value,
showCanvas.value,
signaturePaths.value
]);
// 获取完整图片路径
const getFullPath = (filePath) => {
@@ -279,8 +409,8 @@
// 触发组件导出,导出成功会回调 onSignatureConfirm
signatureRef.value.confirm();
} else {
// 已经有回显的签名
if (!signatureServerPath.value) {
// 已经有回显的签名(云端或本地草稿)
if (!signatureServerPath.value && !signatureLocalPath.value) {
uni.showToast({
title: '请进行电子签名',
icon: 'none'
@@ -289,7 +419,18 @@
}
isSubmitting.value = true;
uni.showLoading({ title: '正在提交...', mask: true });
await executeSubmit();
try {
if (!signatureServerPath.value && signatureLocalPath.value) {
const { url } = await uploadToCloud(signatureLocalPath.value);
applySignatureFromServer(url);
}
await executeSubmit();
} catch (err) {
isSubmitting.value = false;
uni.hideLoading();
console.error('签名上传失败:', err);
uni.showToast({ title: '签名上传失败,请重试', icon: 'none' });
}
}
};
@@ -350,148 +491,47 @@
}
});
// 电子签名画布手写线条变动回调
const onSignatureChange = () => {
// 电子签名:手写过程中自动导出本地预览图写入草稿
const onSignatureStart = () => {
isSignatureEmpty.value = false;
signaturePaths.value = [];
if (rectifyId.value) {
saveDraft();
}
};
// 保存草稿
const saveDraft = () => {
if (isRestoring.value || !isInitialized.value) return;
const key = getDraftKey();
const hasContent = formData.verifyRemark ||
fileList1.value.length > 0 ||
signatureServerPath.value ||
signaturePaths.value.length > 0;
if (!hasContent) {
uni.removeStorageSync(key);
hasDraft.value = false;
return;
}
const data = {
formData: {
result: formData.result,
verifyRemark: formData.verifyRemark
},
fileList1: fileList1.value,
signatureServerPath: signatureServerPath.value,
signatureUrl: signatureUrl.value,
showCanvas: showCanvas.value,
signaturePaths: signaturePaths.value
};
uni.setStorageSync(key, JSON.stringify(data));
hasDraft.value = true;
const onSignatureSigning = () => {
isSignatureEmpty.value = false;
};
// 清空草稿
const clearDraft = (showToast = true) => {
const key = getDraftKey();
uni.removeStorageSync(key);
hasDraft.value = false;
showRestoreBanner.value = false;
isRestoring.value = true;
formData.result = 1;
formData.verifyRemark = '';
fileList1.value = [];
signatureServerPath.value = '';
signatureUrl.value = '';
showCanvas.value = true;
signaturePaths.value = [];
if (signatureRef.value) {
signatureRef.value.clear();
}
nextTick(() => {
isRestoring.value = false;
});
if (showToast) {
uni.showToast({ title: '草稿已清空', icon: 'none' });
}
const onSignatureClear = () => {
isSignatureEmpty.value = true;
signatureLocalPath.value = '';
};
// 恢复草稿
const restoreDraft = () => {
const key = getDraftKey();
const cached = uni.getStorageSync(key);
if (cached) {
try {
const data = JSON.parse(cached);
const hasContent = data.formData.verifyRemark ||
(data.fileList1 && data.fileList1.length > 0) ||
data.signatureServerPath ||
(data.signaturePaths && data.signaturePaths.length > 0);
if (!hasContent) {
isInitialized.value = true;
return;
}
isRestoring.value = true;
formData.result = data.formData.result !== undefined ? data.formData.result : 1;
formData.verifyRemark = data.formData.verifyRemark || '';
fileList1.value = data.fileList1 || [];
signatureServerPath.value = data.signatureServerPath || '';
signatureUrl.value = data.signatureUrl || '';
showCanvas.value = data.showCanvas !== undefined ? data.showCanvas : true;
signaturePaths.value = data.signaturePaths || [];
hasDraft.value = true;
showRestoreBanner.value = true;
// 延迟恢复签名画布线条重绘
if (signaturePaths.value.length > 0) {
setTimeout(() => {
if (signatureRef.value) {
isSignatureEmpty.value = false;
// wot-design-uni auto rendering
}
}, 450);
}
nextTick(() => {
isRestoring.value = false;
isInitialized.value = true;
});
uni.showToast({
title: '已自动恢复您上次未提交的内容',
icon: 'none',
duration: 2500
});
} catch (e) {
console.error('解析草稿失败:', e);
isRestoring.value = false;
isInitialized.value = true;
const scheduleSignatureDraftExport = () => {
clearTimeout(signatureExportTimer);
signatureExportTimer = setTimeout(() => {
if (
!showCanvas.value ||
isSignatureEmpty.value ||
!signatureRef.value ||
isSubmitting.value ||
isDraftExporting.value
) {
return;
}
} else {
isInitialized.value = true;
}
isDraftExporting.value = true;
signatureRef.value.confirm();
}, 600);
};
// 深度监听验收项和签名笔画变化,自动保存草稿
watch(
() => [
formData.result,
formData.verifyRemark,
fileList1.value,
signatureServerPath.value,
signaturePaths.value
],
() => {
if (rectifyId.value) {
saveDraft();
}
},
{ deep: true }
);
const onSignatureEnd = () => {
isSignatureEmpty.value = false;
scheduleSignatureDraftExport();
};
// 清除画布
const clearSignature = () => {
clearTimeout(signatureExportTimer);
isSignatureEmpty.value = true;
signatureLocalPath.value = '';
if (signatureRef.value) {
signatureRef.value.clear();
}
@@ -499,10 +539,12 @@
// 重新签字
const reSign = () => {
clearTimeout(signatureExportTimer);
isSignatureEmpty.value = true;
showCanvas.value = true;
signatureUrl.value = '';
signatureServerPath.value = '';
signatureLocalPath.value = '';
nextTick(() => {
if (signatureRef.value) {
signatureRef.value.clear();
@@ -520,12 +562,17 @@
// 签名导出成功回调
const onSignatureConfirm = async (tempFilePath) => {
if (isDraftExporting.value) {
isDraftExporting.value = false;
applySignatureFromLocal(tempFilePath);
saveDraft();
return;
}
try {
const { url } = await uploadToCloud(tempFilePath);
signatureServerPath.value = url;
signatureUrl.value = url;
showCanvas.value = false;
isSignatureEmpty.value = false;
applySignatureFromServer(url);
saveDraft();
if (isSubmitting.value) {
await executeSubmit();
@@ -538,6 +585,14 @@
}
};
onHide(() => {
if (showCanvas.value && !isSignatureEmpty.value && signatureRef.value && !isDraftExporting.value) {
isDraftExporting.value = true;
signatureRef.value.confirm();
}
saveDraft();
});
onLoad((options) => {
// 计算签名画布宽度
try {

View File

@@ -91,9 +91,11 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { ref, computed } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getDepartmentPersonUsers,assignHiddenDanger } from '@/request/api.js';
import { buildDraftKey, DRAFT_NS } from '@/utils/draftCache.js';
import { useDraftCache } from '@/utils/useDraftCache.js';
// 页面参数
const hazardId = ref('');
@@ -162,6 +164,32 @@
const showDatePicker = ref(false);
const dateValue = ref(Date.now());
const selectedDate = ref('');
// 草稿(仅缓存整改期限,不缓存人员选择)
const {
showRestoreBanner,
clear: clearDraft,
restore: restoreDraft,
bindAutoSave
} = useDraftCache({
getKey: () => buildDraftKey(DRAFT_NS.ASSIGN, hazardId.value),
getPayload: () => ({
selectedDate: selectedDate.value,
dateValue: dateValue.value
}),
hasContent: (payload) => !!payload.selectedDate,
applyPayload: (payload) => {
selectedDate.value = payload.selectedDate || '';
dateValue.value = payload.dateValue || Date.now();
},
clearForm: () => {
selectedDate.value = '';
dateValue.value = Date.now();
},
canSave: () => !!hazardId.value
});
bindAutoSave(() => [selectedDate.value]);
// 获取部门人员列表
const fetchDeptUsers = async () => {
@@ -233,92 +261,6 @@
}
};
// 草稿缓存与恢复逻辑 (移至底部以确保 formData 等响应式状态已被正常定义)
const hasDraft = ref(false);
const showRestoreBanner = ref(false); // 独立控制提示 Banner仅在初次确实从本地恢复了内容时才显示
const isRestoring = ref(false); // 正在恢复标志避免触发冗余watch
const getDraftKey = () => `draft_assign_${hazardId.value || ''}`;
// 保存草稿 (排除选择器人员缓存,仅缓存整改期限日期值)
const saveDraft = () => {
if (isRestoring.value) return;
const key = getDraftKey();
const hasContent = selectedDate.value;
if (!hasContent) {
uni.removeStorageSync(key);
hasDraft.value = false;
return;
}
const data = {
selectedDate: selectedDate.value,
dateValue: dateValue.value
};
uni.setStorageSync(key, JSON.stringify(data));
hasDraft.value = true;
};
// 清空草稿
const clearDraft = (showToast = true) => {
const key = getDraftKey();
uni.removeStorageSync(key);
hasDraft.value = false;
showRestoreBanner.value = false;
isRestoring.value = true;
selectedDate.value = '';
dateValue.value = Date.now();
nextTick(() => {
isRestoring.value = false;
});
if (showToast) {
uni.showToast({ title: '草稿已清空', icon: 'none' });
}
};
// 恢复草稿
const restoreDraft = () => {
const key = getDraftKey();
const cached = uni.getStorageSync(key);
if (cached) {
try {
const data = JSON.parse(cached);
const hasContent = data.selectedDate;
if (!hasContent) return;
isRestoring.value = true;
selectedDate.value = data.selectedDate || '';
dateValue.value = data.dateValue || Date.now();
hasDraft.value = true;
showRestoreBanner.value = true; // 确实存在内容并恢复了,才亮起提示 Banner
nextTick(() => {
isRestoring.value = false;
});
uni.showToast({
title: '已自动恢复您上次未提交的内容',
icon: 'none',
duration: 2500
});
} catch (e) {
console.error('解析草稿失败:', e);
isRestoring.value = false;
}
}
};
// 监听变量变化,自动保存草稿
watch(
() => [selectedDate.value],
() => {
if (hazardId.value) {
saveDraft();
}
}
);
onLoad((options) => {
if (options.hazardId) hazardId.value = options.hazardId;
if (options.assignId) assignId.value = options.assignId;

View File

@@ -0,0 +1,243 @@
<template>
<view class="page">
<view class="top-gradient-wrap">
<u-navbar
title="查看隐患"
:placeholder="true"
:safeAreaInsetTop="true"
bgColor="transparent"
titleColor="#ffffff"
leftIconColor="#ffffff"
:autoBack="true"
:border="false"
/>
<view class="summary-card">
<view class="summary-side summary-side--left">
<image class="summary-icon" src="/static/yinhuan_detail/status.png" mode="aspectFit" />
<view class="summary-icon-gap">
<view class="summary-text">
<view class="summary-label">隐患状态</view>
<view class="summary-status">{{ detailData.statusName || '-' }}</view>
</view>
</view>
</view>
<view class="summary-divider"></view>
<view class="summary-side summary-side--right">
<image class="summary-icon" src="/static/yinhuan_detail/date.png" mode="aspectFit" />
<view class="summary-text">
<view class="summary-label">提交日期</view>
<view class="summary-date">{{ detailData.createdAt || '-' }}</view>
</view>
</view>
</view>
</view>
<view class="panel-wrap">
<HazardDetailPanelV2
:detail="detailData"
:loading="loading"
:body-height="panelBodyHeight"
/>
</view>
</view>
</template>
<script setup>
import { ref, watch, nextTick, getCurrentInstance } from 'vue';
import { onLoad, onReady } from '@dcloudio/uni-app';
import HazardDetailPanelV2 from '@/components/hazardDetail/HazardDetailPanelV2.vue';
import { getHazardDetail } from '@/request/api.js';
const instance = getCurrentInstance();
const queryScope = instance?.proxy || instance;
const detailData = ref({});
const loading = ref(false);
const panelBodyHeight = ref(0);
const calcPanelBodyHeight = () => {
nextTick(() => {
const query = uni.createSelectorQuery().in(queryScope);
query.select('.panel-wrap').boundingClientRect();
query.exec((res) => {
const rect = res?.[0];
if (rect?.height > 0) {
panelBodyHeight.value = Math.floor(rect.height);
return;
}
const sys = uni.getSystemInfoSync();
panelBodyHeight.value = Math.floor(sys.windowHeight * 0.55);
});
});
};
const loadDetail = async (hazardId) => {
loading.value = true;
try {
const res = await getHazardDetail(hazardId);
if (res.code === 0 && res.data) {
detailData.value = res.data;
} else {
uni.showToast({ title: res.msg || '获取详情失败', icon: 'none' });
}
} catch (error) {
console.error('获取隐患详情失败:', error);
uni.showToast({ title: '获取详情失败', icon: 'none' });
} finally {
loading.value = false;
calcPanelBodyHeight();
setTimeout(calcPanelBodyHeight, 100);
setTimeout(calcPanelBodyHeight, 400);
}
};
onReady(() => {
calcPanelBodyHeight();
setTimeout(calcPanelBodyHeight, 100);
setTimeout(calcPanelBodyHeight, 400);
});
watch(loading, (isLoading) => {
if (!isLoading) {
calcPanelBodyHeight();
setTimeout(calcPanelBodyHeight, 100);
setTimeout(calcPanelBodyHeight, 400);
}
});
onLoad((options) => {
if (options.hazardId) {
loadDetail(options.hazardId);
return;
}
uni.showToast({ title: '缺少隐患ID', icon: 'none' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
});
</script>
<style lang="scss" scoped>
.page {
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #f5f7fa;
}
/* 渐变只覆盖导航栏 + 顶部卡片区域,在卡片下沿开始淡出 */
.top-gradient-wrap {
flex-shrink: 0;
background: linear-gradient( 180deg, #046CEA 0%, #2158C8 28.44%, rgba(4,107,234,0) 100%);}
.summary-card {
margin: 32rpx 30rpx 0;
padding: 28rpx 30rpx 32rpx;
background: #fff;
border-radius: 20rpx;
display: flex;
align-items: flex-start;
box-sizing: border-box;
}
.summary-side {
display: flex;
align-items: flex-start;
}
.summary-side--left {
flex-shrink: 0;
align-items: center;
}
.summary-side--left .summary-text {
padding-top: 0;
padding-bottom: 0;
}
.summary-side--right {
flex: 1;
min-width: 0;
align-items: center;
}
.summary-side--right .summary-icon {
margin-right: 22rpx;
}
.summary-side--right .summary-text {
flex: 1;
min-width: 0;
padding-top: 0;
padding-bottom: 0;
}
/* 图标右边缘到竖线 = 169rpx文字靠左右侧留白 */
.summary-icon-gap {
width: 149rpx;
flex-shrink: 0;
box-sizing: border-box;
}
.summary-icon {
width: 55rpx;
height: 65rpx;
flex-shrink: 0;
}
.summary-side--left .summary-icon {
margin-right: 22rpx;
}
.summary-text {
flex-shrink: 0;
box-sizing: border-box;
}
.summary-label {
font-size: 24rpx;
color: #8f9ca2;
line-height: 34rpx;
}
.summary-status {
margin-top: 8rpx;
font-size: 28rpx;
font-weight: 400;
color: #333333;
line-height: 40rpx;
}
.summary-date {
margin-top: 8rpx;
font-size: 28rpx;
font-weight: 400;
color: #333333;
line-height: 40rpx;
white-space: nowrap;
}
.summary-divider {
width: 2rpx;
height: 72rpx;
flex-shrink: 0;
background: #eee;
margin-right: 40rpx;
}
.panel-wrap {
flex: 1;
min-height: 0;
height: 0;
margin-top: 12rpx;
padding: 0 30rpx 30rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>

View File

@@ -235,9 +235,10 @@
:lineWidth="3"
:enableHistory="false"
@confirm="(res) => onSignatureConfirm(res.tempFilePath)"
@start="isSignatureEmpty = false"
@signing="isSignatureEmpty = false"
@clear="isSignatureEmpty = true"
@start="onSignatureStart"
@signing="onSignatureSigning"
@end="onSignatureEnd"
@clear="onSignatureClear"
>
<template #footer></template>
</wd-signature>
@@ -250,9 +251,11 @@
</template>
<script setup>
import {ref,reactive,computed,nextTick,watch,getCurrentInstance} from 'vue'
import {onLoad} from '@dcloudio/uni-app'
import {ref,reactive,computed,nextTick,getCurrentInstance} from 'vue'
import {onLoad, onHide} from '@dcloudio/uni-app'
import {submitRectification,getDepartmentPersonUsers,getRectifyDetail,getDeptUsersWithSubordinates,getHiddenDangerDetail,generateRectifyPlan} from '@/request/api.js'
import { buildDraftKey, buildDraftKeyCompact, DRAFT_NS } from '@/utils/draftCache.js';
import { useDraftCache } from '@/utils/useDraftCache.js';
import {
createUploadListHandlers,
buildAttachmentItem,
@@ -279,10 +282,15 @@
const signatureRef = ref(null); // 签名组件 ref
const isSignatureEmpty = ref(true); // 签名是否为空
const isSubmitting = ref(false); // 是否正在提交表单
const signatureLocalPath = ref(''); // 未上传云端的本地签名临时图
const isDraftExporting = ref(false); // 是否为草稿导出(非提交上传)
let signatureExportTimer = null;
// 清除画布
const clearSignature = () => {
clearTimeout(signatureExportTimer);
isSignatureEmpty.value = true;
signatureLocalPath.value = '';
if (signatureRef.value) {
signatureRef.value.clear();
}
@@ -308,15 +316,62 @@
showCanvas.value = true;
signatureServerPath.value = '';
signatureUrl.value = '';
signatureLocalPath.value = '';
isSignatureEmpty.value = true;
return;
}
signatureServerPath.value = url;
signatureUrl.value = url;
signatureLocalPath.value = '';
showCanvas.value = false;
isSignatureEmpty.value = false;
};
/** 将本地临时签名图应用到预览区(草稿回显) */
const applySignatureFromLocal = (localPath) => {
if (!localPath) return;
signatureLocalPath.value = localPath;
signatureUrl.value = localPath;
signatureServerPath.value = '';
showCanvas.value = false;
isSignatureEmpty.value = false;
};
const onSignatureStart = () => {
isSignatureEmpty.value = false;
};
const onSignatureSigning = () => {
isSignatureEmpty.value = false;
};
const onSignatureClear = () => {
isSignatureEmpty.value = true;
signatureLocalPath.value = '';
};
const scheduleSignatureDraftExport = () => {
clearTimeout(signatureExportTimer);
signatureExportTimer = setTimeout(() => {
if (
!showCanvas.value ||
isSignatureEmpty.value ||
!signatureRef.value ||
isSubmitting.value ||
isDraftExporting.value
) {
return;
}
isDraftExporting.value = true;
signatureRef.value.confirm();
}, 600);
};
const onSignatureEnd = () => {
isSignatureEmpty.value = false;
scheduleSignatureDraftExport();
};
const onSignatureImageError = () => {
console.error('签名图片加载失败:', signatureUrl.value);
uni.showToast({ title: '签名图片加载失败', icon: 'none' });
@@ -324,10 +379,12 @@
// 重新签字
const reSign = () => {
clearTimeout(signatureExportTimer);
isSignatureEmpty.value = true;
showCanvas.value = true;
signatureUrl.value = '';
signatureServerPath.value = '';
signatureLocalPath.value = '';
nextTick(() => {
if (signatureRef.value) {
signatureRef.value.clear();
@@ -730,9 +787,17 @@
// 签名导出成功回调
const onSignatureConfirm = async (tempFilePath) => {
if (isDraftExporting.value) {
isDraftExporting.value = false;
applySignatureFromLocal(tempFilePath);
saveDraft();
return;
}
try {
const { url } = await uploadToCloud(tempFilePath);
applySignatureFromServer(url);
saveDraft();
if (isSubmitting.value) {
await executeSubmit();
@@ -812,8 +877,8 @@
// 触发组件导出,导出成功会回调 onSignatureConfirm
signatureRef.value.confirm();
} else {
// 已经有回显的签名
if (!signatureServerPath.value) {
// 已经有回显的签名(云端或本地草稿)
if (!signatureServerPath.value && !signatureLocalPath.value) {
uni.showToast({
title: '请进行电子签名',
icon: 'none'
@@ -822,7 +887,18 @@
}
isSubmitting.value = true;
uni.showLoading({ title: '正在提交...', mask: true });
await executeSubmit();
try {
if (!signatureServerPath.value && signatureLocalPath.value) {
const { url } = await uploadToCloud(signatureLocalPath.value);
applySignatureFromServer(url);
}
await executeSubmit();
} catch (err) {
isSubmitting.value = false;
uni.hideLoading();
console.error('签名上传失败:', err);
uni.showToast({ title: '签名上传失败,请重试', icon: 'none' });
}
}
};
@@ -1001,42 +1077,38 @@
}
};
// 草稿缓存与恢复逻辑 (移至底部以确保 formData 等响应式状态已被正常定义)
const hasDraft = ref(false);
const showRestoreBanner = ref(false); // 独立控制提示 Banner仅在初次确实从本地恢复了内容时才显示
const isRestoring = ref(false); // 正在恢复标志避免触发冗余watch
const signaturePaths = ref([]); // 缓存手写签名的绘制路径
const getDraftKey = () => `draft_rectify_${hazardId.value || ''}_${rectifyId.value || ''}`;
const signaturePaths = ref([]);
// 电子签名画布手写线条变动回调
const onSignatureChange = () => {
isSignatureEmpty.value = false;
signaturePaths.value = [];
if (hazardId.value || rectifyId.value) {
saveDraft();
}
const rectifyDraftHasContent = (data) => {
const form = data.formData || {};
return !!(
form.rectifyPlan ||
form.rectificationMeasures ||
form.controlMeasures ||
form.rectifyResult ||
form.planCost ||
form.actualCost ||
(data.fileList1 && data.fileList1.length > 0) ||
data.signatureServerPath ||
data.signatureLocalPath ||
(data.signaturePaths && data.signaturePaths.length > 0)
);
};
// 保存草稿 (排除选择器人员缓存,仅缓存方案、情况、金额、图片、签名等输入信息)
const saveDraft = () => {
if (isRestoring.value) return;
const key = getDraftKey();
const hasContent = formData.rectifyPlan ||
formData.rectificationMeasures ||
formData.controlMeasures ||
formData.rectifyResult ||
formData.planCost ||
formData.actualCost ||
fileList1.value.length > 0 ||
signatureServerPath.value ||
signaturePaths.value.length > 0;
if (!hasContent) {
uni.removeStorageSync(key);
hasDraft.value = false;
return;
}
const data = {
const {
showRestoreBanner,
clear: clearDraft,
restore: restoreDraft,
save: saveDraft,
bindAutoSave
} = useDraftCache({
getKey: () => buildDraftKey(DRAFT_NS.RECTIFY, hazardId.value, rectifyId.value),
getFallbackKeys: () => {
const primary = buildDraftKey(DRAFT_NS.RECTIFY, hazardId.value, rectifyId.value);
const compact = buildDraftKeyCompact(DRAFT_NS.RECTIFY, hazardId.value, rectifyId.value);
return compact !== primary ? [compact] : [];
},
getPayload: () => ({
formData: {
rectifyPlan: formData.rectifyPlan,
rectificationMeasures: formData.rectificationMeasures,
@@ -1048,126 +1120,83 @@
fileList1: fileList1.value,
signatureServerPath: signatureServerPath.value,
signatureUrl: signatureUrl.value,
signatureLocalPath: signatureLocalPath.value,
showCanvas: showCanvas.value,
signaturePaths: signaturePaths.value
};
uni.setStorageSync(key, JSON.stringify(data));
hasDraft.value = true;
};
// 清空草稿
const clearDraft = (showToast = true) => {
const key = getDraftKey();
uni.removeStorageSync(key);
hasDraft.value = false;
showRestoreBanner.value = false;
isRestoring.value = true;
formData.rectifyPlan = '';
formData.rectificationMeasures = '';
formData.controlMeasures = '';
formData.rectifyResult = '';
formData.planCost = '';
formData.actualCost = '';
fileList1.value = [];
signatureServerPath.value = '';
signatureUrl.value = '';
showCanvas.value = true;
signaturePaths.value = [];
if (signatureRef.value) {
signatureRef.value.clear();
}
nextTick(() => {
isRestoring.value = false;
});
if (showToast) {
uni.showToast({ title: '草稿已清空', icon: 'none' });
}
};
// 恢复草稿 (不恢复任何选择器数据)
const restoreDraft = () => {
const key = getDraftKey();
const cached = uni.getStorageSync(key);
if (cached) {
try {
const data = JSON.parse(cached);
const hasContent = data.formData.rectifyPlan ||
data.formData.rectificationMeasures ||
data.formData.controlMeasures ||
data.formData.rectifyResult ||
data.formData.planCost ||
data.formData.actualCost ||
(data.fileList1 && data.fileList1.length > 0) ||
data.signatureServerPath ||
(data.signaturePaths && data.signaturePaths.length > 0);
if (!hasContent) return;
isRestoring.value = true;
formData.rectifyPlan = data.formData.rectifyPlan || '';
formData.rectificationMeasures = data.formData.rectificationMeasures || '';
formData.controlMeasures = data.formData.controlMeasures || '';
formData.rectifyResult = data.formData.rectifyResult || '';
formData.planCost = data.formData.planCost || '';
formData.actualCost = data.formData.actualCost || '';
fileList1.value = data.fileList1 || [];
signaturePaths.value = data.signaturePaths || [];
// 优先用草稿里已上传的完整 URL否则保持画板模式
if (data.signatureServerPath || data.signatureUrl) {
applySignatureFromServer(data.signatureServerPath || data.signatureUrl);
} else if (data.showCanvas === false) {
showCanvas.value = false;
}
hasDraft.value = true;
showRestoreBanner.value = true; // 确实存在内容并恢复了,才亮起提示 Banner
// 延迟恢复签名画布线条重绘,等待 Canvas 组件完全加载完毕
if (signaturePaths.value.length > 0) {
setTimeout(() => {
if (signatureRef.value) {
isSignatureEmpty.value = false;
// wot-design-uni auto rendering
}
}, 450);
}
nextTick(() => {
isRestoring.value = false;
});
uni.showToast({
title: '已自动恢复您上次未提交的内容',
icon: 'none',
duration: 2500
});
} catch (e) {
console.error('解析草稿失败:', e);
isRestoring.value = false;
}
}
};
// 深度监听表单项和签名笔画变化,自动保存草稿
watch(
() => [
formData.rectifyPlan,
formData.rectificationMeasures,
formData.controlMeasures,
formData.rectifyResult,
formData.planCost,
formData.actualCost,
fileList1.value,
signatureServerPath.value,
signaturePaths.value
],
() => {
if (hazardId.value || rectifyId.value) {
saveDraft();
}),
hasContent: rectifyDraftHasContent,
applyPayload: (data) => {
const form = data.formData || {};
formData.rectifyPlan = form.rectifyPlan || '';
formData.rectificationMeasures = form.rectificationMeasures || '';
formData.controlMeasures = form.controlMeasures || '';
formData.rectifyResult = form.rectifyResult || '';
formData.planCost = form.planCost || '';
formData.actualCost = form.actualCost || '';
fileList1.value = data.fileList1 || [];
signaturePaths.value = data.signaturePaths || [];
if (data.signatureServerPath || data.signatureUrl) {
applySignatureFromServer(data.signatureServerPath || data.signatureUrl);
} else if (data.signatureLocalPath) {
applySignatureFromLocal(data.signatureLocalPath);
} else if (data.showCanvas === false) {
showCanvas.value = false;
}
},
{ deep: true }
);
clearForm: () => {
formData.rectifyPlan = '';
formData.rectificationMeasures = '';
formData.controlMeasures = '';
formData.rectifyResult = '';
formData.planCost = '';
formData.actualCost = '';
fileList1.value = [];
signatureServerPath.value = '';
signatureUrl.value = '';
signatureLocalPath.value = '';
showCanvas.value = true;
signaturePaths.value = [];
if (signatureRef.value) {
signatureRef.value.clear();
}
},
canSave: () => !!(hazardId.value || rectifyId.value),
onAfterRestore: (data) => {
if (data.signatureServerPath || data.signatureUrl || data.signatureLocalPath) {
return;
}
if (data.signaturePaths?.length > 0) {
setTimeout(() => {
if (signatureRef.value) {
isSignatureEmpty.value = false;
}
}, 450);
}
}
});
bindAutoSave(() => [
formData.rectifyPlan,
formData.rectificationMeasures,
formData.controlMeasures,
formData.rectifyResult,
formData.planCost,
formData.actualCost,
fileList1.value,
signatureServerPath.value,
signatureUrl.value,
signatureLocalPath.value,
showCanvas.value,
signaturePaths.value
]);
onHide(() => {
if (showCanvas.value && !isSignatureEmpty.value && signatureRef.value && !isDraftExporting.value) {
isDraftExporting.value = true;
signatureRef.value.confirm();
}
saveDraft();
});
onLoad((options) => {
// 计算签名画布宽度

View File

@@ -1,196 +1,50 @@
<template>
<view class="padding page">
<view class="padding bg-white radius">
<view class="flex margin-bottom">
<view class="text-gray">检查形式</view>
<view class="text-red">*</view>
</view>
<view class="read-only-box">{{ detailData.source || '暂无' }}</view>
<view class="flex margin-bottom margin-top">
<view class="text-gray">隐患图片</view>
<view class="text-red">*</view>
</view>
<view class="margin-bottom">
<view v-if="detailData.attachments && detailData.attachments.length > 0" class="margin-top-xs">
<view class="flex" style="flex-wrap: wrap; gap: 10rpx;">
<image
v-for="(img, idx) in detailData.attachments"
:key="idx"
:src="getFullPath(img.filePath)"
style="width: 136rpx;height: 136rpx;border-radius: 16rpx;"
mode="aspectFill"
@click="previewHazardImage(idx)"
></image>
</view>
</view>
<view v-else class="text-gray text-sm">暂无图片</view>
<view class="text-gray text-sm margin-top-xs">必填请上传现场照片作为隐患证据</view>
</view>
<view class="flex margin-bottom margin-top">
<view class="text-gray">隐患标题</view>
<view class="text-red">*</view>
</view>
<up-input v-model="detailData.title" disabled disabledColor="#F6F6F6" border="surround" placeholder="暂无" />
<view class="text-sm text-gray margin-top-xs">请用简洁的语言概括隐患要点</view>
<view class="flex margin-bottom margin-top">
<view class="text-gray">隐患等级</view>
<view class="text-red">*</view>
</view>
<view class="flex col-2" style="gap: 10rpx;">
<view :class="detailData.level === 2 ? 'bg-blue light' : 'bg-gray'" class="level-item">一般隐患</view>
<view :class="detailData.level === 3 ? 'bg-blue light' : 'bg-gray'" class="level-item">重大隐患</view>
</view>
<view class="flex margin-bottom margin-top">
<view class="text-gray">隐患位置</view>
<view class="text-red">*</view>
</view>
<up-input v-model="detailData.address" disabled disabledColor="#F6F6F6" border="surround" placeholder="暂无地址" />
<view class="text-gray text-sm margin-top-xs">办公楼3层东侧消防通道生产车间A区设备旁等或点击"选择地址"按钮在地图上选择</view>
<view class="flex margin-bottom margin-top">
<view class="text-gray">法律依据</view>
</view>
<view class="read-only-select">
<view class="select-value" :class="{ placeholder: !legalBasisText }">
{{ legalBasisText || '暂无' }}
</view>
</view>
<view class="flex margin-bottom margin-top">
<view class="text-gray">隐患区域</view>
</view>
<view class="read-only-select">
<view class="flex align-center">
<view
v-if="detailData.areaColor"
class="area-color-dot"
:style="{ backgroundColor: detailData.areaColor }"
></view>
<view class="select-value" :class="{ placeholder: !detailData.areaName }">
{{ detailData.areaName || '暂无' }}
</view>
</view>
</view>
<view class="flex margin-bottom margin-top">
<view class="text-gray">隐患描述</view>
<view class="text-red">*</view>
</view>
<up-textarea v-model="detailData.description" placeholder="暂无描述" disabled autoHeight></up-textarea>
<view class="text-gray text-sm margin-top-xs">请详细说明隐患现状潜在风险及影响范围</view>
<view class="text-gray margin-bottom margin-top">隐患标签</view>
<view class="read-only-box">{{ detailData.tagName || '暂无' }}</view>
</view>
<view class="page">
<HazardDetailPanel :detail="detailData" :loading="loading" />
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getHiddenDangerDetail } from '@/request/api.js'
import { toImageUrl } from '@/request/request.js'
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import HazardDetailPanel from '@/components/hazardDetail/HazardDetailPanel.vue';
import { getHazardDetail } from '@/request/api.js';
const detailData = reactive({
hazardId: '',
assignId: '',
title: '',
level: 0,
levelName: '',
source: '',
description: '',
address: '',
areaName: '',
areaColor: '',
tagName: '',
legalBasis: '',
regulationName: '',
attachments: []
})
const detailData = ref({});
const loading = ref(false);
const legalBasisText = computed(() => detailData.legalBasis || detailData.regulationName || '')
const loadDetail = async (hazardId) => {
loading.value = true;
try {
const res = await getHazardDetail(hazardId);
if (res.code === 0 && res.data) {
detailData.value = res.data;
} else {
uni.showToast({ title: res.msg || '获取详情失败', icon: 'none' });
}
} catch (error) {
console.error('获取隐患详情失败:', error);
} finally {
loading.value = false;
}
};
const getFullPath = (filePath) => toImageUrl(filePath)
const previewHazardImage = (index) => {
if (!detailData.attachments || detailData.attachments.length === 0) return
uni.previewImage({
current: index,
urls: detailData.attachments.map(item => getFullPath(item.filePath))
})
onLoad((options) => {
if (options.hazardId) {
loadDetail(options.hazardId);
return;
}
const fetchDetail = async (hazardId, assignId) => {
try {
const params = { hazardId }
if (assignId) params.assignId = assignId
const res = await getHiddenDangerDetail(params)
if (res.code === 0 && res.data) {
Object.assign(detailData, res.data)
} else {
uni.showToast({ title: res.msg || '获取详情失败', icon: 'none' })
}
} catch (error) {
console.error('获取隐患详情失败:', error)
uni.showToast({ title: '请求失败', icon: 'none' })
}
}
onLoad((options) => {
if (options.hazardId) {
fetchDetail(options.hazardId, options.assignId)
}
})
uni.showToast({ title: '缺少隐患ID', icon: 'none' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
});
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #EBF2FC;
}
.read-only-box {
background: #f5f5f5;
border-radius: 8rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
color: #333;
}
.read-only-select {
background: #f5f5f5;
border: 1rpx solid #dcdfe6;
border-radius: 8rpx;
padding: 20rpx 24rpx;
.select-value {
font-size: 28rpx;
color: #333;
line-height: 1.5;
word-break: break-all;
&.placeholder {
color: #999;
}
}
}
.level-item {
padding: 16rpx 40rpx;
border-radius: 8rpx;
text-align: center;
font-size: 28rpx;
}
.area-color-dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
margin-right: 16rpx;
flex-shrink: 0;
}
.page {
min-height: 100vh;
background: #ebf2fc;
}
</style>

View File

@@ -6,7 +6,7 @@
<image class="header-bg-image" src="/static/home_icon/jianbianbeijing.png" mode="aspectFill"></image>
<!-- 自定义导航栏 -->
<u-navbar
title="三查一曝光"
title="湘西州“三个一”安全管理平台"
:placeholder="false"
:fixed="false"
:safeAreaInsetTop="true"
@@ -45,65 +45,81 @@
</view>
<!-- 我的检查计划 -->
<view class="bg-white margin-top radius" style="padding: 40rpx; margin-left: -30rpx; margin-right: -30rpx;">
<view class="flex margin-bottom-xl">
<!-- <view class="border-tite"></view> -->
<view class="flex margin-bottom-xl align-center justify-between">
<view class="text-bold margin-left-xs" style="font-size: 32rpx;">我的检查计划</view>
<!-- <button class="cu-btn round sm line-blue inspect-list-btn" @click="goInspectList">检查列表</button> -->
</view>
<!-- 无数据提示 -->
<view v-if="checkPlanData.length === 0" class="text-center text-gray padding">
暂无检查计划
</view>
<!-- 列表渲染 -->
<view class="plan-card margin-bottom" v-for="(item, index) in checkPlanData" :key="item.id">
<view class="plan-card margin-bottom" v-for="item in checkPlanData" :key="item.id">
<!-- 蓝色标题栏 -->
<view class="plan-header">
<!-- <image src="/static/蒙版组 273.png" class="plan-header-icon"></image> -->
<view class="plan-header" @click="togglePlanExpand(item.id)">
<text class="plan-header-title">{{ item.name }}</text>
<view class="plan-toggle-btn" @click.stop="togglePlanExpand(item.id)">
<u-icon
:name="isPlanExpanded(item.id) ? 'arrow-down' : 'arrow-up'"
color="#ffffff"
size="18"
></u-icon>
</view>
</view>
<!-- 内容区域 -->
<view class="plan-body">
<view class="flex">
<view class="border-border margin-right-xs">{{ item.runModeName }}完成</view>
<view class="border-border">{{ item.cycle }}</view>
<!-- 内容区域收起时保留标签与计划时间 -->
<view class="plan-body" :class="{ 'plan-body--collapsed': !isPlanExpanded(item.id) }">
<view class="plan-body-summary">
<view class="flex">
<view class="border-border margin-right-xs">{{ item.runModeName }}完成</view>
<view class="border-border">{{ item.cycle }}</view>
</view>
<view class="flex text-gray margin-top">
<view>计划时间</view>
<view style="color: #333333;">{{ formatDate(item.planStartTime) }}{{ formatDate(item.planEndTime) }}</view>
</view>
</view>
<view class="flex text-gray margin-top">
<view>计划时间</view>
<view style="color: #333333;">{{ formatDate(item.planStartTime) }}{{ formatDate(item.planEndTime) }}</view>
</view>
<view class="flex margin-top align-center">
<view style="color: #B5B5B5;">完成进度</view>
<view class="flex align-center margin-left-sm">
<view class="cu-progress round">
<view class="bg-green" :style="{ width: item.progress + '%' }"></view>
<view v-show="isPlanExpanded(item.id)" class="plan-body-detail">
<view class="flex margin-top align-center">
<view style="color: #B5B5B5;">完成进度</view>
<view class="flex align-center margin-left-sm">
<view class="cu-progress round">
<view class="bg-green" :style="{ width: formatProgress(item.progress) + '%' }"></view>
</view>
<text class="margin-left-sm">{{ formatProgress(item.progress) }}%</text>
</view>
<text class="margin-left-sm">{{ item.progress }}%</text>
</view>
</view>
<view class="plan-stats margin-top">
<view class="plan-stat-item">
<view class="plan-stat-num text-orange">{{ item.totalCount }}</view>
<view class="plan-stat-label">排查项</view>
<view class="plan-stats margin-top">
<view class="plan-stat-item">
<view class="plan-stat-num text-orange">{{ item.pendingCount ?? 0 }}</view>
<view class="plan-stat-label">未完成</view>
</view>
<view class="plan-stat-item">
<view class="plan-stat-num text-yellow">{{ item.waitCheckNum ?? 0 }}</view>
<view class="plan-stat-label">待排查</view>
</view>
<view class="plan-stat-item">
<view class="plan-stat-num text-red">{{ item.unusualNum ?? 0 }}</view>
<view class="plan-stat-label">异常数</view>
</view>
<view class="plan-stat-item">
<view class="plan-stat-num text-blue">{{ item.finishedCount ?? 0 }}</view>
<view class="plan-stat-label">已完成</view>
</view>
</view>
<view class="plan-stat-item">
<view class="plan-stat-num text-yellow">{{ item.totalCount - item.finishedCount }}</view>
<view class="plan-stat-label">待排查</view>
<view class="margin-top margin-bottom flex justify-end">
<button class="cu-btn round lg light bg-blue margin-right" @click.stop="goTodayInspect(item)">今日检查</button>
<button class="cu-btn round lg bg-blue" @click.stop="goPlanCheckList(item)">检查清单</button>
</view>
<view class="plan-stat-item">
<view class="plan-stat-num text-olive">0</view>
<view class="plan-stat-label">待验收</view>
</view>
<view class="plan-stat-item">
<view class="plan-stat-num text-blue">{{ item.finishedCount }}</view>
<view class="plan-stat-label">已完成</view>
</view>
</view>
<view class="margin-top margin-bottom flex justify-end">
<button class="cu-btn round lg light bg-blue margin-right" @click.stop="ViewDetails(item)">查看详情</button>
<button v-if="item.finishedCount < item.totalCount" class="cu-btn round lg bg-blue" @click.stop="goDetails(item)">开始检查</button>
<view v-else class="cu-btn round lg bg-green">已完成</view>
</view>
</view>
</view>
<view
v-if="hasMoreCheckPlans"
class="plan-load-more"
@click="loadMoreCheckPlans"
>
加载更多
</view>
</view>
<!-- 我的隐患 -->
<view class="bg-white margin-top radius" style="padding: 40rpx; margin-left: -40rpx; margin-right: -40rpx;">
@@ -177,7 +193,7 @@
<script setup>
import { ref, reactive, computed } from 'vue';
// import { onLoad } from '@dcloudio/uni-app';
import {getCheckPlanList,getHiddenDangerList} from '@/request/api.js'
import {getCheckPlanList, getHiddenDangerList} from '@/request/api.js'
import { getProfileDetail } from '@/request/three_one_api/info.js';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { toImageUrl } from '@/request/request.js';
@@ -293,15 +309,50 @@
// admin、manage 及其他角色展示全部菜单
return allMenuList;
});
const ViewDetails = (item) => {
const goInspectList = () => {
uni.navigateTo({
url: `/pages/plandetail/plandetail?id=${item.id}`
url: '/pages/Inspectionresult/list'
})
}
const goDetails = (item) => {
const formatProgress = (progress) => {
const num = Number(progress);
if (Number.isNaN(num)) return 0;
return Math.min(100, Math.max(0, num));
}
const getTodayDateStr = () => {
const date = new Date();
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
const goTodayInspect = (item) => {
const oneTableId = item.id;
const taskDate = getTodayDateStr();
const tableId = item.tableId ?? item.id;
if (!oneTableId || !taskDate) {
uni.showToast({ title: '缺少任务参数', icon: 'none' });
return;
}
const name = item.name || '';
const tableIdQuery = tableId ? `&tableId=${tableId}` : '';
uni.navigateTo({
url: `/pages/Inspectionresult/Inspectionresult?id=${item.id}`
})
url: `/pages/Inspectionresult/detail?oneTableId=${oneTableId}&taskDate=${encodeURIComponent(taskDate)}&name=${encodeURIComponent(name)}${tableIdQuery}`
});
}
const goPlanCheckList = (item) => {
const tableId = item.tableId ?? item.id;
if (!tableId) {
uni.showToast({ title: '缺少检查表ID', icon: 'none' });
return;
}
uni.navigateTo({
url: `/pages/Inspectionresult/list?tableId=${tableId}&name=${encodeURIComponent(item.name || '')}`
});
}
// 菜单点击跳转
const handleMenuClick = (item) => {
@@ -326,19 +377,55 @@
}
//我的检查计划
const PLAN_INITIAL_SIZE = 4;
const checkPlanParams = ref({
pageNum: 1,
pageSize: 10,
pageSize: PLAN_INITIAL_SIZE,
name: ''
});
const checkPlanData = ref([]);
const checkPlanTotal = ref(0);
const planShowAll = ref(false);
const expandedPlanMap = ref({});
const hasMoreCheckPlans = computed(() => {
return !planShowAll.value && checkPlanTotal.value > PLAN_INITIAL_SIZE;
});
const initPlanExpandedState = (records = [], { reset = false, expandFirst = false } = {}) => {
const map = reset ? {} : { ...expandedPlanMap.value };
records.forEach((item, index) => {
if (reset) {
map[item.id] = expandFirst && index === 0;
} else if (!(item.id in map)) {
map[item.id] = false;
}
});
expandedPlanMap.value = map;
};
const isPlanExpanded = (id) => !!expandedPlanMap.value[id];
const togglePlanExpand = (id) => {
expandedPlanMap.value = {
...expandedPlanMap.value,
[id]: !expandedPlanMap.value[id]
};
};
const getCheckPlanLists = async () => {
planShowAll.value = false;
try {
const res = await getCheckPlanList(checkPlanParams.value);
console.log(res);
const res = await getCheckPlanList({
pageNum: 1,
pageSize: PLAN_INITIAL_SIZE,
name: checkPlanParams.value.name
});
if (res.code === 0) {
checkPlanData.value = res.data.records;
const records = res.data?.records || [];
checkPlanTotal.value = Number(res.data?.total ?? records.length);
checkPlanData.value = records;
initPlanExpandedState(records, { reset: true, expandFirst: true });
}
} catch (error) {
console.error(error);
@@ -346,6 +433,28 @@
loading.value = false;
}
};
const loadMoreCheckPlans = async () => {
if (!hasMoreCheckPlans.value) return;
try {
const res = await getCheckPlanList({
pageNum: 1,
pageSize: checkPlanTotal.value,
name: checkPlanParams.value.name
});
if (res.code === 0) {
const records = res.data?.records || [];
initPlanExpandedState(records);
checkPlanData.value = records;
planShowAll.value = true;
} else {
uni.showToast({ title: res.msg || '加载失败', icon: 'none' });
}
} catch (error) {
console.error(error);
uni.showToast({ title: '加载失败', icon: 'none' });
}
};
// 格式化日期 (2025-12-18 00:00:00 -> 2025-12-18)
const formatDate = (dateStr) => {
@@ -422,7 +531,7 @@
// 查看隐患详情
const viewHazardDetail = (item) => {
uni.navigateTo({
url: `/pages/hiddendanger/view?hazardId=${item.hazardId}&assignId=${item.assignId}`
url: `/pages/hiddendanger/detail2?hazardId=${item.hazardId}&assignId=${item.assignId || ''}`
})
}
@@ -605,6 +714,15 @@
}
// ========== 检查计划卡片 ==========
.inspect-list-btn {
margin: 0;
padding: 0 24rpx;
height: 56rpx;
line-height: 56rpx;
font-size: 24rpx;
flex-shrink: 0;
}
.plan-card {
border-radius: 16rpx;
overflow: hidden;
@@ -616,6 +734,7 @@
padding: 24rpx 30rpx;
display: flex;
align-items: center;
justify-content: space-between;
.plan-header-icon {
width: 36rpx;
@@ -624,15 +743,38 @@
}
.plan-header-title {
flex: 1;
min-width: 0;
padding-right: 16rpx;
color: #fff;
font-size: 30rpx;
font-weight: bold;
}
}
.plan-toggle-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 48rpx;
height: 48rpx;
}
.plan-body {
padding: 24rpx 30rpx 10rpx 30rpx;
background: #fff;
&--collapsed {
padding-bottom: 24rpx;
}
}
.plan-load-more {
text-align: center;
padding: 24rpx 0 8rpx;
font-size: 28rpx;
color: #2667E9;
}
.plan-stats {

View File

@@ -4,7 +4,7 @@
<image src="/static/index/index_bg.png" class="bg-image"></image>
<view class="padding login">
<view class="text-xl text-black text-bold">账号登录</view>
<view class="padding-top">欢迎登录三查一曝光平台</view>
<view class="padding-top">欢迎登录湘西州三个一安全管理平台</view>
</view>
</view>

View File

@@ -39,7 +39,7 @@
<!-- 添加成员按钮 -->
<view class="add-btn-wrapper">
<button class="add-btn" @click="showPopup = true">
<button class="add-btn" @click="openAddMemberPopup">
<text class="cuIcon-add"></text>
<text>添加成员</text>
</button>
@@ -89,6 +89,17 @@
<text class="cuIcon-unfold"></text>
</view>
</view>
<!-- 岗位 -->
<view class="form-item">
<view class="form-label">岗位</view>
<view class="form-select" @click="openPostPicker">
<text :class="selectedPostName ? '' : 'text-gray'">
{{ selectedPostName || postPlaceholder }}
</text>
<text class="cuIcon-unfold"></text>
</view>
</view>
<!-- 占位空白防止被底部按钮遮挡 -->
<view style="height: 40rpx;"></view>
@@ -101,6 +112,14 @@
</view>
</u-popup>
<up-picker
:show="showPostPicker"
:columns="postColumns"
@confirm="onPostConfirm"
@cancel="showPostPicker = false"
@close="showPostPicker = false"
></up-picker>
<up-picker
:show="showRolePicker"
:columns="roleColumns"
@@ -115,7 +134,7 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { addMember, getMemberList, lockOrUnlockMember } from '@/request/api.js';
import { addMember, getMemberList, lockOrUnlockMember, getSystemUserFormOptions, listPostByDeptId } from '@/request/api.js';
// 用户信息从storage获取
const userInfo = ref({
@@ -170,7 +189,9 @@ const fetchMemberList = async () => {
// 弹窗控制
const showPopup = ref(false);
const showRolePicker = ref(false);
const showPostPicker = ref(false);
const selectedRoleName = ref('');
const selectedPostName = ref('');
// 表单数据
const formData = reactive({
@@ -178,29 +199,93 @@ const formData = reactive({
nickname: '',
phone: '',
password: '',
roleType: ''
roleType: '',
postId: ''
});
// 角色类型选择器数据
const roleColumns = reactive([
['管理员', '普通成员']
]);
// 角色选项(接口返回)
const roleOptions = ref([]);
const roleColumns = ref([[]]);
// 角色名称与值的映射
const roleMap = {
'管理员': 'manage',
'普通成员': 'common'
// 岗位选项按部门ID查询
const postOptions = ref([]);
const postColumns = ref([[]]);
const postPlaceholder = computed(() => {
if (!userInfo.value.deptId) return '请先登录并确认部门信息';
return postOptions.value.length ? '请选择岗位' : '暂无可用岗位';
});
// 打开添加成员弹窗:拉取角色和岗位列表
const openAddMemberPopup = async () => {
getUserInfo();
const deptId = userInfo.value.deptId;
if (!deptId) {
uni.showToast({ title: '缺少部门信息,无法添加成员', icon: 'none' });
return;
}
try {
uni.showLoading({ title: '加载中...' });
const [optionsRes, postRes] = await Promise.all([
getSystemUserFormOptions(),
listPostByDeptId(deptId)
]);
uni.hideLoading();
if (optionsRes.code !== 0 || !Array.isArray(optionsRes.roles) || !optionsRes.roles.length) {
uni.showToast({ title: optionsRes.msg || '获取角色列表失败', icon: 'none' });
return;
}
roleOptions.value = optionsRes.roles.filter((item) => item.status === '0');
roleColumns.value = [roleOptions.value.map((item) => item.roleName)];
const posts = postRes.data || postRes.rows || [];
postOptions.value = posts.filter((item) => item.status === '0');
postColumns.value = [postOptions.value.map((item) => item.postName)];
showPopup.value = true;
} catch (error) {
uni.hideLoading();
console.error('获取表单选项失败:', error);
uni.showToast({
title: error?.msg || '获取表单选项失败',
icon: 'none'
});
}
};
const openPostPicker = () => {
if (!postOptions.value.length) {
uni.showToast({ title: '当前部门暂无可用岗位', icon: 'none' });
return;
}
showPostPicker.value = true;
};
// 角色类型选择确认
const onRoleConfirm = (e) => {
if (e.value && e.value.length > 0) {
selectedRoleName.value = e.value[0];
formData.roleType = roleMap[e.value[0]];
const roleName = e.value[0];
const role = roleOptions.value.find((item) => item.roleName === roleName);
selectedRoleName.value = roleName;
formData.roleType = role?.roleKey || '';
}
showRolePicker.value = false;
};
// 岗位选择确认
const onPostConfirm = (e) => {
if (e.value && e.value.length > 0) {
const postName = e.value[0];
const post = postOptions.value.find((item) => item.postName === postName);
selectedPostName.value = postName;
formData.postId = post?.postId ?? '';
}
showPostPicker.value = false;
};
// 重置表单
const resetForm = () => {
formData.username = '';
@@ -208,7 +293,11 @@ const resetForm = () => {
formData.phone = '';
formData.password = '';
formData.roleType = '';
formData.postId = '';
selectedRoleName.value = '';
selectedPostName.value = '';
postOptions.value = [];
postColumns.value = [[]];
};
// 提交表单
@@ -226,13 +315,24 @@ const handleSubmit = async () => {
return;
}
getUserInfo();
const deptId = userInfo.value.deptId;
if (!deptId) {
uni.showToast({ title: '缺少部门信息,无法添加成员', icon: 'none' });
return;
}
const params = {
userName: formData.username,
nickName: formData.nickname || '',
phonenumber: formData.phone || '',
password: formData.password,
roleType: formData.roleType
roleType: formData.roleType,
deptId
};
if (formData.postId) {
params.postId = formData.postId;
}
try {
const res = await addMember(params);