Files
threeonecheck_web/pages/hiddendanger/rectification.vue

1538 lines
45 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="page padding">
<view class="padding bg-white radius">
<!-- 草稿恢复提示 -->
<view v-if="showRestoreBanner" class="bg-orange-light text-orange padding-sm radius margin-bottom flex justify-between align-center" style="font-size: 24rpx; background-color: #FFF7EB; border: 1rpx solid #FFE4CC; width: 100%; box-sizing: border-box; display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 20rpx;">
<view class="flex align-center" style="display: flex; flex-direction: row; align-items: center;">
<text class="cuIcon-info margin-right-xs" style="margin-right: 10rpx;"></text>
<text>已自动恢复您上次未提交的内容</text>
</view>
<text class="text-blue text-bold" style="cursor: pointer; padding: 0 10rpx; color: #2667E9; font-weight: bold;" @click="clearDraft(true)">清空草稿</text>
</view>
<view class="form-header margin-bottom">
<view class="form-label">
<view class="text-gray">整改方案</view>
<view class="text-red">*</view>
</view>
<button class="ai-rectify-btn" :loading="aiGenerating" :disabled="aiGenerating" @click="handleAiGenerate">
<text v-if="!aiGenerating" class="cuIcon-magic ai-btn-icon"></text>
{{ aiGenerating ? 'AI生成中...' : 'AI生成整改方案' }}
</button>
</view>
<up-textarea v-model="formData.rectifyPlan" placeholder="请输入内容" :maxlength="-1" autoHeight></up-textarea>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">整改措施</view>
<view class="text-red">*</view>
</view>
<up-textarea v-model="formData.rectificationMeasures" placeholder="请输入整改措施"></up-textarea>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">管控措施</view>
<view class="text-red">*</view>
</view>
<up-textarea v-model="formData.controlMeasures" placeholder="请输入管控措施"></up-textarea>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">整改完成情况</view>
<view class="text-red">*</view>
</view>
<up-textarea v-model="formData.rectifyResult" placeholder="请输入内容"></up-textarea>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">投资资金(计划)</view>
<view class="text-red">*</view>
</view>
<up-input v-model="formData.planCost" placeholder="请输入内容" type="number"></up-input>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">投资资金(实际)</view>
<view class="text-red">*</view>
</view>
<up-input v-model="formData.actualCost" placeholder="请输入内容" type="number"></up-input>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">限定整改时间</view>
<view class="text-red">*</view>
</view>
<view class="readonly-field">
<text :class="{ 'text-gray': !selectedDeadlineDate }">{{ selectedDeadlineDate || '暂无' }}</text>
</view>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">实际整改时间</view>
<view class="text-red">*</view>
</view>
<view class="select-trigger" @click="showRectifyTimePicker = true">
<view class="select-content" :class="{ 'text-gray': !selectedRectifyTime }">
{{ selectedRectifyTime || '请选择实际整改时间' }}
</view>
<text class="cuIcon-unfold"></text>
</view>
<up-datetime-picker
:show="showRectifyTimePicker"
v-model="rectifyTimeValue"
mode="datetime"
@confirm="onRectifyTimeConfirm"
@cancel="showRectifyTimePicker = false"
@close="showRectifyTimePicker = false"
></up-datetime-picker>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">安全管理人员</view>
<view class="text-red">*</view>
</view>
<view class="select-trigger" @click="openManagerPopup">
<view class="select-content" :class="{ 'text-gray': selectedManagers.length === 0 }">
{{ selectedManagers.length > 0 ? selectedManagersText : '请选择安全管理人员(可多选)' }}
</view>
<text class="cuIcon-unfold"></text>
</view>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">整改责任人</view>
<view class="text-red">*</view>
</view>
<!-- 点击打开人员选择弹窗 -->
<view class="select-trigger" @click="openUserPopup">
<view class="select-content" :class="{ 'text-gray': selectedUsers.length === 0 }">
{{ selectedUsers.length > 0 ? selectedUsersText : '请选择整改责任人(可多选)' }}
</view>
<text class="cuIcon-unfold"></text>
</view>
<!-- 安全管理人员部门-人员级联多选弹窗 -->
<u-popup :show="showManagerPopup" mode="bottom" round="20" @close="cancelManagerSelect">
<view class="user-popup cascader-user-popup">
<view class="popup-header">
<view class="popup-title text-bold">选择安全管理人员</view>
<view class="popup-close" @click="cancelManagerSelect">×</view>
</view>
<view v-if="managerPickerSelectedIds.length > 0" class="selected-summary">
<text class="summary-label">已选 {{ managerPickerSelectedIds.length }} </text>
<text class="summary-text">{{ managerPickerSelectedText }}</text>
</view>
<view class="cascader-body">
<scroll-view class="cascader-col dept-col" scroll-y>
<view
v-for="(dept, index) in managerDeptList"
:key="'manager-dept-' + dept.deptId"
:class="['cascader-item', { active: activeManagerDeptIndex === index }]"
@click="activeManagerDeptIndex = index"
>
<text class="cascader-item-text">{{ dept.deptName }}</text>
<text v-if="getManagerDeptSelectedCount(dept) > 0" class="dept-badge">{{ getManagerDeptSelectedCount(dept) }}</text>
</view>
</scroll-view>
<scroll-view class="cascader-col user-col" scroll-y :key="'manager-dept-users-' + activeManagerDeptIndex">
<view v-if="currentManagerDeptUsers.length === 0" class="empty-tip">该部门暂无人员</view>
<view v-else>
<view class="user-item" v-for="user in currentManagerDeptUsers" :key="'manager-user-' + user.userId">
<up-checkbox
usedAlone
:checked="managerPickerSelectedSet.has(String(user.userId))"
:label="formatUserDisplayName(user)"
activeColor="#2667E9"
shape="square"
@change="(checked) => onManagerCheckChange(user.userId, checked)"
></up-checkbox>
</view>
</view>
</scroll-view>
</view>
<view class="popup-footer">
<button class="btn-cancel" @click="cancelManagerSelect">取消</button>
<button class="btn-confirm bg-blue" @click="confirmManagerSelect">确定</button>
</view>
</view>
</u-popup>
<!-- 整改责任人部门-人员级联多选弹窗 -->
<u-popup :show="showUserPopup" mode="bottom" round="20" @close="cancelUserSelect">
<view class="user-popup cascader-user-popup">
<view class="popup-header">
<view class="popup-title text-bold">选择整改责任人</view>
<view class="popup-close" @click="cancelUserSelect">×</view>
</view>
<view v-if="userPickerSelectedIds.length > 0" class="selected-summary">
<text class="summary-label">已选 {{ userPickerSelectedIds.length }} </text>
<text class="summary-text">{{ userPickerSelectedText }}</text>
</view>
<view class="cascader-body">
<scroll-view class="cascader-col dept-col" scroll-y>
<view
v-for="(dept, index) in deptList"
:key="dept.deptId"
:class="['cascader-item', { active: activeDeptIndex === index }]"
@click="activeDeptIndex = index"
>
<text class="cascader-item-text">{{ dept.deptName }}</text>
<text v-if="getDeptSelectedCount(dept) > 0" class="dept-badge">{{ getDeptSelectedCount(dept) }}</text>
</view>
</scroll-view>
<scroll-view class="cascader-col user-col" scroll-y :key="'dept-users-' + activeDeptIndex">
<view v-if="currentDeptUsers.length === 0" class="empty-tip">该部门暂无人员</view>
<view v-else>
<view class="user-item" v-for="user in currentDeptUsers" :key="'user-' + user.userId">
<up-checkbox
usedAlone
:checked="userPickerSelectedSet.has(String(user.userId))"
:label="formatUserDisplayName(user)"
activeColor="#2667E9"
shape="square"
@change="(checked) => onUserCheckChange(user.userId, checked)"
></up-checkbox>
</view>
</view>
</scroll-view>
</view>
<view class="popup-footer">
<button class="btn-cancel" @click="cancelUserSelect">取消</button>
<button class="btn-confirm bg-blue" @click="confirmUserSelect">确定</button>
</view>
</view>
</u-popup>
<view class="form-label margin-bottom margin-top">
<view class="text-gray">整改图片</view>
<view class="text-red">*</view>
</view>
<up-upload :fileList="fileList1" @afterRead="afterRead" @delete="deletePic" name="1" multiple imageMode="aspectFill"
:maxCount="10"></up-upload>
<!-- 隐藏的 Canvas用于渲染防作弊时间戳水印弹窗打开时卸载避免原生 canvas 穿透盖住弹层 -->
<canvas
v-if="!showUserPopup && !showManagerPopup && !showRectifyTimePicker"
canvas-id="watermarkCanvas"
:width="canvasWidth"
:height="canvasHeight"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px', position: 'fixed', left: '-9999px', top: '-9999px' }"
></canvas>
<!-- 电子签名 -->
<view class="form-label margin-bottom margin-top flex justify-between align-center" style="width: 100%;">
<view class="flex align-center">
<view class="text-gray">电子签名</view>
<view class="text-red">*</view>
</view>
<button v-if="showCanvas" class="cu-btn sm round line-blue" style="margin: 0; padding: 0 20rpx; height: 50rpx; font-size: 22rpx;" @click="clearSignature">清除重写</button>
<button v-else class="cu-btn sm round line-blue" style="margin: 0; padding: 0 20rpx; height: 50rpx; font-size: 22rpx;" @click="reSign">重新签名</button>
</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;"
>
<image
v-if="signatureUrl"
:src="signatureUrl"
class="signature-img"
mode="aspectFit"
style="width: 100%; height: 100%;"
@error="onSignatureImageError"
></image>
<text v-else class="signature-placeholder">暂无签名请点击重新签名</text>
</view>
<!-- 弹窗/选择器打开时卸载 Canvas解决微信原生 canvas 真机穿透盖住弹层的问题 -->
<view v-if="showCanvas && !showUserPopup && !showManagerPopup && !showRectifyTimePicker" class="signature-pad-wrap">
<wd-signature
ref="signatureRef"
:width="signatureWidth"
:height="160"
backgroundColor="#f8f8f8"
penColor="#000000"
:lineWidth="3"
:enableHistory="false"
@confirm="(res) => onSignatureConfirm(res.tempFilePath)"
@start="isSignatureEmpty = false"
@signing="isSignatureEmpty = false"
@clear="isSignatureEmpty = true"
>
<template #footer></template>
</wd-signature>
</view>
</view>
<button class="bg-blue round margin-top-xl" @click="handleSubmit">{{ isEdit ? '保存修改' : '提交整改' }}</button>
</view>
</view>
</template>
<script setup>
import {ref,reactive,computed,nextTick,watch,getCurrentInstance} from 'vue'
import {onLoad} from '@dcloudio/uni-app'
import {submitRectification,getDepartmentPersonUsers,getRectifyDetail,getDeptUsersWithSubordinates,getHiddenDangerDetail,generateRectifyPlan} from '@/request/api.js'
import {
createUploadListHandlers,
buildAttachmentItem,
uploadToCloud,
mapServerFileToUploadItem,
toSubmitFileUrl
} from '@/utils/upload.js'
// 从页面参数获取的ID
const hazardId = ref('');
const assignId = ref('');
const rectifyId = ref(''); // 整改ID编辑模式时使用
const isEdit = ref(false); // 是否为编辑模式
// 防作弊时间戳水印 Canvas 大小配置
const canvasWidth = ref(300);
const canvasHeight = ref(300);
// 电子签名相关
const showCanvas = ref(true); // 是否显示签字画板
const signatureUrl = ref(''); // 签名完整预览地址
const signatureServerPath = ref(''); // 签名服务器相对路径
const signatureWidth = ref(340); // 签名画板宽度(动态计算)
const signatureRef = ref(null); // 签名组件 ref
const isSignatureEmpty = ref(true); // 签名是否为空
const isSubmitting = ref(false); // 是否正在提交表单
// 清除画布
const clearSignature = () => {
isSignatureEmpty.value = true;
if (signatureRef.value) {
signatureRef.value.clear();
}
};
/** 从整改详情里解析签名地址(兼容多种后端字段名) */
const resolveSignPathFromData = (data) => {
if (!data) return '';
return (
data.signPath ||
data.signUrl ||
data.signature ||
data.signatureUrl ||
data.signaturePath ||
''
);
};
/** 将签名地址应用到预览区 */
const applySignatureFromServer = (signPath) => {
const url = signPath ? toSubmitFileUrl(signPath) : '';
if (!url) {
showCanvas.value = true;
signatureServerPath.value = '';
signatureUrl.value = '';
isSignatureEmpty.value = true;
return;
}
signatureServerPath.value = url;
signatureUrl.value = url;
showCanvas.value = false;
isSignatureEmpty.value = false;
};
const onSignatureImageError = () => {
console.error('签名图片加载失败:', signatureUrl.value);
uni.showToast({ title: '签名图片加载失败', icon: 'none' });
};
// 重新签字
const reSign = () => {
isSignatureEmpty.value = true;
showCanvas.value = true;
signatureUrl.value = '';
signatureServerPath.value = '';
nextTick(() => {
if (signatureRef.value) {
signatureRef.value.clear();
}
});
};
// 签名导出失败
const onSignatureError = (err) => {
console.error('签名绘制失败:', err);
uni.showToast({ title: '签名生成失败', icon: 'none' });
isSubmitting.value = false;
uni.hideLoading();
};
// 表单数据
const formData = reactive({
rectifyPlan: '', // 整改方案
rectificationMeasures: '', // 整改措施
controlMeasures: '', // 管控措施
rectifyResult: '', // 整改完成情况
planCost: '', // 投资资金(计划)
actualCost: '' // 投资资金(实际)
});
const selectedDeadlineDate = ref('');
const showRectifyTimePicker = ref(false);
const rectifyTimeValue = ref(Date.now());
const selectedRectifyTime = ref('');
const formatDateValue = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return '';
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}`;
};
// 将交办时限字符串转为日期选择器时间戳
const parseDeadlineToTimestamp = (deadline) => {
if (!deadline) return null;
const decoded = decodeURIComponent(String(deadline)).trim();
const normalized = decoded.replace('T', ' ').replace(/-/g, '/');
const ts = new Date(normalized).getTime();
return Number.isNaN(ts) ? null : ts;
};
const applyDeadlineFromOptions = (deadline) => {
const ts = parseDeadlineToTimestamp(deadline);
if (ts) {
selectedDeadlineDate.value = formatDateValue(ts);
} else if (deadline) {
selectedDeadlineDate.value = String(deadline).trim().replace('T', ' ');
}
};
const parseIdList = (raw) => {
if (raw === null || raw === undefined || raw === '') return [];
if (Array.isArray(raw)) {
return raw.map((id) => String(id)).filter(Boolean);
}
return String(raw).split(',').map((id) => String(id).trim()).filter(Boolean);
};
const resolveManagerIdsFromDetail = (data) => {
const ids = parseIdList(data.manageIds ?? data.managerIds);
if (ids.length > 0) return ids;
if (Array.isArray(data.managers) && data.managers.length > 0) {
return data.managers.map((item) => String(item.userId)).filter(Boolean);
}
return [];
};
const buildUserItemFromDetail = (user) => ({
id: String(user.userId),
name: formatUserDisplayName(user),
deptName: user.deptName || ''
});
const getUsersByIdsFromTree = (ids, tree) => {
const userMap = new Map();
(tree || []).forEach((dept) => {
(dept.users || []).forEach((user) => {
userMap.set(String(user.userId), buildUserItem(user, dept));
});
});
return ids.map((id) => userMap.get(String(id))).filter(Boolean);
};
const mergeUsersFromDetailPool = (ids, resolvedUsers) => {
const userMap = new Map(resolvedUsers.map((user) => [user.id, user]));
ids.forEach((id) => {
const key = String(id);
if (userMap.has(key)) return;
const found = detailPersonPool.value.find((user) => String(user.userId) === key);
if (found) {
userMap.set(key, buildUserItemFromDetail(found));
}
});
return ids.map((id) => userMap.get(String(id))).filter(Boolean);
};
const applyRectifyTimeValue = (timeStr) => {
const ts = parseDeadlineToTimestamp(timeStr);
if (ts) {
rectifyTimeValue.value = ts;
selectedRectifyTime.value = formatDateValue(ts);
} else if (timeStr) {
selectedRectifyTime.value = String(timeStr).trim().replace('T', ' ');
}
};
const initRectifyTimeDefault = () => {
rectifyTimeValue.value = Date.now();
selectedRectifyTime.value = formatDateValue(rectifyTimeValue.value);
};
const onRectifyTimeConfirm = (e) => {
rectifyTimeValue.value = e.value;
selectedRectifyTime.value = formatDateValue(e.value);
showRectifyTimePicker.value = false;
};
// 人员列表(安全管理人员:本部门及上级;整改责任人:隐患关联部门)
const managerDeptList = ref([])
const deptList = ref([])
const detailPersonPool = ref([])
const showManagerPopup = ref(false)
const selectedManagerIds = ref([])
const selectedManagers = ref([])
const activeManagerDeptIndex = ref(0)
const managerPickerSelectedIds = ref([])
const showUserPopup = ref(false)
const selectedUserIds = ref([])
const selectedUsers = ref([])
const activeDeptIndex = ref(0)
const userPickerSelectedIds = ref([])
const formatUserDisplayName = (user) => {
if (user.postName) {
return `${user.nickName}_${user.postName}`;
}
return user.nickName || '';
};
const buildUserItem = (user, dept) => ({
id: String(user.userId),
name: formatUserDisplayName(user),
deptName: dept.deptName
});
const buildSelectedPersonText = (users) => {
if (users.length === 0) return '';
if (users.length <= 2) {
return users.map((u) => u.name).join('、');
}
return `${users[0].name}${users.length}`;
};
const selectedManagersText = computed(() => buildSelectedPersonText(selectedManagers.value));
const selectedUsersText = computed(() => buildSelectedPersonText(selectedUsers.value));
const currentManagerDeptUsers = computed(() => {
const dept = managerDeptList.value[activeManagerDeptIndex.value];
return dept?.users || [];
});
const managerPickerSelectedText = computed(() => {
const users = getManagerUsersByIds(managerPickerSelectedIds.value);
return buildSelectedPersonText(users);
});
const managerPickerSelectedSet = computed(() => {
return new Set(managerPickerSelectedIds.value.map((id) => String(id)));
});
const currentDeptUsers = computed(() => {
const dept = deptList.value[activeDeptIndex.value];
return dept?.users || [];
});
const userPickerSelectedText = computed(() => {
const users = getUsersByIds(userPickerSelectedIds.value);
return buildSelectedPersonText(users);
});
const userPickerSelectedSet = computed(() => {
return new Set(userPickerSelectedIds.value.map((id) => String(id)));
});
const getManagerUsersByIds = (ids) => {
let users = getUsersByIdsFromTree(ids, managerDeptList.value);
if (users.length < ids.length) {
const userDeptUsers = getUsersByIdsFromTree(ids, deptList.value);
const userMap = new Map(users.map((user) => [user.id, user]));
userDeptUsers.forEach((user) => {
if (!userMap.has(user.id)) userMap.set(user.id, user);
});
users = ids.map((id) => userMap.get(String(id))).filter(Boolean);
}
return mergeUsersFromDetailPool(ids, users);
};
const getUsersByIds = (ids) => {
let users = getUsersByIdsFromTree(ids, deptList.value);
return mergeUsersFromDetailPool(ids, users);
};
const syncSelectedManagersFromIds = (ids) => {
selectedManagers.value = getManagerUsersByIds(ids);
};
const syncSelectedUsersFromIds = (ids) => {
selectedUsers.value = getUsersByIds(ids);
};
const getManagerDeptSelectedCount = (dept) => {
if (!dept.users?.length) return 0;
const selectedSet = new Set(managerPickerSelectedIds.value.map(String));
return dept.users.filter((user) => selectedSet.has(String(user.userId))).length;
};
const getDeptSelectedCount = (dept) => {
if (!dept.users?.length) return 0;
const selectedSet = new Set(userPickerSelectedIds.value.map(String));
return dept.users.filter((user) => selectedSet.has(String(user.userId))).length;
};
function onManagerCheckChange(userId, checked) {
const id = String(userId);
if (checked) {
if (!managerPickerSelectedSet.value.has(id)) {
managerPickerSelectedIds.value = [...managerPickerSelectedIds.value, id];
}
return;
}
managerPickerSelectedIds.value = managerPickerSelectedIds.value.filter((item) => String(item) !== id);
}
function onUserCheckChange(userId, checked) {
const id = String(userId);
if (checked) {
if (!userPickerSelectedSet.value.has(id)) {
userPickerSelectedIds.value = [...userPickerSelectedIds.value, id];
}
return;
}
userPickerSelectedIds.value = userPickerSelectedIds.value.filter((item) => String(item) !== id);
}
const openManagerPopup = () => {
managerPickerSelectedIds.value = [...selectedManagerIds.value];
const firstDeptWithUsers = managerDeptList.value.findIndex((dept) => dept.users?.length > 0);
activeManagerDeptIndex.value = firstDeptWithUsers >= 0 ? firstDeptWithUsers : 0;
showManagerPopup.value = true;
};
const cancelManagerSelect = () => {
showManagerPopup.value = false;
};
const openUserPopup = () => {
userPickerSelectedIds.value = [...selectedUserIds.value];
const firstDeptWithUsers = deptList.value.findIndex((dept) => dept.users?.length > 0);
activeDeptIndex.value = firstDeptWithUsers >= 0 ? firstDeptWithUsers : 0;
showUserPopup.value = true;
};
const cancelUserSelect = () => {
showUserPopup.value = false;
};
// 确认选择安全管理人员
const confirmManagerSelect = () => {
selectedManagerIds.value = managerPickerSelectedIds.value.map((id) => String(id));
syncSelectedManagersFromIds(selectedManagerIds.value);
showManagerPopup.value = false;
};
// 确认选择整改责任人
const confirmUserSelect = () => {
selectedUserIds.value = userPickerSelectedIds.value.map((id) => String(id));
syncSelectedUsersFromIds(selectedUserIds.value);
showUserPopup.value = false;
};
// 获取安全管理人员候选列表(本部门及上级部门)
const fetchManagerDeptUsers = async () => {
try {
const res = await getDepartmentPersonUsers();
if (res.code === 0 && res.data) {
managerDeptList.value = res.data;
if (selectedManagerIds.value.length > 0) {
syncSelectedManagersFromIds(selectedManagerIds.value);
}
}
} catch (error) {
console.error('获取安全管理人员列表失败:', error);
}
};
// 获取整改责任人候选列表(隐患关联部门人员)
const fetchRectifyDeptUsers = async () => {
console.log('当前hazardId:', hazardId.value);
try {
const res = await getDeptUsersWithSubordinates({ hazardId: hazardId.value });
if (res.code === 0 && res.data) {
deptList.value = res.data;
if (selectedUserIds.value.length > 0) {
syncSelectedUsersFromIds(selectedUserIds.value);
}
console.log('整改责任人部门树:', deptList.value);
}
} catch (error) {
console.error('获取整改责任人列表失败:', error);
}
};
const fetchPersonnelLists = async () => {
await Promise.all([fetchManagerDeptUsers(), fetchRectifyDeptUsers()]);
if (selectedManagerIds.value.length > 0) {
syncSelectedManagersFromIds(selectedManagerIds.value);
}
};
const fileList1 = ref([]);
const rectifyUploadInstance = getCurrentInstance();
const { afterRead, deletePic } = createUploadListHandlers(fileList1, {
watermark: {
canvasId: 'watermarkCanvas',
canvasWidthRef: canvasWidth,
canvasHeightRef: canvasHeight,
instance: rectifyUploadInstance
}
});
// 提交整改
// 真正的提交接口请求
const executeSubmit = async () => {
// 构建附件列表
const attachments = fileList1.value
.filter((f) => f.status === 'success')
.map((file) => buildAttachmentItem(file));
const params = {
hazardId: hazardId.value,
assignId: assignId.value,
rectifyPlan: formData.rectifyPlan,
rectificationMeasures: formData.rectificationMeasures,
controlMeasures: formData.controlMeasures,
rectifyResult: formData.rectifyResult,
planCost: Number(formData.planCost) || 0,
actualCost: Number(formData.actualCost) || 0,
attachments: attachments,
manageIds: selectedManagerIds.value.map((id) => Number(id)),
memberIds: selectedUserIds.value.map((id) => Number(id)),
rectifyTime: selectedRectifyTime.value || formatDateValue(rectifyTimeValue.value),
signPath: signatureServerPath.value || ''
};
// 编辑模式需要传递rectifyId
if (rectifyId.value) {
params.rectifyId = rectifyId.value;
}
try {
const res = await submitRectification(params);
uni.hideLoading();
if (res.code === 0) {
clearDraft(false);
uni.showToast({
title: isEdit.value ? '保存成功' : '提交成功',
icon: 'success'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
} else {
uni.showToast({
title: res.msg || (isEdit.value ? '保存失败' : '提交失败'),
icon: 'none'
});
}
} catch (error) {
uni.hideLoading();
console.error('提交整改失败:', error);
uni.showToast({
title: '操作失败',
icon: 'none'
});
} finally {
isSubmitting.value = false;
}
};
// 签名导出成功回调
const onSignatureConfirm = async (tempFilePath) => {
try {
const { url } = await uploadToCloud(tempFilePath);
applySignatureFromServer(url);
if (isSubmitting.value) {
await executeSubmit();
}
} catch (err) {
isSubmitting.value = false;
uni.hideLoading();
console.error('签名上传失败:', err);
uni.showToast({ title: '签名上传失败,请重试', icon: 'none' });
}
};
// 提交整改(触发签名导出或直接提交)
const handleSubmit = async () => {
if (!formData.rectifyPlan) {
uni.showToast({
title: '请输入整改方案',
icon: 'none'
});
return;
}
if (!formData.rectificationMeasures) {
uni.showToast({
title: '请输入整改措施',
icon: 'none'
});
return;
}
if (!formData.controlMeasures) {
uni.showToast({
title: '请输入管控措施',
icon: 'none'
});
return;
}
if (!formData.rectifyResult) {
uni.showToast({
title: '请输入整改完成情况',
icon: 'none'
});
return;
}
if (!selectedRectifyTime.value) {
uni.showToast({
title: '请选择实际整改时间',
icon: 'none'
});
return;
}
if (selectedManagers.value.length === 0) {
uni.showToast({
title: '请选择安全管理人员',
icon: 'none'
});
return;
}
if (selectedUsers.value.length === 0) {
uni.showToast({
title: '请选择整改责任人',
icon: 'none'
});
return;
}
// 电子签名验证与处理
if (showCanvas.value) {
if (!signatureRef.value || isSignatureEmpty.value) {
uni.showToast({
title: '请进行电子签名',
icon: 'none'
});
return;
}
isSubmitting.value = true;
uni.showLoading({ title: '正在提交...', mask: true });
// 触发组件导出,导出成功会回调 onSignatureConfirm
signatureRef.value.confirm();
} else {
// 已经有回显的签名
if (!signatureServerPath.value) {
uni.showToast({
title: '请进行电子签名',
icon: 'none'
});
return;
}
isSubmitting.value = true;
uni.showLoading({ title: '正在提交...', mask: true });
await executeSubmit();
}
};
const handleSubmitOld = async () => {
if (!formData.rectifyPlan) {
uni.showToast({
title: '请输入整改方案',
icon: 'none'
});
return;
}
if (!formData.rectifyResult) {
uni.showToast({
title: '请输入整改完成情况',
icon: 'none'
});
return;
}
if (selectedUsers.value.length === 0) {
uni.showToast({
title: '请选择整改人员',
icon: 'none'
});
return;
}
const attachments = fileList1.value
.filter((f) => f.status === 'success')
.map((file) => buildAttachmentItem(file));
const params = {
hazardId: hazardId.value,
assignId: assignId.value,
rectifyPlan: formData.rectifyPlan,
rectifyResult: formData.rectifyResult,
planCost: Number(formData.planCost) || 0,
actualCost: Number(formData.actualCost) || 0,
attachments: attachments,
memberIds: selectedUserIds.value.map(id => Number(id))
};
// 编辑模式需要传递rectifyId
if (rectifyId.value) {
params.rectifyId = rectifyId.value;
}
try {
const res = await submitRectification(params);
if (res.code === 0) {
uni.showToast({
title: isEdit.value ? '保存成功' : '提交成功',
icon: 'success'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
} else {
uni.showToast({
title: res.msg || (isEdit.value ? '保存失败' : '提交失败'),
icon: 'none'
});
}
} catch (error) {
console.error('提交整改失败:', error);
uni.showToast({
title: '操作失败',
icon: 'none'
});
}
};
// 获取整改详情(编辑模式)
const fetchRectifyDetail = async () => {
try {
uni.showLoading({ title: '加载中...' });
const res = await getRectifyDetail({ rectifyId: rectifyId.value });
uni.hideLoading();
if (res.code === 0 && res.data) {
const data = res.data;
// 回显表单数据
formData.rectifyPlan = data.rectifyPlan || '';
formData.rectificationMeasures = data.rectificationMeasures || '';
formData.controlMeasures = data.controlMeasures || '';
formData.rectifyResult = data.rectifyResult || '';
formData.planCost = data.planCost ? String(data.planCost) : '';
formData.actualCost = data.actualCost ? String(data.actualCost) : '';
// 回显限定整改时间
if (data.deadline) {
applyDeadlineFromOptions(data.deadline);
}
// 回显实际整改时间
if (data.rectifyTime) {
applyRectifyTimeValue(data.rectifyTime);
}
// 回显电子签名
const signPath = resolveSignPathFromData(data);
console.log('整改详情签名路径:', signPath);
applySignatureFromServer(signPath);
// 保存hazardId和assignId
hazardId.value = data.hazardId || '';
assignId.value = data.assignId || '';
detailPersonPool.value = [
...(Array.isArray(data.managers) ? data.managers : []),
...(Array.isArray(data.members) ? data.members : [])
];
// 先解析人员 ID再拉取候选列表并回显
const managerIdArr = resolveManagerIdsFromDetail(data);
const memberIdArr = parseIdList(data.memberIds);
if (managerIdArr.length > 0) {
selectedManagerIds.value = managerIdArr;
}
if (memberIdArr.length > 0) {
selectedUserIds.value = memberIdArr;
} else if (data.rectifierId) {
selectedUserIds.value = [String(data.rectifierId)];
}
await fetchPersonnelLists();
// 回显附件
if (data.attachments && data.attachments.length > 0) {
fileList1.value = data.attachments.map((att) => mapServerFileToUploadItem(att));
}
// 设置页面标题
uni.setNavigationBarTitle({ title: '编辑整改信息' });
}
} catch (error) {
uni.hideLoading();
console.error('获取整改详情失败:', error);
uni.showToast({ title: '获取详情失败', icon: 'none' });
}
};
// AI生成整改方案
const aiGenerating = ref(false);
const handleAiGenerate = async () => {
if (!hazardId.value) {
uni.showToast({ title: '缺少隐患信息', icon: 'none' });
return;
}
aiGenerating.value = true;
try {
const detailRes = await getHiddenDangerDetail({
hazardId: hazardId.value,
assignId: assignId.value
});
if (detailRes.code !== 0 || !detailRes.data) {
uni.showToast({ title: '获取隐患详情失败', icon: 'none' });
return;
}
const { title, description } = detailRes.data;
const aiRes = await generateRectifyPlan({ title, description });
if (aiRes.code === 0 && aiRes.data) {
if (aiRes.data.rawResponse) {
formData.rectifyPlan = aiRes.data.rawResponse;
}
uni.showToast({ title: 'AI生成完成', icon: 'success', duration: 2000 });
} else {
uni.showToast({ title: aiRes.msg || 'AI生成失败', icon: 'none' });
}
} catch (error) {
console.error('AI生成整改方案失败:', error);
uni.showToast({ title: 'AI生成失败请重试', icon: 'none' });
} finally {
aiGenerating.value = false;
}
};
// 草稿缓存与恢复逻辑 (移至底部以确保 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 onSignatureChange = () => {
isSignatureEmpty.value = false;
signaturePaths.value = [];
if (hazardId.value || rectifyId.value) {
saveDraft();
}
};
// 保存草稿 (排除选择器人员缓存,仅缓存方案、情况、金额、图片、签名等输入信息)
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 = {
formData: {
rectifyPlan: formData.rectifyPlan,
rectificationMeasures: formData.rectificationMeasures,
controlMeasures: formData.controlMeasures,
rectifyResult: formData.rectifyResult,
planCost: formData.planCost,
actualCost: formData.actualCost
},
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 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();
}
},
{ deep: true }
);
onLoad((options) => {
// 计算签名画布宽度
try {
const sysInfo = uni.getSystemInfoSync();
signatureWidth.value = sysInfo.windowWidth - 40;
} catch (e) {
console.error('获取系统信息失败:', e);
}
if (options.hazardId) {
hazardId.value = options.hazardId;
}
if (options.assignId) {
assignId.value = options.assignId;
}
// 在hazardId赋值后调用确保有值
if (!options.rectifyId) fetchPersonnelLists();
// 编辑模式:以接口详情为准,不使用本地草稿覆盖签名
if (options.rectifyId) {
rectifyId.value = options.rectifyId;
isEdit.value = options.isEdit === '1';
fetchRectifyDetail();
} else {
// 页面打开时自动恢复草稿 (不包含选择器缓存以防打乱UI时钟)
restoreDraft();
initRectifyTimeDefault();
}
// 从列表页带入的限定整改时间(待整改交办 deadline
if (options.deadline) {
applyDeadlineFromOptions(options.deadline);
}
});
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #EBF2FC;
}
.ai-rectify-btn {
display: flex;
align-items: center;
justify-content: center;
height: 60rpx;
padding: 0 24rpx;
margin: 0;
font-size: 24rpx;
color: #fff;
background: linear-gradient(135deg, #4facfe 0%, #2668EA 100%);
border-radius: 30rpx;
border: none;
white-space: nowrap;
flex-shrink: 0;
&::after {
border: none;
}
.ai-btn-icon {
margin-right: 6rpx;
font-size: 26rpx;
}
&[disabled] {
opacity: 0.7;
}
}
.form-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.form-label {
display: flex;
align-items: center;
.text-red {
margin-left: 4rpx;
line-height: 1;
}
}
.date-input {
background: #fff;
border-radius: 8rpx;
padding: 24rpx 20rpx;
margin-bottom: 20rpx;
border: 1rpx solid #F6F6F6;
text {
font-size: 28rpx;
color: #333;
}
}
.readonly-field {
background: #f5f7fa;
border: 1rpx solid #dcdfe6;
border-radius: 8rpx;
padding: 20rpx 24rpx;
margin-bottom: 20rpx;
font-size: 28rpx;
color: #333;
}
// 选择触发器样式
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border: 1rpx solid #dcdfe6;
border-radius: 8rpx;
padding: 20rpx 24rpx;
margin-bottom: 20rpx;
.select-content {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
// 人员选择弹窗
.user-popup {
background: #fff;
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #eee;
.popup-title {
font-size: 32rpx;
color: #333;
}
.popup-close {
font-size: 40rpx;
color: #999;
line-height: 1;
}
}
.popup-body {
padding: 20rpx 30rpx;
max-height: 600rpx;
overflow-y: auto;
}
&.cascader-user-popup {
.selected-summary {
padding: 16rpx 30rpx;
background: #f5f7fa;
border-bottom: 1rpx solid #eee;
font-size: 24rpx;
line-height: 1.5;
.summary-label {
color: #909399;
}
.summary-text {
color: #333;
}
}
.cascader-body {
display: flex;
height: 600rpx;
border-bottom: 1rpx solid #eee;
}
.cascader-col {
height: 600rpx;
box-sizing: border-box;
}
.dept-col {
width: 38%;
background: #f7f8fa;
border-right: 1rpx solid #eee;
}
.user-col {
width: 62%;
padding: 10rpx 20rpx;
box-sizing: border-box;
}
.cascader-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 24rpx;
font-size: 28rpx;
color: #333;
border-bottom: 1rpx solid #eef0f3;
&.active {
background: #fff;
color: #2667E9;
font-weight: 600;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: #2667E9;
}
}
}
.cascader-item-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dept-badge {
min-width: 32rpx;
height: 32rpx;
line-height: 32rpx;
padding: 0 8rpx;
margin-left: 8rpx;
border-radius: 16rpx;
background: #2667E9;
color: #fff;
font-size: 20rpx;
text-align: center;
}
.empty-tip {
padding: 80rpx 20rpx;
text-align: center;
color: #909399;
font-size: 26rpx;
}
}
.user-item {
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
}
.popup-footer {
display: flex;
border-top: 1rpx solid #eee;
padding-bottom: env(safe-area-inset-bottom);
button {
flex: 1;
height: 90rpx;
line-height: 90rpx;
border-radius: 0;
font-size: 30rpx;
&::after {
border: none;
}
}
.btn-cancel {
background: #fff;
color: #666;
}
.btn-confirm {
color: #fff;
}
}
}
// 签名相关样式
.signature-box {
width: 100%;
min-height: 240rpx;
background: #f8f8f8;
border: 1rpx dashed #dcdfe6;
border-radius: 8rpx;
margin-top: 16rpx;
.signature-img {
width: 100%;
height: 100%;
}
.signature-placeholder {
color: #909399;
font-size: 28rpx;
}
}
.signature-popup {
width: 650rpx;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #eee;
.popup-title {
font-size: 32rpx;
color: #333;
}
.popup-close {
font-size: 40rpx;
color: #999;
line-height: 1;
}
}
.popup-body {
padding: 40rpx 30rpx;
background: #fff;
}
.popup-footer {
display: flex;
padding: 0 30rpx 30rpx;
gap: 20rpx;
button {
flex: 1;
height: 80rpx;
line-height: 80rpx;
font-size: 28rpx;
border-radius: 40rpx;
&::after {
border: none;
}
}
.btn-cancel {
background: #f5f5f5;
color: #666;
}
.btn-confirm {
color: #fff;
}
}
}
</style>