From 6ff219555c53c37d4daae0747751043ea103af1e Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期二, 28 四月 2026 11:32:39 +0800
Subject: [PATCH] 水印签到拜访照片

---
 src/utils/filePreview.js                           |   67 +++++
 src/pages/cooperativeOffice/clientVisit/detail.vue |  161 ++++++++++++
 src/components/AlbumImageUpload/index.vue          |  527 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 752 insertions(+), 3 deletions(-)

diff --git a/src/components/AlbumImageUpload/index.vue b/src/components/AlbumImageUpload/index.vue
new file mode 100644
index 0000000..e30231f
--- /dev/null
+++ b/src/components/AlbumImageUpload/index.vue
@@ -0,0 +1,527 @@
+<template>
+  <view class="album-image-upload">
+    <view class="actions" v-if="!readonly">
+      <u-button
+        type="primary"
+        size="small"
+        :loading="uploading"
+        :disabled="uploading || innerList.length >= limit"
+        @click="chooseFromAlbum"
+      >
+        {{ buttonText }}
+      </u-button>
+      <view class="tip" v-if="tipText">{{ tipText }}</view>
+    </view>
+
+    <view v-if="innerList.length" class="list">
+      <view
+        v-for="(img, idx) in innerList"
+        :key="img.id || img.url || idx"
+        class="item"
+      >
+        <image
+          class="img"
+          :src="img.previewUrl || img.url"
+          mode="aspectFill"
+          @click="preview(idx)"
+        />
+        <view v-if="!readonly" class="del" @click.stop="remove(idx)">脳</view>
+      </view>
+    </view>
+
+    <!-- 闅愯棌鐢诲竷锛氱敤浜庡浘鐗囧姞姘村嵃 -->
+    <canvas
+      v-if="enableWatermark"
+      :canvas-id="canvasId"
+      :id="canvasId"
+      :style="{
+        position: 'absolute',
+        left: '-9999px',
+        top: '-9999px',
+        width: canvasWidth + 'px',
+        height: canvasHeight + 'px'
+      }"
+      :width="canvasWidth"
+      :height="canvasHeight"
+    />
+  </view>
+</template>
+
+<script setup>
+import { computed, getCurrentInstance, ref, watch } from "vue";
+import config from "@/config.js";
+import { getToken } from "@/utils/auth";
+import { normalizeFileUrl } from "@/utils/filePreview";
+
+const props = defineProps({
+  modelValue: { type: Array, default: () => [] },
+  // 鏈�澶у紶鏁�
+  limit: { type: Number, default: 6 },
+  // 涓婁紶鎺ュ彛 path锛堜細鎷兼帴 config.baseUrl锛�
+  action: { type: String, default: "/invoiceLedger/upload" },
+  // 涓婁紶瀛楁鍚�
+  name: { type: String, default: "file" },
+  // 浠呯浉鍐岋紙鍥哄畾锛夛紝鏆撮湶鍑烘潵鏂逛究鏈潵鎵╁睍
+  sourceType: { type: Array, default: () => ["album"] },
+  // 閫夋嫨鍘嬬缉/鍘熷浘
+  sizeType: { type: Array, default: () => ["compressed", "original"] },
+  // 鏂囨
+  buttonText: { type: String, default: "浠庣浉鍐岄�夋嫨" },
+  tipText: { type: String, default: "浠呮敮鎸佷粠鐩稿唽閫夋嫨" },
+  // 鍙
+  readonly: { type: Boolean, default: false },
+
+  // 姘村嵃
+  enableWatermark: { type: Boolean, default: true },
+  watermarkText: { type: [String, Function], default: "" },
+
+  // 涓婁紶鍓嶄笟鍔℃牎楠�/鎷︽埅锛氳繑鍥� false 鐩存帴鎷︽埅
+  beforeUpload: { type: Function, default: null },
+});
+
+const emit = defineEmits(["update:modelValue", "change", "error"]);
+
+const instance = getCurrentInstance();
+const uploading = ref(false);
+const innerList = ref([]);
+
+watch(
+  () => props.modelValue,
+  val => {
+    innerList.value = Array.isArray(val) ? val.filter(v => v?.url) : [];
+  },
+  { immediate: true, deep: true }
+);
+
+const canvasId = `album_wm_canvas_${Date.now()}_${Math.random()
+  .toString(16)
+  .slice(2)}`;
+const canvasWidth = ref(1);
+const canvasHeight = ref(1);
+
+const uploadUrl = computed(() => {
+  const base = config.baseUrl || "";
+  return base + props.action;
+});
+
+const resolveWatermarkText = () => {
+  try {
+    return typeof props.watermarkText === "function"
+      ? props.watermarkText()
+      : props.watermarkText;
+  } catch (e) {
+    return "";
+  }
+};
+
+const normalizeUploadResultToFile = data => {
+  if (!data) return null;
+  if (Array.isArray(data)) return normalizeUploadResultToFile(data[0]);
+  if (typeof data === "string") {
+    const url = normalizeFileUrl(data);
+    return url ? { url, downloadUrl: url } : null;
+  }
+  if (typeof data === "object") {
+    const url = normalizeFileUrl(
+      data.url || data.downloadUrl || data.tempPath || data.path
+    );
+    if (url) return { ...data, url, downloadUrl: url };
+    return null;
+  }
+  return null;
+};
+
+const addWatermarkByBrowserCanvas = (tempFilePath, text) => {
+  return new Promise((resolve, reject) => {
+    try {
+      const wmText = String(text || "").trim();
+      if (!wmText) return resolve(tempFilePath);
+
+      const img = new Image();
+      img.onload = () => {
+        try {
+          const w = img.naturalWidth || img.width || 1;
+          const h = img.naturalHeight || img.height || 1;
+          const canvas = document.createElement("canvas");
+          canvas.width = w;
+          canvas.height = h;
+          const ctx = canvas.getContext("2d");
+          if (!ctx) return resolve(tempFilePath);
+
+          ctx.drawImage(img, 0, 0, w, h);
+
+          const lines = wmText.split(/\r?\n/).filter(Boolean);
+          const fontSize = Math.max(8, Math.floor(Math.min(w, h) * 0.014));
+          const padding = Math.max(3, Math.floor(fontSize * 0.35));
+          const lineGap = Math.max(1, Math.floor(fontSize * 0.1));
+          const edgeGap = 0;
+
+          ctx.font = `${fontSize}px sans-serif`;
+          const maxChars = Math.max(...lines.map(t => (t || "").length), 0);
+          const approxTextWidth = Math.min(
+            w * 0.55,
+            Math.max(fontSize * maxChars * 0.5, fontSize * 4)
+          );
+          const blockW = approxTextWidth + padding * 2;
+          const blockH =
+            lines.length * fontSize + (lines.length - 1) * lineGap + padding * 2;
+          const blockX = Math.max(0, w - blockW - edgeGap);
+          const blockY = Math.max(0, h - blockH - edgeGap);
+
+          ctx.fillStyle = "rgba(0,0,0,0.2)";
+          ctx.fillRect(blockX, blockY, blockW, blockH);
+          ctx.fillStyle = "rgba(255,255,255,0.95)";
+          lines.forEach((t, idx) => {
+            const textWidth = ctx.measureText(t).width;
+            const x = Math.max(blockX + padding, blockX + blockW - padding - textWidth);
+            const y = blockY + padding + fontSize + idx * (fontSize + lineGap);
+            ctx.fillText(t, x, y);
+          });
+
+          canvas.toBlob(
+            blob => {
+              if (!blob) return resolve(tempFilePath);
+              resolve(URL.createObjectURL(blob));
+            },
+            "image/jpeg",
+            0.92
+          );
+        } catch (e) {
+          resolve(tempFilePath);
+        }
+      };
+      img.onerror = () => resolve(tempFilePath);
+      img.src = tempFilePath;
+    } catch (e) {
+      reject(e);
+    }
+  });
+};
+
+const addWatermarkToImage = (tempFilePath, text) => {
+  return new Promise((resolve, reject) => {
+    if (!tempFilePath) return reject(new Error("鍥剧墖璺緞涓嶅瓨鍦�"));
+    const wmText = String(text || "").trim();
+    if (!props.enableWatermark || !wmText) return resolve(tempFilePath);
+    // H5 绔紭鍏堜娇鐢ㄦ祻瑙堝櫒鍘熺敓 canvas锛岄伩鍏� uni canvas 鐢熸垚绌虹櫧鍥�
+    if (typeof window !== "undefined" && typeof document !== "undefined") {
+      addWatermarkByBrowserCanvas(tempFilePath, wmText)
+        .then(resolve)
+        .catch(() => resolve(tempFilePath));
+      return;
+    }
+
+    uni.getImageInfo({
+      src: tempFilePath,
+      success: info => {
+        try {
+          const w = info.width || 1;
+          const h = info.height || 1;
+          canvasWidth.value = w;
+          canvasHeight.value = h;
+
+          const ctx = uni.createCanvasContext(canvasId, instance);
+          ctx.drawImage(tempFilePath, 0, 0, w, h);
+
+          const lines = wmText.split(/\r?\n/).filter(Boolean);
+          const fontSize = Math.max(8, Math.floor(Math.min(w, h) * 0.014));
+          const padding = Math.max(3, Math.floor(fontSize * 0.35));
+          const lineGap = Math.max(1, Math.floor(fontSize * 0.1));
+          const edgeGap = 0;
+
+          ctx.setFontSize(fontSize);
+          ctx.setFillStyle("rgba(0,0,0,0.2)");
+
+          const maxChars = Math.max(...lines.map(t => (t || "").length), 0);
+          const approxTextWidth = Math.min(
+            w * 0.55,
+            Math.max(fontSize * maxChars * 0.5, fontSize * 4)
+          );
+          const blockW = approxTextWidth + padding * 2;
+          const blockH =
+            lines.length * fontSize + (lines.length - 1) * lineGap + padding * 2;
+          const blockX = Math.max(0, w - blockW - edgeGap);
+          const blockY = Math.max(0, h - blockH - edgeGap);
+
+          ctx.fillRect(blockX, blockY, blockW, blockH);
+          ctx.setFillStyle("rgba(255,255,255,0.95)");
+          lines.forEach((t, idx) => {
+            const textWidth = fontSize * String(t || "").length * 0.55;
+            const x = Math.max(blockX + padding, blockX + blockW - padding - textWidth);
+            const y = blockY + padding + fontSize + idx * (fontSize + lineGap);
+            ctx.fillText(t, x, y);
+          });
+
+          ctx.draw(false, () => {
+            uni.canvasToTempFilePath(
+              {
+                canvasId,
+                width: w,
+                height: h,
+                destWidth: w,
+                destHeight: h,
+                fileType: "jpg",
+                quality: 0.92,
+                success: r => resolve(r.tempFilePath),
+                fail: err => reject(err),
+              },
+              instance
+            );
+          });
+        } catch (e) {
+          reject(e);
+        }
+      },
+      fail: err => reject(err),
+    });
+  });
+};
+
+const uploadOneByH5FormData = (filePath, extraFormData = {}) => {
+  return new Promise(async (resolve, reject) => {
+    try {
+      const resp = await fetch(filePath);
+      const blob = await resp.blob();
+      const formData = new FormData();
+      const explicitName = String(
+        extraFormData?.fileName || extraFormData?.originalName || ""
+      ).trim();
+      const pathExt = getFileExtFromPath(filePath);
+      const mimeExt = String(blob?.type || "")
+        .toLowerCase()
+        .split("/")
+        .pop();
+      const uploadFileName =
+        explicitName || `file_${Date.now()}.${pathExt || mimeExt || "jpg"}`;
+      formData.append(props.name, blob, uploadFileName);
+
+      Object.keys(extraFormData || {}).forEach(key => {
+        if (key === "fileName" || key === "originalName") return;
+        const val = extraFormData[key];
+        if (val === undefined || val === null) return;
+        formData.append(key, String(val));
+      });
+
+      const xhr = new XMLHttpRequest();
+      xhr.open("POST", uploadUrl.value, true);
+      xhr.setRequestHeader("Authorization", "Bearer " + getToken());
+      xhr.onload = () => {
+        try {
+          const body = JSON.parse(xhr.responseText || "{}");
+          if (body.code === 200) {
+            const file = normalizeUploadResultToFile(body.data);
+            if (!file) return reject(new Error("涓婁紶鎴愬姛浣嗘湭杩斿洖鏂囦欢淇℃伅"));
+            resolve(file);
+          } else {
+            reject(new Error(body.msg || "涓婁紶澶辫触"));
+          }
+        } catch (e) {
+          reject(e);
+        }
+      };
+      xhr.onerror = () => reject(new Error("涓婁紶澶辫触"));
+      xhr.send(formData);
+    } catch (e) {
+      reject(e);
+    }
+  });
+};
+
+const uploadOne = (filePath, extraFormData = {}) => {
+  const isH5BlobPath =
+    typeof window !== "undefined" &&
+    typeof FormData !== "undefined" &&
+    String(filePath || "").startsWith("blob:");
+  if (isH5BlobPath) {
+    return uploadOneByH5FormData(filePath, extraFormData);
+  }
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: uploadUrl.value,
+      filePath,
+      name: props.name,
+      formData: extraFormData,
+      header: { Authorization: "Bearer " + getToken() },
+      success: res => {
+        try {
+          const body = JSON.parse(res.data || "{}");
+          if (body.code === 200) {
+            const file = normalizeUploadResultToFile(body.data);
+            if (!file) return reject(new Error("涓婁紶鎴愬姛浣嗘湭杩斿洖鏂囦欢淇℃伅"));
+            resolve(file);
+          } else {
+            reject(new Error(body.msg || "涓婁紶澶辫触"));
+          }
+        } catch (e) {
+          reject(e);
+        }
+      },
+      fail: err => reject(err),
+    });
+  });
+};
+
+const getFileExtFromPath = filePath => {
+  const raw = String(filePath || "").split("?")[0].split("#")[0];
+  const match = raw.match(/\.([a-zA-Z0-9]+)$/);
+  return (match?.[1] || "").toLowerCase();
+};
+
+const getFileNameFromPath = filePath => {
+  const raw = String(filePath || "").split("?")[0].split("#")[0];
+  const seg = raw.split("/").pop() || "";
+  return seg.split("\\").pop() || "";
+};
+
+const buildUploadMeta = (originalPath, rawOriginalName = "") => {
+  const cleanOriginalName = String(rawOriginalName || "").trim();
+  const pathName = getFileNameFromPath(originalPath);
+  const fallbackName = cleanOriginalName || pathName;
+  const ext =
+    getFileExtFromPath(fallbackName) || getFileExtFromPath(originalPath) || "jpg";
+  const fileName = fallbackName || `${Date.now()}.${ext}`;
+  return {
+    ext,
+    fileName,
+    formData: { fileName },
+  };
+};
+
+const resolvePreviewPath = (wmPath, originPath) => {
+  return new Promise(resolve => {
+    if (!wmPath) return resolve(originPath || "");
+    uni.getImageInfo({
+      src: wmPath,
+      success: () => resolve(wmPath),
+      fail: () => resolve(originPath || wmPath || ""),
+    });
+  });
+};
+
+const emitList = list => {
+  emit("update:modelValue", list);
+  emit("change", list);
+};
+
+const chooseFromAlbum = () => {
+  if (props.readonly) return;
+  if (typeof props.beforeUpload === "function") {
+    const ok = props.beforeUpload();
+    if (ok === false) return;
+  }
+
+  const remaining = Math.max(0, props.limit - innerList.value.length);
+  if (remaining <= 0) {
+    uni.showToast({ title: `鏈�澶氬彧鑳戒笂浼�${props.limit}寮燻, icon: "none" });
+    return;
+  }
+
+  uni.chooseImage({
+    count: remaining,
+    sizeType: props.sizeType,
+    sourceType: props.sourceType,
+    success: async res => {
+      const paths = res?.tempFilePaths || [];
+      const tempFiles = Array.isArray(res?.tempFiles) ? res.tempFiles : [];
+      if (!paths.length) return;
+
+      uploading.value = true;
+      uni.showLoading({ title: "姝e湪涓婁紶...", mask: true });
+      try {
+        const wmText = resolveWatermarkText();
+        for (let idx = 0; idx < paths.length; idx++) {
+          const p = paths[idx];
+          const picked = tempFiles[idx] || {};
+          const originalName = picked.name || getFileNameFromPath(p);
+          const wmPath = await addWatermarkToImage(p, wmText);
+          const uploadMeta = buildUploadMeta(p, originalName);
+          const uploaded = await uploadOne(wmPath, uploadMeta.formData);
+          // 淇濈暀鍚庣杩斿洖鍦板潃鐢ㄤ簬鍚庣画鍥炴樉/鎻愪氦锛岀珛鍗抽瑙堜紭鍏堝睍绀哄姞姘村嵃鍚庣殑鏈湴鍥�
+          uploaded.previewUrl = await resolvePreviewPath(wmPath, p);
+          uploaded.originalName = uploaded.originalName || uploadMeta.fileName;
+          uploaded.suffix = uploaded.suffix || uploadMeta.ext;
+          innerList.value.push(uploaded);
+          emitList(innerList.value.slice());
+        }
+        uni.showToast({ title: "涓婁紶鎴愬姛", icon: "none" });
+      } catch (e) {
+        emit("error", e);
+        uni.showToast({ title: e?.message || "涓婁紶澶辫触", icon: "none" });
+      } finally {
+        uploading.value = false;
+        uni.hideLoading();
+      }
+    },
+  });
+};
+
+const remove = idx => {
+  const list = innerList.value.slice();
+  list.splice(idx, 1);
+  innerList.value = list;
+  emitList(list);
+};
+
+const preview = idx => {
+  const urls = innerList.value
+    .map(i => i?.previewUrl || i?.url || i?.downloadUrl)
+    .filter(Boolean);
+  if (!urls.length) return;
+  uni.previewImage({ urls, current: urls[idx] || urls[0] });
+};
+</script>
+
+<style scoped lang="scss">
+.album-image-upload {
+  width: 100%;
+}
+
+.actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 6px 0;
+}
+
+.tip {
+  font-size: 12px;
+  color: #999;
+}
+
+.list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  padding: 8px 0 4px;
+}
+
+.item {
+  position: relative;
+  width: 76px;
+  height: 76px;
+  border-radius: 8px;
+  overflow: hidden;
+  background: #f5f5f5;
+  border: 1px solid #eee;
+}
+
+.img {
+  width: 100%;
+  height: 100%;
+}
+
+.del {
+  position: absolute;
+  right: 4px;
+  top: 4px;
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  background: rgba(0, 0, 0, 0.55);
+  color: #fff;
+  font-size: 14px;
+  line-height: 18px;
+  text-align: center;
+}
+</style>
+
diff --git a/src/pages/cooperativeOffice/clientVisit/detail.vue b/src/pages/cooperativeOffice/clientVisit/detail.vue
index 0bab2d7..5fb3677 100644
--- a/src/pages/cooperativeOffice/clientVisit/detail.vue
+++ b/src/pages/cooperativeOffice/clientVisit/detail.vue
@@ -61,6 +61,18 @@
             </template>
           </u-input>
         </u-form-item>
+        <u-form-item label="鎷滆鐓х墖">
+          <AlbumImageUpload
+            v-model="form.storageBlobDTO"
+            @change="onVisitImageChange"
+            :limit="6"
+            :enableWatermark="true"
+            :watermarkText="getVisitWatermarkText"
+            :beforeUpload="beforeUploadVisitImages"
+            action="/file/upload"
+            tipText=""
+          />
+        </u-form-item>
       </u-cell-group>
       <!-- 澶囨敞淇℃伅 -->
       <u-cell-group title="澶囨敞淇℃伅">
@@ -105,6 +117,9 @@
 
   import { ref, onMounted } from "vue";
   import PageHeader from "@/components/PageHeader.vue";
+  import AlbumImageUpload from "@/components/AlbumImageUpload/index.vue";
+  import { normalizeFileUrl } from "@/utils/filePreview";
+  import config from "@/config.js";
   import {
     clientVisitSignIn,
     clientVisitUpdate,
@@ -128,6 +143,12 @@
     longitude: "",
     locationAddress: "",
     remark: "",
+    // 涓存椂鏂囦欢id鍒楄〃锛堟彁浜ょ敤锛�
+    tempFileIds: [],
+    // 姝e紡鏂囦欢鍒楄〃锛堝洖鏄剧敤锛屽悗绔煡璇細杩斿洖锛�
+    commonFileList: [],
+    // 涓婁紶缁勪欢鍥炰紶鐨勬枃浠跺垪琛紙鍚庣鑻ヤ笉鎺ユ敹鍙拷鐣ワ紱鍏堥殢琛ㄥ崟鎻愪氦锛�
+    storageBlobDTO: [],
   });
 
   // 椤甸潰鐘舵��
@@ -275,6 +296,23 @@
       Object.keys(source).forEach(k => {
         submitData[k] = source[k];
       });
+      // 浠庝笂浼犲垪琛ㄦ彁鍙� tempFileIds锛堝吋瀹� tempId / tempFileId / id锛�
+      if (Array.isArray(submitData.storageBlobDTO)) {
+        submitData.tempFileIds = submitData.storageBlobDTO
+          .map(f => f?.tempId ?? f?.tempFileId ?? f?.id)
+          .filter(v => v !== undefined && v !== null && v !== "");
+      } else {
+        submitData.tempFileIds = Array.isArray(submitData.tempFileIds)
+          ? submitData.tempFileIds
+          : [];
+      }
+      // 鍏煎鍚庣鍙帴鏀� URL 瀛楃涓茬殑鍦烘櫙锛氶澶栬ˉ涓�涓� visitImageUrls
+      if (Array.isArray(submitData.storageBlobDTO)) {
+        submitData.visitImageUrls = submitData.storageBlobDTO
+          .map(f => f?.url)
+          .filter(Boolean)
+          .join(",");
+      }
       console.log("submitData", submitData);
       if (isEdit.value) {
         const { code } = await clientVisitUpdate(submitData);
@@ -305,6 +343,72 @@
     }
   };
   const isEdit = ref(false);
+  const getVisitFileKey = file => {
+    return (
+      file?.tempId ??
+      file?.tempFileId ??
+      file?.id ??
+      file?.url ??
+      file?.downloadUrl ??
+      ""
+    );
+  };
+  const onVisitImageChange = list => {
+    const nextList = Array.isArray(list) ? list.slice() : [];
+    form.value.storageBlobDTO = nextList;
+
+    // 鍚屾鎻愪氦鐢� tempFileIds锛岄伩鍏嶅垹闄ゅ悗浠嶆惡甯︽棫 ID
+    form.value.tempFileIds = nextList
+      .map(f => f?.tempId ?? f?.tempFileId ?? f?.id)
+      .filter(v => v !== undefined && v !== null && v !== "");
+
+    // 缂栬緫鍦烘櫙涓嬪悓姝� commonFileList锛岄伩鍏嶆彁浜ゆ椂娈嬬暀宸插垹闄ゆ枃浠�
+    if (Array.isArray(form.value.commonFileList)) {
+      const keepKeys = new Set(nextList.map(getVisitFileKey).filter(Boolean));
+      form.value.commonFileList = form.value.commonFileList.filter(item =>
+        keepKeys.has(getVisitFileKey(item))
+      );
+    }
+  };
+  const isLikelyPathValue = value => {
+    const v = String(value || "").trim();
+    if (!v) return false;
+    if (/^(https?:)?\/\//i.test(v)) return true;
+    if (/^(blob:|data:|wxfile:|file:|content:)/i.test(v)) return true;
+    if (v.includes("/") || v.includes("\\")) return true;
+    if (/\.[a-zA-Z0-9]{2,8}($|\?)/.test(v)) return true;
+    return false;
+  };
+  const buildVisitFilePreviewUrl = file => {
+    const candidates = [
+      file?.link,
+      file?.url,
+      file?.path,
+      file?.filePath,
+      file?.tempPath,
+      file?.urlFull,
+      file?.downloadUrl,
+    ].filter(Boolean);
+
+    for (const raw of candidates) {
+      if (!isLikelyPathValue(raw)) continue;
+      const normalized = normalizeFileUrl(raw);
+      if (normalized) return normalized;
+    }
+
+    for (const raw of candidates) {
+      const value = String(raw || "").trim();
+      if (!value) continue;
+      // 鍏滃簳璧� common/download锛屽吋瀹规煇浜涘悗绔彧杩斿洖鐩稿瀛樺偍璺緞
+      return (
+        (config.baseUrl || "") +
+        "/common/download?fileName=" +
+        encodeURIComponent(value) +
+        "&delete=false"
+      );
+    }
+    return "";
+  };
   onLoad(() => {
     // 缂栬緫鎷滆鏃讹紝浠庢湰鍦板瓨鍌ㄨ幏鍙栨嫓璁胯褰�
     const visit = uni.getStorageSync("clientVisit");
@@ -312,6 +416,35 @@
       form.value = visit;
       isEdit.value = true;
       console.log("form.value", form.value);
+
+      // 鍥炴樉锛氳嫢鍚庣/鍒楄〃椤靛浜� commonFileList锛屽垯杞垚涓婁紶缁勪欢鍙瘑鍒殑 storageBlobDTO
+      const commonFileList = Array.isArray(visit?.commonFileList)
+        ? visit.commonFileList
+        : [];
+      if (commonFileList.length && (!Array.isArray(visit?.storageBlobDTO) || !visit.storageBlobDTO.length)) {
+        form.value.commonFileList = commonFileList;
+        form.value.storageBlobDTO = commonFileList
+          .map(f => {
+            const previewUrl = buildVisitFilePreviewUrl(f);
+            return {
+              ...f,
+              tempId: f?.tempId ?? f?.tempFileId ?? f?.id,
+              id: f?.id ?? f?.tempId ?? f?.tempFileId,
+              url: previewUrl,
+              downloadUrl: previewUrl,
+            };
+          })
+          .filter(x => x?.url);
+      }
+
+      // 缂栬緫鍦烘櫙锛氬悓姝ヤ竴浠� tempFileIds锛屼究浜庡悗绔渶瑕佹椂鐩存帴浣跨敤
+      if (!Array.isArray(form.value.tempFileIds) || !form.value.tempFileIds.length) {
+        form.value.tempFileIds = Array.isArray(form.value.storageBlobDTO)
+          ? form.value.storageBlobDTO
+              .map(f => f?.tempId ?? f?.tempFileId ?? f?.id)
+              .filter(Boolean)
+          : [];
+      }
     } else {
       isEdit.value = false;
     }
@@ -330,21 +463,43 @@
   onMounted(() => {
     initPageData();
   });
+
+  const beforeUploadVisitImages = () => {
+    const name = (form.value.customerName || "").trim();
+    if (!name) {
+      showToast("璇峰厛濉啓瀹㈡埛鍚嶇О鍚庝笂浼�");
+      return false;
+    }
+    const visitTime = (form.value.purposeDate || "").trim();
+    if (!visitTime) {
+      showToast("璇峰厛閫夋嫨鎷滆鏃堕棿鍚庝笂浼�");
+      return false;
+    }
+    return true;
+  };
+
+  const getVisitWatermarkText = () => {
+    const name = (form.value.customerName || "").trim();
+    // 姘村嵃鏃堕棿鏀逛负鈥滄嫓璁挎椂闂粹��
+    const visitTime = (form.value.purposeDate || "").trim();
+    const time = visitTime || dayjs().format("YYYY-MM-DD HH:mm:ss");
+    return name ? `${name}\n${time}` : `\n${time}`;
+  };
 </script>
 
 <style scoped lang="scss">
   @import "@/static/scss/form-common.scss";
-  .client-visit {
+  .client-visit-detail {
     min-height: 100vh;
     background: #f8f9fa;
-    padding-bottom: 5rem;
+    padding-bottom: calc(5rem + env(safe-area-inset-bottom));
   }
 
   .footer-btns {
     position: fixed;
     left: 0;
     right: 0;
-    bottom: 0;
+    bottom: calc(env(safe-area-inset-bottom) + 8px);
     background: #fff;
     display: flex;
     justify-content: space-around;
diff --git a/src/utils/filePreview.js b/src/utils/filePreview.js
new file mode 100644
index 0000000..a98ee1e
--- /dev/null
+++ b/src/utils/filePreview.js
@@ -0,0 +1,67 @@
+import config from "@/config.js";
+
+// 缁熶竴鏂囦欢棰勮鍦板潃杞崲锛堝榻� inspectionUpload 鐨� normalizeFileUrl 瑙勫垯锛�
+export const normalizeFileUrl = (rawUrl = "") => {
+  let fileUrl = rawUrl || "";
+  if (typeof fileUrl === "string") {
+    fileUrl = fileUrl.trim().replace(/^['"]|['"]$/g, "");
+  }
+  const javaApi = config.fileUrl || config.baseUrl || "";
+  const localPrefixes = ["wxfile://", "file://", "content://", "blob:", "data:"];
+
+  if (localPrefixes.some(prefix => String(fileUrl).startsWith(prefix))) {
+    return fileUrl;
+  }
+
+  if (fileUrl && String(fileUrl).indexOf("\\") > -1) {
+    const lowerPath = String(fileUrl).toLowerCase();
+    const uploadPathIndex = lowerPath.indexOf("uploadpath");
+    const prodIndex = lowerPath.indexOf("\\prod\\");
+    const tempIndex = lowerPath.indexOf("\\temp\\");
+
+    if (uploadPathIndex > -1) {
+      fileUrl = String(fileUrl).substring(uploadPathIndex).replace(/\\/g, "/");
+    } else if (prodIndex > -1) {
+      const relative = String(fileUrl)
+        .substring(prodIndex + "\\prod\\".length)
+        .replace(/\\/g, "/");
+      fileUrl = `/profile/prod/${relative}`;
+    } else if (tempIndex > -1) {
+      const relative = String(fileUrl)
+        .substring(tempIndex + "\\temp\\".length)
+        .replace(/\\/g, "/");
+      fileUrl = `/profile/temp/${relative}`;
+    } else {
+      fileUrl = String(fileUrl).replace(/\\/g, "/");
+    }
+  }
+
+  // /javaWork/.../file/prod/xxx -> /profile/prod/xxx
+  const normalizedLower = String(fileUrl).toLowerCase();
+  const fileProdIdx = normalizedLower.indexOf("/file/prod/");
+  if (fileProdIdx > -1) {
+    fileUrl = `/profile/prod/${String(fileUrl).substring(
+      fileProdIdx + "/file/prod/".length
+    )}`;
+  }
+
+  // /javaWork/.../file/temp/xxx -> /profile/temp/xxx
+  const fileTempIdx = normalizedLower.indexOf("/file/temp/");
+  if (fileTempIdx > -1) {
+    fileUrl = `/profile/temp/${String(fileUrl).substring(
+      fileTempIdx + "/file/temp/".length
+    )}`;
+  }
+
+  if (/^\/?uploadPath/i.test(String(fileUrl))) {
+    fileUrl = String(fileUrl).replace(/^\/?uploadPath/i, "/profile");
+  }
+
+  if (fileUrl && !String(fileUrl).startsWith("http")) {
+    if (!String(fileUrl).startsWith("/")) fileUrl = "/" + fileUrl;
+    fileUrl = javaApi + fileUrl;
+  }
+
+  return fileUrl;
+};
+

--
Gitblit v1.9.3