<template>
|
<view class="expert-review-page">
|
<!-- 审查任务概览 - 优化后的紧凑布局 -->
|
<view class="review-overview card">
|
<view class="overview-header">
|
<text class="title">伦理审查任务</text>
|
<view
|
class="status-badge"
|
:class="getStatusClass(caseInfo.receiveStatus)"
|
>
|
{{ getStatusText(caseInfo.receiveStatus) }}
|
</view>
|
</view>
|
|
<!-- 紧凑型基础信息布局 -->
|
<view class="compact-info-grid">
|
<view class="compact-info-item">
|
<up-icon name="file-text" size="14" color="#909399" />
|
<text class="compact-label">住院号</text>
|
<text class="compact-value">{{ caseInfo.inpatientno || "--" }}</text>
|
</view>
|
<view class="compact-info-item">
|
<up-icon name="account" size="14" color="#909399" />
|
<text class="compact-label">捐献者</text>
|
<text class="compact-value">{{ caseInfo.name || "--" }}</text>
|
</view>
|
<view class="compact-info-item">
|
<up-icon name="man" size="14" color="#909399" />
|
<text class="compact-label">性别/年龄</text>
|
<text class="compact-value"
|
>{{ caseInfo.sex || "--" }}/{{ caseInfo.age || "--"
|
}}{{ caseInfo.ageunit || "岁" }}</text
|
>
|
</view>
|
<view class="compact-info-item">
|
<up-icon name="heart" size="14" color="#909399" />
|
<text class="compact-label">疾病诊断</text>
|
<text class="compact-value">{{
|
caseInfo.diagnosisname || "--"
|
}}</text>
|
</view>
|
</view>
|
</view>
|
|
<!-- 优化后的附件区域 - 紧凑列表式设计 -->
|
<view class="materials-section card">
|
<view class="section-header">
|
<text class="section-title">审查材料</text>
|
<text class="material-count">{{ materials.length }}个文件</text>
|
</view>
|
|
<view class="compact-material-list">
|
<view
|
v-for="material in materials"
|
:key="material.id"
|
class="compact-material-item"
|
@tap="previewMaterial(material)"
|
>
|
<view class="material-left">
|
<up-icon :name="material.icon" :color="material.color" size="18" />
|
<text class="file-name">{{ material.name }}</text>
|
<view v-if="material.type" class="file-meta">
|
<text class="file-type">{{ material.type || "文件" }}</text>
|
</view>
|
</view>
|
<view class="material-right">
|
<text class="file-size">{{ material.size }}</text>
|
<up-icon name="arrow-right" size="14" color="#c0c4cc" />
|
</view>
|
</view>
|
</view>
|
</view>
|
|
<!-- 审查表单 -->
|
<view class="review-form card">
|
<view class="section-header">
|
<text class="section-title">审查意见</text>
|
<view v-if="isTimeout" class="timeout-badge">
|
<up-icon name="clock" size="16" />
|
<text>已超时</text>
|
</view>
|
</view>
|
|
<view class="form-content">
|
<!-- 使用uView单选组件替代自定义实现 -->
|
<view class="form-group">
|
<text class="form-label">审查结论</text>
|
<u-radio-group
|
v-model="form.expertconclusion"
|
placement="column"
|
activeColor="#007aff"
|
@change="onConclusionChange"
|
:disabled="isReadonly"
|
>
|
<u-radio
|
v-for="option in conclusionOptions"
|
:key="option.value"
|
:name="option.value"
|
:label="option.label"
|
:customStyle="{ marginBottom: '16rpx' }"
|
></u-radio>
|
</u-radio-group>
|
</view>
|
|
<!-- 审查意见 -->
|
<view class="form-group">
|
<text class="form-label">详细意见</text>
|
<u--textarea
|
v-model="form.expertopinion"
|
placeholder="请输入详细的审查意见和改进建议..."
|
maxlength="1000"
|
count
|
:height="120"
|
border="surround"
|
:disabled="isReadonly"
|
></u--textarea>
|
</view>
|
|
<!-- 签名确认 -->
|
<view class="signature-section">
|
<view class="expert-signature">
|
<up-icon name="edit-pen" size="16" color="#007aff" />
|
<text class="signature-text">审查专家:{{ expertInfo.name }}</text>
|
</view>
|
<view class="time-signature">
|
<up-icon name="clock" size="16" color="#007aff" />
|
<text class="signature-text">{{ currentTime }}</text>
|
</view>
|
</view>
|
</view>
|
</view>
|
|
<!-- 签名确认区域 -->
|
<view class="signature-section card">
|
<view class="section-header">
|
<text class="section-title">专家签名确认</text>
|
<view v-if="isTimeout" class="timeout-badge">
|
<up-icon name="clock" size="16" />
|
<text>已超时,不可操作</text>
|
</view>
|
</view>
|
|
<view class="signature-content">
|
<!-- 显示已签名图片 -->
|
<view v-if="signatureData.signatureUrl" class="signed-preview">
|
<image
|
:src="signatureData.signatureUrl"
|
mode="aspectFit"
|
class="signature-image"
|
@tap="previewSignature"
|
/>
|
<view class="signature-info">
|
<text class="signature-name">签名人:{{ expertInfo.name }}</text>
|
<text class="signature-time">{{
|
signatureData.signatureTime
|
}}</text>
|
<view class="signature-actions" v-if="!isReadonly">
|
<button class="re-sign-btn" @tap="removeSignature">
|
<u-icon name="photo" size="16" />
|
<text>重新签名</text>
|
</button>
|
</view>
|
</view>
|
</view>
|
|
<!-- 添加签名按钮 -->
|
<view
|
v-else
|
class="signature-upload"
|
@tap="openSignaturePanel"
|
v-if="!isReadonly"
|
>
|
<view class="signature-upload-area">
|
<up-icon name="edit-pen" size="48" color="#c0c4cc" />
|
<text class="upload-hint">点击进行手写签名</text>
|
<text class="upload-tip">签名将作为重要凭证</text>
|
</view>
|
</view>
|
|
<!-- 超时状态下显示提示 -->
|
<view
|
v-if="isReadonly && !signatureData.signatureUrl"
|
class="signature-disabled"
|
>
|
<view class="signature-disabled-area">
|
<up-icon name="close-circle" size="48" color="#dcdfe6" />
|
<text class="disabled-hint">当前任务已超时</text>
|
<text class="disabled-tip">无法进行签名操作</text>
|
</view>
|
</view>
|
</view>
|
</view>
|
|
<!-- 操作按钮 -->
|
<view class="action-bar-compact" v-if="!isReadonly">
|
<button class="action-btn save-btn" @tap="saveDraft" v-if="showSaveBtn">
|
<up-icon name="file-text" size="16" color="#606266" />
|
<text>保存草稿</text>
|
</button>
|
<button
|
class="action-btn submit-btn"
|
@tap="submitReview"
|
v-if="showSubmitBtn"
|
>
|
<up-icon name="checkmark" size="16" color="#fff" />
|
<text>{{ submitBtnText }}</text>
|
</button>
|
</view>
|
|
<!-- 只读状态提示 -->
|
<view v-if="isReadonly" class="readonly-tip card">
|
<up-icon name="info-circle" size="20" color="#fa8c16" />
|
<text>当前任务已超时,仅可查看,不可操作</text>
|
</view>
|
|
<!-- 优化的签名弹窗组件 -->
|
<u-popup
|
:show="showSignaturePanel"
|
@close="closeSignaturePanel"
|
mode="bottom"
|
:round="20"
|
:closeable="true"
|
closeIcon="close"
|
>
|
<view class="signature-modal">
|
<view class="modal-header">
|
<text class="modal-title">手写签名</text>
|
<text class="modal-subtitle">请在下方区域进行签名</text>
|
</view>
|
|
<view class="signature-canvas-container">
|
<!-- 手写签名画布 -->
|
<canvas
|
canvas-id="signatureCanvas"
|
class="signature-canvas"
|
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
@touchstart="onTouchStart"
|
@touchmove="onTouchMove"
|
@touchend="onTouchEnd"
|
disable-scroll
|
></canvas>
|
|
<!-- 操作按钮 -->
|
<view class="canvas-actions">
|
<button class="action-btn clear-btn" @tap="clearCanvas">
|
<up-icon name="trash" size="20" />
|
<text>清空</text>
|
</button>
|
<button
|
class="action-btn redo-btn"
|
@tap="undoLastStroke"
|
:disabled="!canUndo"
|
>
|
<up-icon name="play-left" size="20" />
|
<text>撤销</text>
|
</button>
|
<button
|
class="action-btn confirm-btn"
|
@tap="confirmSignature"
|
:disabled="!hasSignature"
|
>
|
<up-icon name="checkmark" size="20" />
|
<text>确认签名</text>
|
</button>
|
</view>
|
|
<!-- 签名预览 -->
|
<view v-if="tempSignatureData" class="signature-preview">
|
<text class="preview-title">签名预览</text>
|
<image
|
:src="tempSignatureData"
|
mode="aspectFit"
|
class="preview-image"
|
/>
|
</view>
|
</view>
|
</view>
|
</u-popup>
|
|
<!-- 提交确认弹窗 -->
|
<u-modal
|
:show="showSubmitModal"
|
:title="modalTitle"
|
:content="modalContent"
|
showCancelButton
|
@confirm="confirmSubmit"
|
@cancel="showSubmitModal = false"
|
></u-modal>
|
</view>
|
</template>
|
|
<script setup>
|
import { ref, computed, reactive, onMounted, nextTick } from "vue";
|
import { onLoad, onShow } from "@dcloudio/uni-app";
|
import dayjs from "dayjs";
|
import { useUserStore } from "@/stores/user";
|
const userStore = useUserStore();
|
|
const dict = ref({});
|
// 响应式数据
|
const caseInfo = ref({
|
hospitalNo: "",
|
donorName: "",
|
gender: "",
|
age: "",
|
diagnosis: "",
|
receiveStatus: "0",
|
endtime: "",
|
});
|
|
const id = ref(null);
|
const fcid = ref(null);
|
const materials = ref([]);
|
const form = ref({
|
expertconclusion: "", // 专家结论
|
expertopinion: "", // 专家意见
|
});
|
|
const expertInfo = ref({
|
name: "",
|
title: "",
|
});
|
|
const showSubmitModal = ref(false);
|
const modalTitle = ref("确认提交");
|
const modalContent = ref("确定要提交审查意见吗?提交后将无法修改。");
|
|
// 计算属性
|
const currentTime = computed(() => {
|
return new Date().toLocaleString("zh-CN", {
|
year: "numeric",
|
month: "2-digit",
|
day: "2-digit",
|
hour: "2-digit",
|
minute: "2-digit",
|
});
|
});
|
|
const conclusionOptions = ref([
|
{ label: "同意", value: "1" }, // 对应receiveStatus 5-完成
|
{ label: "驳回", value: "2" }, // 对应receiveStatus 6-驳回
|
]);
|
|
// 签名相关数据
|
const showSignaturePanel = ref(false);
|
const signatureData = reactive({
|
signatureUrl: "",
|
signatureTime: "",
|
fileName: "",
|
serverData: null,
|
});
|
|
// 手写签名相关
|
const canvasWidth = 650;
|
const canvasHeight = 300;
|
let ctx = null;
|
let isDrawing = false;
|
let lastX = 0;
|
let lastY = 0;
|
let strokeHistory = [];
|
const tempSignatureData = ref("");
|
const canUndo = computed(() => strokeHistory.length > 0);
|
const hasSignature = computed(() => tempSignatureData.value !== "");
|
|
// 上传配置
|
const uploadConfig = reactive({
|
uploadUrl: "/api/common/upload",
|
extraParams: {
|
caseNo: "",
|
expertId: "",
|
expertName: "",
|
type: "ethics_review_signature",
|
bizType: "expert_review",
|
},
|
});
|
|
// 按钮显示控制
|
const showSaveBtn = ref(true);
|
const showSubmitBtn = ref(true);
|
const submitBtnText = ref("提交审查");
|
|
// 状态转换
|
const getStatusText = (status) => {
|
const statusMap = {
|
0: "待接收",
|
1: "未接收",
|
2: "已接收",
|
3: "超时",
|
4: "中止",
|
5: "完成",
|
6: "驳回",
|
};
|
return statusMap[status] || "未知状态";
|
};
|
|
const getStatusClass = (status) => {
|
const classMap = {
|
0: "pending",
|
1: "pending",
|
2: "pending",
|
3: "submitted",
|
4: "submitted",
|
5: "success",
|
6: "error",
|
};
|
return classMap[status] || "pending";
|
};
|
|
// 是否超时
|
const isTimeout = computed(() => {
|
return caseInfo.value.receiveStatus === "3";
|
});
|
|
// 是否只读模式
|
const isReadonly = computed(() => {
|
return isTimeout.value;
|
});
|
|
// 是否可以提交
|
const canSubmit = computed(() => {
|
if (isReadonly.value) return false;
|
return (
|
form.value.expertconclusion !== "" &&
|
form.value.expertopinion.trim().length > 0 &&
|
signatureData.signatureUrl !== "" &&
|
signatureData.signatureUrl.startsWith("http") // 确保是完整的URL路径
|
);
|
});
|
|
// 生命周期
|
onLoad(async (options) => {
|
id.value = options.id;
|
fcid.value = options.fcid;
|
if (fcid.value) {
|
await loadCaseData(fcid.value);
|
}
|
});
|
|
onShow(() => {
|
// 页面显示时检查用户登录状态
|
const userInfo = uni.getStorageSync("userInfo");
|
if (userInfo) {
|
expertInfo.value.name = userInfo.nickName || userInfo.userName || "";
|
}
|
});
|
|
// 加载案件数据
|
const loadCaseData = async (id) => {
|
try {
|
uni.showLoading({ title: "加载中..." });
|
|
// 构建查询参数
|
const params = {
|
fcid: id,
|
pageNum: 1,
|
pageSize: 1,
|
};
|
|
const res = await uni.$uapi.get(
|
`/project/ethicalreviewopinions/listnew`,
|
params,
|
);
|
|
if (res.code === 200 && res.rows && res.rows.length > 0) {
|
const data = res.rows[0];
|
console.log("加载的审查数据:", data);
|
|
// 设置案件信息
|
caseInfo.value = {
|
...caseInfo.value,
|
inpatientno: data.inpatientno,
|
name: data.name,
|
sex: data.sex,
|
age: data.age,
|
ageunit: data.ageunit,
|
expertType: data.expertType,
|
diagnosisname: data.diagnosisname,
|
receiveStatus: data.receiveStatus || "0",
|
endtime: data.endtime || "",
|
caseNo: data.caseNo || "",
|
};
|
|
// 设置表单数据
|
if (data.expertconclusion) {
|
form.value.expertconclusion = data.expertconclusion.toString();
|
}
|
if (data.expertopinion) {
|
form.value.expertopinion = data.expertopinion;
|
}
|
|
// 设置专家信息
|
if (data.expertname) {
|
expertInfo.value.name = data.expertname;
|
} else {
|
// 从用户信息获取
|
const userInfo = uni.getStorageSync("userInfo");
|
if (userInfo) {
|
expertInfo.value.name = userInfo.nickName || userInfo.userName || "";
|
}
|
}
|
|
// 设置签名
|
if (data.sigin) {
|
// 检查签名URL是否是完整路径
|
if (data.sigin.startsWith("http")) {
|
signatureData.signatureUrl = data.sigin;
|
} else {
|
// 如果不是完整路径,可能需要拼接基础URL
|
signatureData.signatureUrl = `/api${
|
data.sigin.startsWith("/") ? "" : "/"
|
}${data.sigin}`;
|
}
|
signatureData.signatureTime = data.conclusiontime || "";
|
}
|
|
// 解析附件
|
if (data.filePatch) {
|
parseAnnexFiles(data.filePatch);
|
}
|
|
// 检查是否超时
|
checkTimeoutStatus(data);
|
|
// 根据状态控制按钮显示
|
updateButtonStatus(data.receiveStatus);
|
|
// 更新上传参数
|
uploadConfig.extraParams.caseNo = data.caseNo || "";
|
uploadConfig.extraParams.expertName = expertInfo.value.name;
|
} else {
|
uni.showToast({
|
title: res.msg || "未找到审查数据",
|
icon: "none",
|
});
|
}
|
} catch (error) {
|
if (error.message === "未登录") {
|
// ✅ 什么都不做,拦截器已经处理
|
return;
|
}
|
uni.showToast({ title: "加载失败", icon: "none" });
|
} finally {
|
uni.hideLoading();
|
}
|
};
|
|
// 检查是否超时
|
const checkTimeoutStatus = (data) => {
|
if (data.receiveStatus === "3") {
|
// 状态已经是超时
|
return;
|
}
|
|
// 如果有截止时间,检查是否超过当前时间
|
if (data.endtime) {
|
const endTime = new Date(data.endtime);
|
const now = new Date();
|
if (now > endTime) {
|
// 标记为超时状态
|
caseInfo.value.receiveStatus = "3";
|
updateButtonStatus("3");
|
}
|
}
|
};
|
|
// 解析附件文件
|
const parseAnnexFiles = (filePatch) => {
|
if (!filePatch) return;
|
|
try {
|
// 解析JSON字符串
|
let fileList = [];
|
|
// 检查是否是JSON字符串格式
|
if (filePatch.startsWith("[") && filePatch.endsWith("]")) {
|
// 处理转义字符
|
const cleanJson = filePatch.replace(/\\"/g, '"');
|
fileList = JSON.parse(cleanJson);
|
} else if (filePatch.includes("fileName")) {
|
// 尝试直接解析
|
fileList = JSON.parse(filePatch);
|
} else {
|
// 旧格式处理
|
const oldFileList = filePatch.split(";").filter((item) => item.trim());
|
fileList = oldFileList.map((file) => ({
|
fileName: file.split("/").pop() || `附件`,
|
path: file,
|
fileUrl: file,
|
}));
|
}
|
|
// 转换为需要的格式
|
materials.value = fileList.map((file, index) => {
|
const fileName = file.fileName || `附件${index + 1}`;
|
const fileUrl = file.fileUrl || file.path || file.url || "";
|
const ext = fileName.split(".").pop().toLowerCase();
|
let icon = "file-text";
|
let color = "#909399";
|
|
if (["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(ext)) {
|
icon = "photo";
|
color = "#fa8c16";
|
} else if (["doc", "docx"].includes(ext)) {
|
icon = "file-text";
|
color = "#1890ff";
|
} else if (["xls", "xlsx", "csv"].includes(ext)) {
|
icon = "file-text";
|
color = "#52c41a";
|
} else if (["pdf"].includes(ext)) {
|
icon = "file-text";
|
color = "#f56c6c";
|
} else if (["txt", "text"].includes(ext)) {
|
icon = "file-text";
|
color = "#909399";
|
} else if (["zip", "rar", "7z"].includes(ext)) {
|
icon = "folder";
|
color = "#722ed1";
|
}
|
|
return {
|
id: file.infoid || index + 1,
|
name: fileName,
|
icon: icon,
|
color: color,
|
size: "--",
|
url: fileUrl,
|
type: ext,
|
createTime: file.createTime || "",
|
};
|
});
|
} catch (error) {
|
console.error("解析附件失败:", error, filePatch);
|
|
// 回退到旧格式处理
|
const fileList = filePatch.split(";").filter((item) => item.trim());
|
materials.value = fileList.map((file, index) => {
|
const fileName = file.split("/").pop() || `附件${index + 1}`;
|
const ext = fileName.split(".").pop().toLowerCase();
|
let icon = "file-text";
|
let color = "#909399";
|
|
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(ext)) {
|
icon = "photo";
|
color = "#fa8c16";
|
} else if (["doc", "docx"].includes(ext)) {
|
icon = "file-text";
|
color = "#1890ff";
|
} else if (["xls", "xlsx"].includes(ext)) {
|
icon = "file-text";
|
color = "#52c41a";
|
} else if (["pdf"].includes(ext)) {
|
icon = "file-text";
|
color = "#f56c6c";
|
}
|
|
return {
|
id: index + 1,
|
name: fileName,
|
icon: icon,
|
color: color,
|
size: "--",
|
url: file,
|
};
|
});
|
}
|
};
|
|
// 更新按钮状态
|
const updateButtonStatus = (status) => {
|
const statusNum = status;
|
switch (statusNum) {
|
case "3": // 超时
|
case "4": // 中止
|
case "5": // 已完成
|
case "6": // 已驳回
|
showSaveBtn.value = false;
|
showSubmitBtn.value = false;
|
break;
|
default:
|
showSaveBtn.value = true;
|
showSubmitBtn.value = true;
|
submitBtnText.value = "提交审查";
|
}
|
};
|
|
// 打开签名面板
|
const openSignaturePanel = () => {
|
if (isReadonly.value) {
|
uni.showToast({
|
title: "当前任务已超时,不可操作",
|
icon: "none",
|
});
|
return;
|
}
|
|
showSignaturePanel.value = true;
|
nextTick(() => {
|
initCanvas();
|
});
|
};
|
|
// 关闭签名面板
|
const closeSignaturePanel = () => {
|
showSignaturePanel.value = false;
|
clearCanvas();
|
};
|
|
// 初始化画布
|
const initCanvas = () => {
|
ctx = uni.createCanvasContext("signatureCanvas");
|
clearCanvas();
|
};
|
|
// 清空画布
|
const clearCanvas = () => {
|
if (!ctx) return;
|
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
ctx.setStrokeStyle("#000000");
|
ctx.setLineWidth(3);
|
ctx.setLineCap("round");
|
ctx.setLineJoin("round");
|
ctx.draw();
|
strokeHistory = [];
|
tempSignatureData.value = "";
|
};
|
|
// 撤销上一步
|
const undoLastStroke = () => {
|
if (strokeHistory.length === 0) return;
|
|
// 移除最后一步
|
strokeHistory.pop();
|
|
// 重新绘制
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
ctx.setStrokeStyle("#000000");
|
ctx.setLineWidth(3);
|
ctx.setLineCap("round");
|
ctx.setLineJoin("round");
|
|
// 绘制历史笔迹
|
strokeHistory.forEach((stroke) => {
|
ctx.beginPath();
|
ctx.moveTo(stroke.startX, stroke.startY);
|
ctx.lineTo(stroke.endX, stroke.endY);
|
ctx.stroke();
|
});
|
|
ctx.draw();
|
|
// 更新预览
|
if (strokeHistory.length === 0) {
|
tempSignatureData.value = "";
|
} else {
|
getCanvasImage();
|
}
|
};
|
|
// 触摸开始
|
const onTouchStart = (e) => {
|
if (isReadonly.value) return;
|
|
isDrawing = true;
|
const touch = e.touches[0];
|
lastX = touch.x;
|
lastY = touch.y;
|
ctx.beginPath();
|
ctx.moveTo(lastX, lastY);
|
};
|
|
// 触摸移动
|
const onTouchMove = (e) => {
|
if (!isDrawing || isReadonly.value) return;
|
|
const touch = e.touches[0];
|
const currentX = touch.x;
|
const currentY = touch.y;
|
|
ctx.lineTo(currentX, currentY);
|
ctx.stroke();
|
ctx.draw(true);
|
|
// 保存笔迹到历史记录
|
strokeHistory.push({
|
startX: lastX,
|
startY: lastY,
|
endX: currentX,
|
endY: currentY,
|
});
|
|
lastX = currentX;
|
lastY = currentY;
|
};
|
|
// 触摸结束
|
const onTouchEnd = () => {
|
if (!isDrawing) return;
|
|
isDrawing = false;
|
ctx.closePath();
|
|
// 更新预览
|
getCanvasImage();
|
};
|
|
// 获取画布图片
|
const getCanvasImage = () => {
|
uni.canvasToTempFilePath({
|
canvasId: "signatureCanvas",
|
success: (res) => {
|
tempSignatureData.value = res.tempFilePath;
|
},
|
fail: (err) => {
|
console.error("获取画布图片失败:", err);
|
},
|
});
|
};
|
|
// 确认签名
|
const confirmSignature = async () => {
|
if (!tempSignatureData.value) {
|
uni.showToast({
|
title: "请先签名",
|
icon: "none",
|
});
|
return;
|
}
|
|
try {
|
uni.showLoading({ title: "保存签名中..." });
|
|
// 上传签名文件
|
const uploadResult = await uploadSignature(tempSignatureData.value);
|
|
if (uploadResult) {
|
// 更新签名数据
|
signatureData.signatureUrl = uploadResult.url;
|
signatureData.signatureTime = new Date().toLocaleString("zh-CN");
|
signatureData.fileName =
|
uploadResult.originalFilename || `signature_${Date.now()}.png`;
|
signatureData.serverData = uploadResult;
|
|
// 保存到本地
|
saveSignatureToLocal(uploadResult);
|
|
uni.hideLoading();
|
uni.showToast({
|
title: "签名保存成功",
|
icon: "success",
|
});
|
|
showSignaturePanel.value = false;
|
clearCanvas();
|
} else {
|
uni.hideLoading();
|
uni.showToast({
|
title: "签名上传失败",
|
icon: "none",
|
});
|
}
|
} catch (error) {
|
console.error("签名上传失败:", error);
|
uni.hideLoading();
|
uni.showToast({
|
title: "签名上传失败",
|
icon: "none",
|
});
|
}
|
};
|
|
// 上传签名文件方法
|
const uploadSignature = (filePath) => {
|
return new Promise((resolve, reject) => {
|
const token = uni.getStorageSync("token");
|
|
// 获取用户信息
|
const userInfo = uni.getStorageSync("userInfo");
|
const expertName =
|
userInfo?.nickName || userInfo?.userName || expertInfo.value.name;
|
|
uni.uploadFile({
|
url: "/api/common/upload",
|
filePath: filePath,
|
name: "file",
|
header: {
|
Authorization: `Bearer ${token}`,
|
},
|
formData: {
|
// 添加额外参数,参考附件组件
|
bizType: "expert_review_signature",
|
caseNo: caseInfo.value.caseNo || "",
|
expertName: expertName,
|
uploadType: "signature",
|
},
|
success: (res) => {
|
if (res.statusCode === 200) {
|
const data = JSON.parse(res.data);
|
console.log("签名上传成功:", data);
|
|
if (data.code === 200) {
|
resolve({
|
url: data.url,
|
fileName: data.fileName,
|
newFileName: data.newFileName,
|
originalFilename: data.originalFilename,
|
filePath: data.filePath || data.fileName,
|
size: data.size,
|
});
|
} else {
|
reject(new Error(data.msg || "上传失败"));
|
}
|
} else {
|
reject(new Error(`上传失败,状态码: ${res.statusCode}`));
|
}
|
},
|
fail: (err) => {
|
reject(err);
|
},
|
});
|
});
|
};
|
|
// 保存到本地
|
const saveSignatureToLocal = (uploadData) => {
|
try {
|
const signatureInfo = {
|
signatureUrl: signatureData.signatureUrl,
|
signatureTime: signatureData.signatureTime,
|
fileName: signatureData.fileName,
|
uploadData: uploadData, // 保存上传返回的数据
|
caseNo: caseInfo.value.caseNo,
|
timestamp: Date.now(),
|
};
|
|
uni.setStorageSync("expert_review_signature", signatureInfo);
|
} catch (error) {
|
console.error("保存签名到本地失败:", error);
|
}
|
};
|
|
// 删除签名
|
const removeSignature = () => {
|
if (isReadonly.value) {
|
uni.showToast({
|
title: "当前任务已超时,不可操作",
|
icon: "none",
|
});
|
return;
|
}
|
|
uni.showModal({
|
title: "提示",
|
content: "确定要删除签名吗?",
|
success: (res) => {
|
if (res.confirm) {
|
signatureData.signatureUrl = "";
|
signatureData.signatureTime = "";
|
signatureData.fileName = "";
|
signatureData.serverData = null;
|
uni.removeStorageSync("expert_review_signature");
|
}
|
},
|
});
|
};
|
|
// 预览签名
|
// 预览签名
|
const previewSignature = () => {
|
if (signatureData.signatureUrl) {
|
// 检查URL是否需要拼接基础路径
|
let previewUrl = signatureData.signatureUrl;
|
if (!previewUrl.startsWith("http")) {
|
// 可能需要拼接服务器基础URL
|
previewUrl = `${userStore?.baseUrlHt || ""}${previewUrl}`;
|
}
|
|
uni.previewImage({
|
urls: [previewUrl],
|
});
|
}
|
};
|
|
// 预览材料
|
// 预览材料
|
const previewMaterial = (material) => {
|
if (material.url) {
|
uni.showLoading({ title: "加载中..." });
|
|
const fileExt =
|
material.type || material.url.split(".").pop().toLowerCase();
|
|
// 判断文件类型
|
if (["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(fileExt)) {
|
// 图片直接预览
|
uni.previewImage({
|
urls: [material.url],
|
current: 0,
|
success: () => {
|
console.log("图片预览成功");
|
},
|
fail: (err) => {
|
console.error("图片预览失败:", err);
|
uni.showToast({
|
title: "图片加载失败",
|
icon: "none",
|
});
|
},
|
complete: () => {
|
uni.hideLoading();
|
},
|
});
|
} else if (
|
["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt"].includes(
|
fileExt,
|
)
|
) {
|
// 文档文件下载后打开
|
uni.downloadFile({
|
url: material.url,
|
success: (res) => {
|
if (res.statusCode === 200) {
|
const filePath = res.tempFilePath;
|
uni.openDocument({
|
filePath: filePath,
|
showMenu: true,
|
fileType: fileExt === "pdf" ? "pdf" : "",
|
success: () => {
|
console.log("打开文档成功");
|
},
|
fail: (err) => {
|
console.error("打开文档失败:", err);
|
uni.showToast({
|
title: "无法打开该文件",
|
icon: "none",
|
});
|
},
|
});
|
}
|
},
|
fail: (err) => {
|
console.error("下载文件失败:", err);
|
uni.showToast({
|
title: "文件下载失败",
|
icon: "none",
|
});
|
},
|
complete: () => {
|
uni.hideLoading();
|
},
|
});
|
} else {
|
// 其他文件类型
|
uni.showToast({
|
title: `暂不支持预览${fileExt}格式文件`,
|
icon: "none",
|
});
|
uni.hideLoading();
|
}
|
} else {
|
uni.showToast({
|
title: `预览: ${material.name}`,
|
icon: "none",
|
});
|
}
|
};
|
|
const onConclusionChange = (value) => {
|
console.log("选中结论:", value);
|
};
|
|
// 保存草稿
|
const saveDraft = async () => {
|
if (isReadonly.value) {
|
uni.showToast({
|
title: "当前任务已超时,不可操作",
|
icon: "none",
|
});
|
return;
|
}
|
|
if (!validateForm(true)) return;
|
|
try {
|
uni.showLoading({ title: "保存中..." });
|
|
const submitData = {
|
fcid: fcid.value,
|
expertconclusion: form.value.expertconclusion,
|
expertopinion: form.value.expertopinion,
|
sigin: signatureData.signatureUrl, // 使用上传后的完整路径
|
receiveStatus: "2", // 已接收状态
|
conclusiontime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
|
};
|
|
const res = await uni.$uapi.post(
|
"/project/ethicalreviewopinions/edit",
|
submitData,
|
);
|
|
if (res.code === 200) {
|
uni.showToast({
|
title: "保存成功",
|
icon: "success",
|
});
|
// 更新本地状态
|
caseInfo.value.receiveStatus = "2";
|
updateButtonStatus("2");
|
} else {
|
uni.showToast({
|
title: res.msg || "保存失败",
|
icon: "none",
|
});
|
}
|
} catch (error) {
|
console.error("保存草稿失败:", error);
|
uni.showToast({
|
title: "保存失败",
|
icon: "none",
|
});
|
} finally {
|
uni.hideLoading();
|
}
|
};
|
|
// 提交审查
|
const submitReview = () => {
|
console.log(1);
|
|
if (isReadonly.value) {
|
uni.showToast({
|
title: "当前任务已超时,不可操作",
|
icon: "none",
|
});
|
return;
|
}
|
console.log(2);
|
|
if (!validateForm()) return;
|
|
modalTitle.value = "确认提交";
|
modalContent.value = "确定要提交审查意见吗?提交后将无法修改。";
|
showSubmitModal.value = true;
|
};
|
|
// 确认提交
|
const confirmSubmit = async () => {
|
try {
|
uni.showLoading({ title: "提交中..." });
|
console.log(caseInfo.value, "form.value");
|
|
const submitData = {
|
id: fcid.value,
|
expertconclusion: form.value.expertconclusion,
|
expertopinion: form.value.expertopinion,
|
sigin: signatureData.signatureUrl,
|
expertType: caseInfo.value.expertType,
|
// 根据结论设置状态
|
receiveStatus: form.value.expertconclusion == "1" ? "5" : "6", // 5-完成, 6-驳回
|
conclusiontime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
|
};
|
|
const res = await uni.$uapi.post(
|
"/project/ethicalreviewopinions/edit",
|
submitData,
|
);
|
|
if (res.code === 200) {
|
uni.showToast({
|
title: "提交成功",
|
icon: "success",
|
duration: 2000,
|
});
|
|
// 更新本地状态
|
caseInfo.value.receiveStatus = submitData.receiveStatus;
|
updateButtonStatus(submitData.receiveStatus);
|
|
showSubmitModal.value = false;
|
|
setTimeout(() => {
|
uni.navigateBack();
|
}, 1500);
|
} else {
|
uni.showToast({
|
title: res.msg || "提交失败",
|
icon: "none",
|
});
|
}
|
} catch (error) {
|
console.error("提交失败:", error);
|
uni.showToast({
|
title: "提交失败",
|
icon: "none",
|
});
|
} finally {
|
uni.hideLoading();
|
}
|
};
|
|
// 表单验证
|
const validateForm = (isDraft = false) => {
|
if (!form.value.expertconclusion && !isDraft) {
|
uni.showToast({
|
title: "请选择审查结论",
|
icon: "none",
|
});
|
return false;
|
}
|
console.log(3);
|
|
if (!form.value.expertopinion.trim() && !isDraft) {
|
uni.showToast({
|
title: "请输入审查意见",
|
icon: "none",
|
});
|
return false;
|
}
|
console.log(signatureData, "signatureData");
|
|
if (!signatureData.signatureUrl && !isDraft) {
|
uni.showToast({
|
title: "请进行手写签名",
|
icon: "none",
|
});
|
return false;
|
}
|
|
return true;
|
};
|
</script>
|
|
<style lang="scss">
|
.expert-review-page {
|
min-height: 100vh;
|
background: #f5f7fa;
|
padding: 20rpx;
|
padding-bottom: 140rpx;
|
|
.card {
|
background: #fff;
|
border-radius: 16rpx;
|
margin-bottom: 20rpx;
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
}
|
|
.review-overview {
|
padding: 24rpx;
|
|
.overview-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 20rpx;
|
|
.title {
|
font-size: 32rpx;
|
font-weight: 600;
|
color: #303133;
|
}
|
|
.status-badge {
|
padding: 6rpx 12rpx;
|
border-radius: 12rpx;
|
font-size: 22rpx;
|
font-weight: 500;
|
|
&.pending {
|
background: #fff2e8;
|
color: #fa8c16;
|
}
|
&.submitted {
|
background: #e6f7ff;
|
color: #1890ff;
|
}
|
&.success {
|
background: #f6ffed;
|
color: #52c41a;
|
}
|
&.error {
|
background: #fff1f0;
|
color: #ff4d4f;
|
}
|
}
|
}
|
|
.compact-info-grid {
|
display: grid;
|
grid-template-columns: 1fr 1fr;
|
gap: 16rpx;
|
|
.compact-info-item {
|
display: flex;
|
align-items: center;
|
padding: 12rpx;
|
background: #f8f9fa;
|
border-radius: 8rpx;
|
|
.compact-label {
|
font-size: 24rpx;
|
color: #909399;
|
margin: 0 8rpx 0 12rpx;
|
}
|
|
.compact-value {
|
font-size: 24rpx;
|
color: #303133;
|
font-weight: 500;
|
flex: 1;
|
}
|
}
|
}
|
}
|
.material-left {
|
display: flex;
|
align-items: center;
|
flex: 1;
|
|
.file-info {
|
margin-left: 16rpx;
|
display: flex;
|
flex-direction: column;
|
}
|
|
.file-name {
|
font-size: 26rpx;
|
color: #303133;
|
max-width: 400rpx;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.file-meta {
|
display: flex;
|
align-items: center;
|
gap: 8rpx;
|
margin-top: 4rpx;
|
|
.file-type {
|
font-size: 20rpx;
|
color: #909399;
|
background: #f0f2f5;
|
padding: 2rpx 8rpx;
|
border-radius: 4rpx;
|
}
|
|
.file-time {
|
font-size: 20rpx;
|
color: #909399;
|
}
|
}
|
}
|
.materials-section {
|
padding: 24rpx;
|
|
.section-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 20rpx;
|
|
.section-title {
|
font-size: 28rpx;
|
font-weight: 600;
|
color: #303133;
|
}
|
|
.material-count {
|
font-size: 24rpx;
|
color: #909399;
|
}
|
}
|
|
.compact-material-list {
|
.compact-material-item {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 20rpx;
|
border-bottom: 1rpx solid #f0f0f0;
|
|
&:last-child {
|
border-bottom: none;
|
}
|
|
.material-left {
|
display: flex;
|
align-items: center;
|
flex: 1;
|
|
.file-name {
|
font-size: 26rpx;
|
color: #303133;
|
margin-left: 16rpx;
|
max-width: 400rpx;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
}
|
|
.material-right {
|
display: flex;
|
align-items: center;
|
|
.file-size {
|
font-size: 22rpx;
|
color: #909399;
|
margin-right: 12rpx;
|
}
|
}
|
|
&:active {
|
background: #f8f9fa;
|
}
|
}
|
}
|
}
|
|
.review-form {
|
padding: 24rpx;
|
|
.section-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 20rpx;
|
|
.section-title {
|
font-size: 28rpx;
|
font-weight: 600;
|
color: #303133;
|
}
|
|
.timeout-badge {
|
display: flex;
|
align-items: center;
|
gap: 4rpx;
|
padding: 4rpx 12rpx;
|
background: #fff2e8;
|
border-radius: 12rpx;
|
font-size: 22rpx;
|
color: #fa8c16;
|
}
|
}
|
|
.form-group {
|
margin-bottom: 32rpx;
|
|
.form-label {
|
display: block;
|
font-size: 28rpx;
|
font-weight: 600;
|
color: #303133;
|
margin-bottom: 20rpx;
|
}
|
}
|
|
.signature-section {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
padding: 20rpx;
|
background: #f8f9fa;
|
border-radius: 8rpx;
|
margin-top: 32rpx;
|
|
.expert-signature,
|
.time-signature {
|
display: flex;
|
align-items: center;
|
|
.signature-text {
|
font-size: 24rpx;
|
color: #606266;
|
margin-left: 8rpx;
|
}
|
}
|
}
|
}
|
|
.action-bar-compact {
|
position: fixed;
|
bottom: 0rpx;
|
left: 0;
|
right: 0;
|
display: flex;
|
gap: 20rpx;
|
padding: 20rpx;
|
background: #fff;
|
border-top: 1rpx solid #e4e7ed;
|
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.08);
|
|
.action-btn {
|
flex: 1;
|
height: 80rpx;
|
border: none;
|
border-radius: 40rpx;
|
font-size: 28rpx;
|
font-weight: 500;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
gap: 8rpx;
|
|
&.save-btn {
|
background: #f5f5f5;
|
color: #606266;
|
}
|
|
&.submit-btn {
|
background: linear-gradient(135deg, #0f95b0, #89c4c1) !important;
|
color: #fff;
|
|
&:disabled {
|
background: #c0c4cc !important;
|
opacity: 0.6;
|
}
|
}
|
|
&:active:not(:disabled) {
|
transform: scale(0.98);
|
}
|
}
|
}
|
|
.readonly-tip {
|
display: flex;
|
align-items: center;
|
gap: 8rpx;
|
padding: 20rpx 24rpx;
|
font-size: 24rpx;
|
color: #fa8c16;
|
}
|
}
|
|
.signature-section {
|
margin-top: 20rpx;
|
padding: 24rpx;
|
background: #fff;
|
border-radius: 16rpx;
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
|
.section-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 20rpx;
|
|
.section-title {
|
font-size: 28rpx;
|
font-weight: 600;
|
color: #333;
|
}
|
|
.timeout-badge {
|
display: flex;
|
align-items: center;
|
gap: 4rpx;
|
padding: 4rpx 12rpx;
|
background: #fff2e8;
|
border-radius: 12rpx;
|
font-size: 22rpx;
|
color: #fa8c16;
|
}
|
}
|
}
|
|
.signature-content {
|
.signed-preview {
|
display: flex;
|
align-items: center;
|
padding: 20rpx;
|
background: #f8f9fa;
|
border-radius: 12rpx;
|
gap: 20rpx;
|
|
.signature-image {
|
width: 200rpx;
|
height: 100rpx;
|
border: 1rpx solid #dcdfe6;
|
border-radius: 8rpx;
|
background: #fff;
|
}
|
|
.signature-info {
|
flex: 1;
|
|
.signature-name {
|
display: block;
|
font-size: 26rpx;
|
color: #333;
|
margin-bottom: 8rpx;
|
font-weight: 500;
|
}
|
|
.signature-time {
|
display: block;
|
font-size: 24rpx;
|
color: #666;
|
margin-bottom: 8rpx;
|
}
|
|
.signature-actions {
|
margin-top: 12rpx;
|
|
.re-sign-btn {
|
padding: 8rpx 16rpx;
|
border: 1rpx solid #dcdfe6;
|
background: #fff;
|
border-radius: 8rpx;
|
font-size: 24rpx;
|
color: #f56c6c;
|
display: flex;
|
align-items: center;
|
gap: 6rpx;
|
|
&:active {
|
background: #f5f7fa;
|
}
|
}
|
}
|
}
|
}
|
|
.signature-upload {
|
.signature-upload-area {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
height: 200rpx;
|
border: 2rpx dashed #c0c4cc;
|
border-radius: 12rpx;
|
background: #fafafa;
|
transition: all 0.3s;
|
|
&:active {
|
background: #f0f2f5;
|
border-color: #007aff;
|
}
|
|
.upload-hint {
|
font-size: 28rpx;
|
color: #606266;
|
margin-top: 16rpx;
|
margin-bottom: 8rpx;
|
}
|
|
.upload-tip {
|
font-size: 24rpx;
|
color: #909399;
|
}
|
}
|
}
|
|
.signature-disabled {
|
.signature-disabled-area {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
height: 200rpx;
|
border: 2rpx solid #e4e7ed;
|
border-radius: 12rpx;
|
background: #f8f9fa;
|
|
.disabled-hint {
|
font-size: 28rpx;
|
color: #909399;
|
margin-top: 16rpx;
|
margin-bottom: 8rpx;
|
}
|
|
.disabled-tip {
|
font-size: 24rpx;
|
color: #c0c4cc;
|
}
|
}
|
}
|
}
|
|
// 签名模态框样式
|
.signature-modal {
|
padding: 30rpx;
|
background: #fff;
|
border-radius: 20rpx 20rpx 0 0;
|
max-height: 80vh;
|
overflow: hidden;
|
|
.modal-header {
|
text-align: center;
|
margin-bottom: 30rpx;
|
|
.modal-title {
|
font-size: 32rpx;
|
font-weight: 600;
|
color: #303133;
|
display: block;
|
margin-bottom: 8rpx;
|
}
|
|
.modal-subtitle {
|
font-size: 24rpx;
|
color: #909399;
|
}
|
}
|
|
.signature-canvas-container {
|
.signature-canvas {
|
display: block;
|
margin: 0 auto;
|
border: 2rpx solid #e4e7ed;
|
border-radius: 8rpx;
|
background: #fff;
|
touch-action: none;
|
}
|
|
.canvas-actions {
|
display: flex;
|
gap: 20rpx;
|
margin: 20rpx 0;
|
padding: 0 20rpx;
|
|
.action-btn {
|
flex: 1;
|
height: 60rpx;
|
border-radius: 30rpx;
|
border: 1rpx solid #dcdfe6;
|
background: #fff;
|
font-size: 24rpx;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
gap: 8rpx;
|
|
&.clear-btn {
|
color: #f56c6c;
|
border-color: #f56c6c;
|
}
|
|
&.redo-btn {
|
color: #1890ff;
|
border-color: #1890ff;
|
|
&:disabled {
|
color: #c0c4cc;
|
border-color: #e4e7ed;
|
}
|
}
|
|
&.confirm-btn {
|
background: linear-gradient(135deg, #0f95b0, #89c4c1);
|
color: #fff;
|
border: none;
|
|
&:disabled {
|
background: #c0c4cc;
|
opacity: 0.6;
|
}
|
}
|
|
&:active:not(:disabled) {
|
transform: scale(0.98);
|
}
|
}
|
}
|
|
.signature-preview {
|
margin-top: 20rpx;
|
padding: 20rpx;
|
background: #f8f9fa;
|
border-radius: 8rpx;
|
|
.preview-title {
|
display: block;
|
font-size: 24rpx;
|
color: #606266;
|
margin-bottom: 12rpx;
|
}
|
|
.preview-image {
|
width: 200rpx;
|
height: 80rpx;
|
border: 1rpx solid #dcdfe6;
|
border-radius: 4rpx;
|
background: #fff;
|
}
|
}
|
}
|
}
|
|
/* 响应式设计 */
|
@media (max-width: 768px) {
|
.expert-review-page {
|
padding: 20rpx;
|
padding-bottom: 120rpx;
|
|
.review-overview .compact-info-grid {
|
grid-template-columns: 1fr;
|
}
|
|
.action-bar-compact {
|
padding: 16rpx;
|
gap: 16rpx;
|
|
.action-btn {
|
height: 72rpx;
|
font-size: 26rpx;
|
}
|
}
|
}
|
|
.signature-modal {
|
padding: 20rpx;
|
|
.signature-canvas-container {
|
.signature-canvas {
|
width: 100% !important;
|
height: 250rpx !important;
|
}
|
|
.canvas-actions {
|
// flex-direction: column;
|
gap: 12rpx;
|
padding: 0;
|
|
.action-btn {
|
height: 72rpx;
|
}
|
}
|
}
|
}
|
}
|
</style>
|