WXL
5 天以前 2140c5aae2032673cef7055df9738b9bacf37816
pagesSub/ethicalReview/ethicalInfo.vue
@@ -28,7 +28,7 @@
          <up-icon name="man" size="14" color="#909399" />
          <text class="compact-label">性别/年龄</text>
          <text class="compact-value"
            >{{ caseInfo.sex || "--" }}/{{ caseInfo.age || "--"
            >{{ caseInfo.sex == 1 ? '男' : caseInfo.sex == 2 ? '女' : '未知' }}/{{ caseInfo.age || "--"
            }}{{ caseInfo.ageunit || "岁" }}</text
          >
        </view>
@@ -213,69 +213,75 @@
      <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>
    <!-- 全屏签名弹框(Canvas 2D 同步绘制,解决断点与 iOS 兼容问题) -->
    <view v-if="showSignaturePanel" class="fullscreen-signature">
      <!-- 顶部栏 -->
      <view class="fs-header">
        <text class="fs-header-btn fs-cancel" @tap="closeSignaturePanel">取消</text>
        <text class="fs-title">手写签名</text>
        <text class="fs-header-btn fs-clear" @tap="clearCanvas">清空</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="fs-body">
        <!-- H5 端:canvas 由 JS 原生创建 + 原生事件绑定(绕开组件编译层的坐标差异,根治笔迹错位) -->
        <!-- #ifdef H5 -->
        <view id="h5CanvasWrap" ref="h5CanvasWrap" class="fs-canvas-wrap"></view>
        <!-- #endif -->
          <!-- 操作按钮 -->
          <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>
        <!-- 小程序/App 端:canvas 2d 组件 -->
        <!-- #ifndef H5 -->
        <canvas
          id="signatureCanvas"
          canvas-id="signatureCanvas"
          type="2d"
          class="fs-canvas"
          @touchstart="onTouchStart"
          @touchmove="onTouchMove"
          @touchend="onTouchEnd"
          @touchcancel="onTouchEnd"
          disable-scroll
        ></canvas>
        <!-- #endif -->
          <!-- 签名预览 -->
          <view v-if="tempSignatureData" class="signature-preview">
            <text class="preview-title">签名预览</text>
            <image
              :src="tempSignatureData"
              mode="aspectFit"
              class="preview-image"
            />
          </view>
        <view v-if="!hasSignature" class="fs-hint" @tap.stop>
          <up-icon name="edit-pen" size="44" color="#c0c4cc" />
          <text>请在此区域签名</text>
        </view>
      </view>
    </u-popup>
      <!-- 笔触粗细选择 -->
      <view class="fs-toolbar">
        <view
          class="toolbar-item"
          v-for="s in strokeSizes"
          :key="s.value"
          @tap="strokeWidth = s.value"
        >
          <view
            class="size-dot"
            :style="{
              width: s.value * 2 + 'px',
              height: s.value * 2 + 'px',
              background: strokeWidth == s.value ? '#0f95b0' : '#c0c4cc',
            }"
          ></view>
          <text class="size-label" :class="{ active: strokeWidth == s.value }">{{
            s.label
          }}</text>
        </view>
      </view>
      <!-- 底部操作栏 -->
      <view class="fs-footer">
        <button class="fs-btn fs-undo" :disabled="!canUndo" @tap="undoLastStroke">
          撤销
        </button>
        <button class="fs-btn fs-confirm" :disabled="!hasSignature" @tap="confirmSignature">
          确认签名
        </button>
      </view>
    </view>
    <view v-if="showHomeButton" class="home-fab" @click="goHome">
      <uni-icons type="home" size="24" color="#fff" />
@@ -290,15 +296,28 @@
      @confirm="confirmSubmit"
      @cancel="showSubmitModal = false"
    ></u-modal>
    <!-- H5 内嵌 PDF 预览组件 -->
    <!-- #ifdef H5 -->
    <pdf-preview
      :show="pdfPreview.show"
      :url="pdfPreview.url"
      :title="pdfPreview.title"
      @close="pdfPreview.show = false"
    />
    <!-- #endif -->
  </view>
</template>
<script setup>
import { ref, computed, reactive, onMounted, nextTick } from "vue";
import { ref, computed, reactive, onMounted, nextTick, getCurrentInstance } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import dayjs from "dayjs";
import { useUserStore } from "@/stores/user";
import { useHomeButton } from "@/stores/useHomeButton";
// #ifdef H5
import PdfPreview from "@/components/pdf-preview/index.vue";
// #endif
const { showHomeButton, checkAutoLogin, goHome } = useHomeButton();
const userStore = useUserStore();
@@ -318,27 +337,22 @@
const fcid = ref(null);
const materials = ref([]);
// ==================== 新增:根据环境获取 baseUrlHt ====================
// PDF 预览(H5 端内嵌 pdf.js 组件预览)
const pdfPreview = reactive({ show: false, url: "", title: "" });
// ============== 新增:根据环境获取 baseUrlHt ==============
const getBaseUrlHt = () => {
  // #ifdef H5
  // H5 环境:根据浏览器地址动态拼接
  // H5 环境:文件统一走「同源」地址(window.location.origin),由部署该页面的 nginx 反向代理
  // /profile/ 到后端。这样文件与页面同源,浏览器不会触发 CORS(彻底规避后端重复
  // Access-Control-Allow-Origin 头导致的跨域报错,也规避了页面在 :8091、文件在 :443 的跨域)。
  try {
    const href = window.location.href;
    // 规则 1:192.168.120.27:8091 → 120.27 + :9095
    if (href.startsWith('http://192.168.120.27:8091')) {
      return 'http://192.168.120.27:9095';
    }
    // 规则 2:192.168.76.25:8091 → 76.25 + :9095
    if (href.startsWith('http://192.168.76.25:8091')) {
      return 'http://192.168.76.25:9095';
    }
    // 规则 3:其余全部走生产
    return 'https://opo.qduh.cn';
    return (
      window.location.origin ||
      `${window.location.protocol}//${window.location.host}`
    );
  } catch {
    return 'https://opo.qduh.cn';
    return "https://opo.qduh.cn";
  }
  // #endif
@@ -376,8 +390,10 @@
});
const conclusionOptions = ref([
  { label: "同意", value: "1" }, // 对应receiveStatus 5-完成
  { label: "驳回", value: "2" }, // 对应receiveStatus 6-驳回
  { label: "不同意", value: "0" },
  { label: "同意", value: "1" },
  { label: "弃权", value: "3" },
  { label: "修改后同意", value: "4" },
]);
// 签名相关数据
@@ -389,17 +405,26 @@
  serverData: null,
});
// 手写签名相关
const canvasWidth = 650;
const canvasHeight = 300;
let ctx = null;
// 手写签名相关(Canvas 2D 全屏版)
let ctx = null; // Canvas 2D 渲染上下文(同步绘制)
let canvasNode = null; // canvas 节点(用于导出图片)
let canvasCssWidth = 0; // 画布 CSS 宽度
let canvasCssHeight = 0; // 画布 CSS 高度
let canvasRect = { left: 0, top: 0 }; // 画布相对视口的位置(逻辑像素),用于触摸坐标换算
let strokes = ref([]); // 所有笔画(每笔为 { points: [...], width: n },用于撤销)
let currentStroke = []; // 当前正在绘制的点数组
let currentStrokeWidth = 4; // 当前笔触粗细(px)
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 strokeSizes = [
  { label: "细", value: 2 },
  { label: "中", value: 4 },
  { label: "粗", value: 6 },
];
const strokeWidth = ref(4); // 当前选中的粗细(对新笔画生效)
const hasSignature = ref(false);
const canUndo = computed(() => strokes.value.length > 0);
// 上传配置
const uploadConfig = reactive({
@@ -455,12 +480,11 @@
  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路径
  );
@@ -525,7 +549,11 @@
      console.log(caseInfo.value.receiveStatus, "caseInfo.value");
      // 设置表单数据
      if (data.expertconclusion) {
      if (
        data.expertconclusion !== null &&
        data.expertconclusion !== undefined &&
        data.expertconclusion !== ""
      ) {
        form.value.expertconclusion = data.expertconclusion.toString();
      }
      if (data.expertopinion) {
@@ -588,7 +616,7 @@
  }
};
// ==================== 新增:获取完整URL(平台安全版) ====================
// ============== 新增:获取完整URL(平台安全版) ==============
const getFullUrl = (path) => {
  if (!path) return '';
  
@@ -627,7 +655,7 @@
  return `${baseUrlHt}${path.startsWith('/') ? '' : '/'}${path}`;
};
// ==================== 修改:replaceUrlDomain 使用 getFullUrl ====================
// ============== 修改:replaceUrlDomain 使用 getFullUrl ==============
const replaceUrlDomain = (url) => {
  return getFullUrl(url);
};
@@ -757,7 +785,7 @@
  }
};
// 打开签名面板
// 打开签名面板(全屏弹框)
const openSignaturePanel = () => {
  if (isReadonly.value) {
    uni.showToast({
@@ -768,132 +796,435 @@
  }
  showSignaturePanel.value = true;
  // 每次打开都重置签名状态,重新初始化画布
  strokes.value = [];
  currentStroke = [];
  hasSignature.value = false;
  isDrawing = false;
  ctx = null;
  canvasNode = null;
  // 等待全屏弹框 DOM 渲染完成后再初始化画布
  nextTick(() => {
    initCanvas();
    setTimeout(() => {
      initCanvas();
    }, 100);
  });
};
// 关闭签名面板
const closeSignaturePanel = () => {
  showSignaturePanel.value = false;
  clearCanvas();
  // #ifdef H5
  // 移除原生事件监听,避免重复绑定
  if (canvasNode) {
    canvasNode.removeEventListener("touchstart", onH5TouchStart);
    canvasNode.removeEventListener("touchmove", onH5TouchMove);
    canvasNode.removeEventListener("touchend", onH5TouchEnd);
    canvasNode.removeEventListener("touchcancel", onH5TouchEnd);
    canvasNode.removeEventListener("mousedown", onH5MouseDown);
    canvasNode.removeEventListener("mousemove", onH5MouseMove);
    canvasNode.removeEventListener("mouseup", onH5MouseUp);
  }
  // #endif
  ctx = null;
  canvasNode = null;
};
// 初始化画布
// 初始化画布(Canvas 2D 同步 API)
const initCanvas = () => {
  ctx = uni.createCanvasContext("signatureCanvas");
  clearCanvas();
  // #ifdef H5
  initH5Canvas();
  // #endif
  // #ifndef H5
  initMPCanvas();
  // #endif
};
// 清空画布
const clearCanvas = () => {
  if (!ctx) return;
// #ifdef H5
// ============== H5 端:原生 canvas 方案 ==============
// 不用 uni-app 的 canvas 组件(其组件层对 type="2d" 的节点/坐标处理各版本不一致),
// 而是用 JS 动态创建原生 canvas + 原生事件绑定,坐标换算完全受控,从根上消除笔迹偏移。
const h5CanvasWrap = ref(null);
  ctx.clearRect(0, 0, canvasWidth, canvasHeight);
  ctx.setStrokeStyle("#000000");
  ctx.setLineWidth(3);
  ctx.setLineCap("round");
  ctx.setLineJoin("round");
  ctx.draw();
  strokeHistory = [];
  tempSignatureData.value = "";
};
const initH5Canvas = () => {
  // 拿容器真实 DOM:uni-app H5 里 <view> 编译成 uni-view 组件,ref 拿到的是组件实例,
  // 必须用 getElementById 或 .$el 才能拿到真实 DOM,否则 appendChild 会报错、canvas 创建不出来。
  let wrap = document.getElementById("h5CanvasWrap");
  if (!wrap && h5CanvasWrap.value) {
    wrap = h5CanvasWrap.value.$el || h5CanvasWrap.value;
  }
  if (!wrap) {
    console.error("[签名] H5 画布容器获取失败(id=h5CanvasWrap 未找到)");
    return;
  }
  // 清空容器,避免重复初始化产生多个 canvas
  wrap.innerHTML = "";
  const canvas = document.createElement("canvas");
  canvas.style.display = "block";
  canvas.style.background = "#ffffff";
  canvas.style.touchAction = "none";
  wrap.appendChild(canvas);
// 撤销上一步
const undoLastStroke = () => {
  if (strokeHistory.length == 0) return;
  // 读取容器实际渲染尺寸并让 canvas 撑满(不依赖百分比,避免 flex 容器中解析异常导致尺寸为 0)
  const fit = () => {
    const wr = wrap.getBoundingClientRect();
    if (!wr.width || !wr.height) return false;
    canvas.style.width = wr.width + "px";
    canvas.style.height = wr.height + "px";
    return true;
  };
  // 移除最后一步
  strokeHistory.pop();
  const bind = () => {
    try {
      const rect = canvas.getBoundingClientRect();
      canvasNode = canvas;
      canvasCssWidth = rect.width;
      canvasCssHeight = rect.height;
      canvasRect = { left: rect.left, top: rect.top };
      const dpr = window.devicePixelRatio || 2;
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx = canvas.getContext("2d");
      ctx.scale(dpr, dpr);
      console.log("[签名] H5 画布初始化", {
        cssW: canvasCssWidth,
        cssH: canvasCssHeight,
        dpr,
        rect: canvasRect,
      });
      redraw();
  // 重新绘制
  ctx.clearRect(0, 0, canvasWidth, canvasHeight);
  ctx.setStrokeStyle("#000000");
  ctx.setLineWidth(3);
  ctx.setLineCap("round");
  ctx.setLineJoin("round");
      // 触摸事件(手机/平板)
      canvas.addEventListener("touchstart", onH5TouchStart, { passive: false });
      canvas.addEventListener("touchmove", onH5TouchMove, { passive: false });
      canvas.addEventListener("touchend", onH5TouchEnd);
      canvas.addEventListener("touchcancel", onH5TouchEnd);
      // 鼠标事件(PC 浏览器调试)
      canvas.addEventListener("mousedown", onH5MouseDown);
      canvas.addEventListener("mousemove", onH5MouseMove);
      canvas.addEventListener("mouseup", onH5MouseUp);
    } catch (err) {
      console.error("[签名] H5 画布初始化失败:", err);
    }
  };
  // 绘制历史笔迹
  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 = "";
  // 布局可能尚未稳定(弹框刚显示),尺寸为 0 时重试几次
  if (fit()) {
    bind();
  } else {
    getCanvasImage();
    let tries = 0;
    const timer = setInterval(() => {
      tries++;
      if (fit() || tries >= 10) {
        clearInterval(timer);
        if (fit()) {
          bind();
        } else {
          console.error("[签名] H5 画布容器尺寸始终为 0,初始化失败");
        }
      }
    }, 50);
  }
};
// 触摸开始
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);
// H5 触摸坐标:实时取 getBoundingClientRect,与触摸视口坐标同源换算
let lastH5TouchEndTime = 0; // 记录触摸结束时间,用于屏蔽触摸后浏览器派发的兼容 mouse 事件
const onH5TouchStart = (e) => {
  if (!ctx || isReadonly.value) return;
  const t = e.touches[0];
  if (!t) return;
  const rect = canvasNode.getBoundingClientRect();
  beginStroke({ x: t.clientX - rect.left, y: t.clientY - rect.top });
};
// 触摸移动
const onTouchMove = (e) => {
  if (!isDrawing || isReadonly.value) return;
const onH5TouchMove = (e) => {
  e.preventDefault();
  if (!isDrawing || !ctx) return;
  const t = e.touches[0];
  if (!t) return;
  const rect = canvasNode.getBoundingClientRect();
  continueStroke({ x: t.clientX - rect.left, y: t.clientY - rect.top });
};
  const touch = e.touches[0];
  const currentX = touch.x;
  const currentY = touch.y;
const onH5TouchEnd = () => {
  lastH5TouchEndTime = Date.now();
  endStroke();
};
  ctx.lineTo(currentX, currentY);
  ctx.stroke();
  ctx.draw(true);
// H5 鼠标事件(PC 调试用)
let h5MouseDown = false;
const onH5MouseDown = (e) => {
  // 触摸结束后 500ms 内的兼容 mouse 事件直接忽略,避免平板/手机上画多余的点
  if (Date.now() - lastH5TouchEndTime < 500) return;
  if (!ctx || isReadonly.value) return;
  h5MouseDown = true;
  const rect = canvasNode.getBoundingClientRect();
  beginStroke({ x: e.clientX - rect.left, y: e.clientY - rect.top });
};
  // 保存笔迹到历史记录
  strokeHistory.push({
    startX: lastX,
    startY: lastY,
    endX: currentX,
    endY: currentY,
const onH5MouseMove = (e) => {
  if (!h5MouseDown || !isDrawing || !ctx) return;
  const rect = canvasNode.getBoundingClientRect();
  continueStroke({ x: e.clientX - rect.left, y: e.clientY - rect.top });
};
const onH5MouseUp = () => {
  h5MouseDown = false;
  endStroke();
};
// #endif
// #ifndef H5
// ============== 小程序/App 端:canvas 2d ==============
// 一次 fields({ node, rect }) 查询同时拿到 node 和「逻辑像素」位置/尺寸,
// 避免 node 与 boundingClientRect 两次查询之间布局变化导致基准错位。
const initMPCanvas = () => {
  const instance = getCurrentInstance().proxy;
  const query = uni.createSelectorQuery().in(instance);
  query
    .select("#signatureCanvas")
    .fields({ node: true, rect: true })
    .exec((res) => {
      const node = res && res[0] && res[0].node;
      const rect = res && res[0] && res[0].rect;
      if (!node) {
        console.error("[签名] 画布节点获取失败:", res);
        uni.showToast({ title: "签名初始化失败,请重试", icon: "none" });
        return;
      }
      if (rect && rect.width > 0) {
        // 一次查询同时拿到节点和位置(逻辑像素),避免两次查询间布局变化导致基准错位
        canvasRect = { left: rect.left, top: rect.top };
        setupCanvas(node, rect.width, rect.height);
        return;
      }
      // 部分基础库 fields 拿不到 rect,兜底单独查一次 boundingClientRect
      uni
        .createSelectorQuery()
        .in(instance)
        .select("#signatureCanvas")
        .boundingClientRect((r) => {
          if (r && r.width > 0) {
            canvasRect = { left: r.left, top: r.top };
            setupCanvas(node, r.width, r.height);
          } else {
            console.error("[签名] 画布尺寸获取失败:", r);
            uni.showToast({ title: "签名初始化失败,请重试", icon: "none" });
          }
        })
        .exec();
    });
};
// 每次落笔前刷新画布基准位置,防御页面滚动/布局变化导致的偏移
const refreshCanvasRect = () => {
  uni
    .createSelectorQuery()
    .in(getCurrentInstance().proxy)
    .select("#signatureCanvas")
    .boundingClientRect((rect) => {
      if (rect && rect.left !== undefined) {
        canvasRect = { left: rect.left, top: rect.top };
      }
    })
    .exec();
};
// #endif
// 配置画布(设置物理像素,防止模糊)
const setupCanvas = (node, cssW, cssH) => {
  canvasNode = node;
  canvasCssWidth = cssW;
  canvasCssHeight = cssH;
  const dpr = uni.getSystemInfoSync().pixelRatio || 2;
  node.width = cssW * dpr;
  node.height = cssH * dpr;
  ctx = node.getContext("2d");
  ctx.scale(dpr, dpr);
  // 调试用:打印画布尺寸,排查签名偏移(同时可据此判断浏览器跑的是不是最新包)
  console.log("[签名] 画布初始化", {
    cssW,
    cssH,
    dpr,
    bufferW: node.width,
    bufferH: node.height,
  });
  redraw();
};
  lastX = currentX;
  lastY = currentY;
// 重绘所有笔画(Canvas 2D 同步 API,无丢帧问题)
const redraw = () => {
  if (!ctx) return;
  // 白色背景(导出图片时即为白底黑字);先 clearRect 彻底清空再铺白底,确保撤销/清空生效
  ctx.clearRect(0, 0, canvasCssWidth, canvasCssHeight);
  ctx.fillStyle = "#ffffff";
  ctx.fillRect(0, 0, canvasCssWidth, canvasCssHeight);
  // 线条通用样式
  ctx.strokeStyle = "#000000";
  ctx.lineCap = "round";
  ctx.lineJoin = "round";
  // 重绘每一笔(每笔记录自己的粗细,切换粗细不影响已画笔画)
  strokes.value.forEach((stroke) => {
    const points = stroke.points;
    ctx.lineWidth = stroke.width;
    if (points.length == 1) {
      drawDot(points[0], stroke.width);
    } else if (points.length > 1) {
      ctx.beginPath();
      ctx.moveTo(points[0].x, points[0].y);
      for (let i = 1; i < points.length; i++) {
        ctx.lineTo(points[i].x, points[i].y);
      }
      ctx.stroke();
    }
  });
};
// 画一个圆点(处理单击不出墨的情况,半径随笔触粗细)
const drawDot = (p, w) => {
  const r = Math.max(1, (w || 3) / 2);
  ctx.beginPath();
  ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
  ctx.fillStyle = "#000000";
  ctx.fill();
};
// 获取触摸点相对画布的坐标(小程序/App 端:视口坐标 - 画布位置)
// #ifndef H5
const getTouchPos = (e) => {
  const t = e.touches[0] || e.changedTouches[0];
  if (!t) return { x: 0, y: 0 };
  const cx = typeof t.clientX == "number" ? t.clientX : t.pageX;
  const cy = typeof t.clientY == "number" ? t.clientY : t.pageY;
  return { x: cx - canvasRect.left, y: cy - canvasRect.top };
};
// #endif
// ============== 共用绘制逻辑(H5 原生事件与小程序组件事件共用) ==============
// 落笔
const beginStroke = (pos) => {
  if (!ctx || isReadonly.value) return;
  isDrawing = true;
  currentStroke = [pos];
  currentStrokeWidth = strokeWidth.value; // 记录本笔的粗细
  strokes.value.push({ points: currentStroke, width: currentStrokeWidth });
  hasSignature.value = true;
  ctx.lineWidth = currentStrokeWidth;
  drawDot(pos, currentStrokeWidth);
};
// 续笔
const continueStroke = (pos) => {
  if (!isDrawing || !ctx) return;
  const prev = currentStroke[currentStroke.length - 1];
  if (prev) {
    ctx.beginPath();
    ctx.lineWidth = currentStrokeWidth;
    ctx.moveTo(prev.x, prev.y);
    ctx.lineTo(pos.x, pos.y);
    ctx.stroke();
  }
  currentStroke.push(pos);
};
// 收笔
const endStroke = () => {
  if (!isDrawing) return;
  isDrawing = false;
  currentStroke = [];
};
// 触摸开始(小程序/App 端组件事件)
// #ifndef H5
const onTouchStart = (e) => {
  if (!ctx || isReadonly.value) return;
  // 落笔前异步刷新画布基准位置,防御页面滚动/布局变化导致的偏移
  refreshCanvasRect();
  const pos = getTouchPos(e);
  beginStroke(pos);
};
// 触摸移动(Canvas 2D 同步绘制,线条实时连续)
const onTouchMove = (e) => {
  if (!isDrawing || !ctx) return;
  const pos = getTouchPos(e);
  continueStroke(pos);
};
// 触摸结束
const onTouchEnd = () => {
  if (!isDrawing) return;
  endStroke();
};
// #endif
  isDrawing = false;
  ctx.closePath();
  // 更新预览
  getCanvasImage();
// 清空画布
const clearCanvas = () => {
  strokes.value = [];
  currentStroke = [];
  hasSignature.value = false;
  redraw();
};
// 获取画布图片
// 撤销上一笔
const undoLastStroke = () => {
  if (strokes.value.length == 0) return;
  strokes.value.pop();
  hasSignature.value = strokes.value.length > 0;
  redraw();
};
// 生成签名图片(Promise 形式,返回临时文件路径)
const getCanvasImage = () => {
  uni.canvasToTempFilePath({
    canvasId: "signatureCanvas",
    success: (res) => {
      tempSignatureData.value = res.tempFilePath;
    },
    fail: (err) => {
      console.error("获取画布图片失败:", err);
    },
  return new Promise((resolve, reject) => {
    if (!canvasNode) {
      reject(new Error("画布未初始化"));
      return;
    }
    // #ifdef MP-WEIXIN
    uni.canvasToTempFilePath({
      canvas: canvasNode,
      fileType: "png",
      success: (res) => resolve(res.tempFilePath),
      fail: (err) => {
        console.error("获取画布图片失败:", err);
        reject(err);
      },
    });
    // #endif
    // #ifndef MP-WEIXIN
    try {
      canvasNode.toBlob(
        (blob) => {
          if (blob) {
            // H5 端:生成带 .png 扩展名的 File 对象,上传时才能保留图片格式
            if (typeof File !== "undefined") {
              const file = new File([blob], `signature_${Date.now()}.png`, {
                type: "image/png",
              });
              resolve(file);
            } else {
              resolve(URL.createObjectURL(blob));
            }
          } else {
            reject(new Error("生成图片失败"));
          }
        },
        "image/png",
      );
    } catch (err) {
      reject(err);
    }
    // #endif
  });
};
// 确认签名
const confirmSignature = async () => {
  if (!tempSignatureData.value) {
  if (!hasSignature.value) {
    uni.showToast({
      title: "请先签名",
      icon: "none",
@@ -904,8 +1235,11 @@
  try {
    uni.showLoading({ title: "保存签名中..." });
    // 生成签名图片
    const filePath = await getCanvasImage();
    // 上传签名文件
    const uploadResult = await uploadSignature(tempSignatureData.value);
    const uploadResult = await uploadSignature(filePath);
    if (uploadResult) {
      // 使用 getFullUrl 处理签名URL
@@ -925,7 +1259,8 @@
      });
      showSignaturePanel.value = false;
      clearCanvas();
      ctx = null;
      canvasNode = null;
    } else {
      uni.hideLoading();
      uni.showToast({
@@ -953,6 +1288,53 @@
    const expertName =
      userInfo?.nickName || userInfo?.userName || expertInfo.value.name;
    // 统一解析上传成功的响应
    const parseUploadData = (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 || "上传失败"));
      }
    };
    // #ifdef H5
    // H5 端 filePath 为带 .png 扩展名的 File 对象,用 FormData 手动上传以保留图片格式
    if (typeof File !== "undefined" && filePath instanceof File) {
      const formData = new FormData();
      formData.append("file", filePath, filePath.name);
      formData.append("bizType", "expert_review_signature");
      formData.append("caseNo", caseInfo.value.caseNo || "");
      formData.append("expertName", expertName);
      formData.append("uploadType", "signature");
      const xhr = new XMLHttpRequest();
      xhr.open("POST", "/api/common/upload");
      xhr.setRequestHeader("Authorization", `Bearer ${token}`);
      xhr.onload = () => {
        if (xhr.status === 200) {
          try {
            parseUploadData(JSON.parse(xhr.responseText));
          } catch (e) {
            reject(new Error("上传响应解析失败"));
          }
        } else {
          reject(new Error(`上传失败,状态码: ${xhr.status}`));
        }
      };
      xhr.onerror = () => reject(new Error("上传失败"));
      xhr.send(formData);
      return;
    }
    // #endif
    uni.uploadFile({
      url: "/api/common/upload",
      filePath: filePath,
@@ -970,20 +1352,7 @@
      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 || "上传失败"));
          }
          parseUploadData(data);
        } else {
          reject(new Error(`上传失败,状态码: ${res.statusCode}`));
        }
@@ -1038,7 +1407,7 @@
  });
};
// ==================== 修改:预览签名 ====================
// ============== 修改:预览签名 ==============
const previewSignature = () => {
  if (signatureData.signatureUrl) {
    // 使用 getFullUrl 处理URL
@@ -1060,7 +1429,7 @@
  }
};
// ==================== 修改:预览材料 ====================
// ============== 修改:预览材料 ==============
const previewMaterial = (material) => {
  if (material.url) {
    uni.showLoading({ title: "加载中..." });
@@ -1069,7 +1438,21 @@
      material.type || material.url.split(".").pop().toLowerCase();
    // 使用 getFullUrl 处理URL
    const fileUrl = getFullUrl(material.url);
    let fileUrl = getFullUrl(material.url);
    // URL 编码(处理中文文件名,避免双重编码)
    try {
      const urlObj = new URL(fileUrl);
      // decodeURI 先解码已有的编码,encodeURI 重新编码,确保只编码一次
      fileUrl = urlObj.origin + encodeURI(decodeURI(urlObj.pathname)) + (urlObj.search || "");
    } catch {
      // 降级
      try {
        fileUrl = encodeURI(decodeURI(fileUrl));
      } catch {
        fileUrl = encodeURI(fileUrl);
      }
    }
    // 判断文件类型
    if (["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(fileExt)) {
@@ -1096,9 +1479,35 @@
        fileExt,
      )
    ) {
      // 文档文件下载后打开
      // 文档文件处理
      // #ifdef H5
      // H5 环境:直接用浏览器打开,支持 PDF 在线预览
      uni.hideLoading();
      if (fileExt == "pdf") {
        // PDF 用 pdf.js 组件内嵌预览,避免手机浏览器直接触发下载
        pdfPreview.url = fileUrl;
        pdfPreview.title = material.name || "PDF 预览";
        pdfPreview.show = true;
      } else {
        // 其他文档用 <a> 标签触发下载
        const link = document.createElement("a");
        link.href = fileUrl;
        link.download = material.name || material.url.split("/").pop();
        link.target = "_blank";
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
      }
      // #endif
      // #ifdef MP-WEIXIN
      // 小程序环境:下载后用 openDocument 打开
      const token = uni.getStorageSync("token") || "";
      uni.downloadFile({
        url: fileUrl,
        header: {
          Authorization: token ? `Bearer ${token}` : "",
        },
        success: (res) => {
          if (res.statusCode == 200) {
            const filePath = res.tempFilePath;
@@ -1117,19 +1526,28 @@
                });
              },
            });
          } else {
            console.error("下载状态码异常:", res.statusCode);
            uni.showToast({
              title: `服务器返回异常(${res.statusCode})`,
              icon: "none",
            });
          }
        },
        fail: (err) => {
          console.error("下载文件失败:", err);
          console.error("下载URL:", fileUrl);
          uni.showToast({
            title: "文件下载失败",
            title: "文件下载失败,请检查网络或域名配置",
            icon: "none",
            duration: 3000,
          });
        },
        complete: () => {
          uni.hideLoading();
        },
      });
      // #endif
    } else {
      // 其他文件类型
      uni.showToast({
@@ -1236,8 +1654,11 @@
      expertopinion: form.value.expertopinion,
      sigin: signatureData.signatureUrl,
      expertType: caseInfo.value.expertType,
      // 根据结论设置状态
      receiveStatus: form.value.expertconclusion == "1" ? "5" : "6", // 5-完成, 6-驳回
      // 根据结论设置状态:同意/修改后同意 → 完成(5),不同意/弃权 → 驳回(6)
      receiveStatus:
        form.value.expertconclusion == "1" || form.value.expertconclusion == "4"
          ? "5"
          : "6",
      conclusiontime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    };
@@ -1289,14 +1710,8 @@
    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) {
@@ -1796,111 +2211,172 @@
  }
}
// 签名模态框样式
.signature-modal {
  padding: 30rpx;
  background: #fff;
  border-radius: 20rpx 20rpx 0 0;
  max-height: 80vh;
  overflow: hidden;
// 全屏签名弹框样式
.fullscreen-signature {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 10080;
  background: #f5f7fa;
  display: flex;
  flex-direction: column;
  // 适配刘海屏/状态栏
  padding-top: constant(safe-area-inset-top);
  padding-top: env(safe-area-inset-top);
  .modal-header {
    text-align: center;
    margin-bottom: 30rpx;
  .fs-header {
    height: 88rpx;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 30rpx;
    background: #fff;
    border-bottom: 1rpx solid #ebeef5;
    .modal-title {
    .fs-title {
      font-size: 32rpx;
      font-weight: 600;
      color: #303133;
      display: block;
      margin-bottom: 8rpx;
    }
    .modal-subtitle {
      font-size: 24rpx;
      color: #909399;
    .fs-header-btn {
      font-size: 28rpx;
      padding: 10rpx 20rpx;
      &.fs-cancel {
        color: #606266;
      }
      &.fs-clear {
        color: #f56c6c;
      }
    }
  }
  .signature-canvas-container {
    .signature-canvas {
  .fs-body {
    flex: 1;
    position: relative;
    margin: 20rpx;
    border-radius: 12rpx;
    overflow: hidden;
    box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
    .fs-canvas {
      width: 100%;
      height: 100%;
      display: block;
      margin: 0 auto;
      border: 2rpx solid #e4e7ed;
      border-radius: 8rpx;
      background: #fff;
      // H5 端防止触摸时页面滚动
      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);
        }
      }
    // H5 端原生 canvas 容器(canvas 由 JS 创建并撑满)
    .fs-canvas-wrap {
      width: 100%;
      height: 100%;
      background: #fff;
    }
    .signature-preview {
      margin-top: 20rpx;
      padding: 20rpx;
      background: #f8f9fa;
      border-radius: 8rpx;
    .fs-hint {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 16rpx;
      color: #c0c4cc;
      font-size: 28rpx;
      pointer-events: none;
    }
  }
      .preview-title {
        display: block;
        font-size: 24rpx;
        color: #606266;
        margin-bottom: 12rpx;
  // 笔触粗细选择栏
  .fs-toolbar {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 48rpx;
    padding: 16rpx 30rpx;
    background: #fff;
    border-top: 1rpx solid #f0f2f5;
    .toolbar-item {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 8rpx;
      padding: 8rpx 16rpx;
      .size-dot {
        border-radius: 50%;
        transition: background 0.2s;
      }
      .preview-image {
        width: 200rpx;
        height: 80rpx;
        border: 1rpx solid #dcdfe6;
        border-radius: 4rpx;
      .size-label {
        font-size: 22rpx;
        color: #909399;
        &.active {
          color: #0f95b0;
          font-weight: 600;
        }
      }
      &:active {
        opacity: 0.7;
      }
    }
  }
  .fs-footer {
    display: flex;
    gap: 24rpx;
    padding: 20rpx 30rpx;
    padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
    padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
    background: #fff;
    border-top: 1rpx solid #ebeef5;
    .fs-btn {
      flex: 1;
      height: 88rpx;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 30rpx;
      border-radius: 44rpx;
      margin: 0;
      line-height: 1;
      &.fs-undo {
        color: #1890ff;
        background: #fff;
        border: 1rpx solid #1890ff;
        &:disabled {
          color: #c0c4cc;
          border-color: #e4e7ed;
          background: #f5f7fa;
        }
      }
      &.fs-confirm {
        background: linear-gradient(135deg, #0f95b0, #89c4c1);
        color: #fff;
        border: none;
        &:disabled {
          background: #c0c4cc;
          opacity: 0.6;
        }
      }
      &::after {
        border: none;
      }
    }
  }
@@ -1927,25 +2403,14 @@
    }
  }
  .signature-modal {
    padding: 20rpx;
  .fullscreen-signature {
    .fs-body {
      margin: 12rpx;
    }
    .signature-canvas-container {
      .signature-canvas {
        width: 100% !important;
        height: 250rpx !important;
      }
      .canvas-actions {
        // flex-direction: column;
        gap: 12rpx;
        padding: 0;
        .action-btn {
          height: 72rpx;
        }
      }
    .fs-footer {
      gap: 16rpx;
    }
  }
}
</style>
</style>