spring
2025-11-19 af4f45eaa2703ecf991bd10f07f6df179f2677d9
src/pages/routingInspection/upload.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,587 @@
<template>
  <view class="attachment-container">
    <!-- å¤´éƒ¨æ“ä½œåŒº -->
    <view class="header-actions">
      <wd-button
        icon="file-add"
        :round="false"
        size="small"
        custom-class="add_btn"
        @click="addAttachment"
        v-if="isEdit"
      >
        æ–°å¢ž
      </wd-button>
    </view>
    <!-- é™„件列表 -->
    <view class="attachment-list">
      <wd-status-tip
        v-if="attachmentList.length === 0"
        image="content"
        tip="暂无附件"
        custom-class="status-tip-full"
      />
      <view v-for="(item, index) in attachmentList" :key="item.id || index" class="attachment-card">
        <view class="media-wrapper" @click="previewAttachment(item)">
          <!-- å›¾ç‰‡é¢„览 -->
          <template v-if="isImageType(item.url)">
            <image
              :src="getFullUrl(item.url)"
              mode="aspectFill"
              class="media-preview"
              style="width: 100%; height: 100%"
              @error="onImageError(item, index)"
              @load="onImageLoad(item, index)"
              :show-menu-by-longpress="true"
            />
            <!-- åŠ è½½ä¸­é®ç½© -->
            <view v-if="item.loading" class="loading-mask">
              <text class="loading-text">加载中...</text>
            </view>
            <!-- å›¾ç‰‡åŠ è½½å¤±è´¥æ˜¾ç¤ºé»˜è®¤å›¾æ ‡ -->
            <view v-if="item.loadError" class="file-icon-wrapper error-overlay">
              <wd-icon name="picture" size="48px" color="#ccc" />
              <text class="file-name error-text">加载失败</text>
            </view>
          </template>
          <!-- è§†é¢‘预览 -->
          <template v-else-if="isVideoType(item.url)">
            <video
              :src="getFullUrl(item.url)"
              class="media-preview"
              :controls="false"
              :show-center-play-btn="true"
              @error="onVideoError(item, index)"
              object-fit="cover"
            />
            <!-- è§†é¢‘加载失败显示默认图标 -->
            <view v-if="item.loadError" class="file-icon-wrapper error-overlay">
              <wd-icon name="video" size="48px" color="#ccc" />
              <text class="file-name error-text">加载失败</text>
            </view>
          </template>
          <!-- å…¶ä»–文件类型显示图标 -->
          <view v-else class="file-icon-wrapper">
            <wd-icon name="file-outline" size="48px" color="#999" />
            <text class="file-name">文件</text>
          </view>
          <!-- åˆ é™¤æŒ‰é’® -->
          <view class="delete-btn" @click.stop="deleteAttachment(item.id)" v-if="isEdit">
            <wd-icon name="delete" color="#fff" size="20px" />
          </view>
        </view>
      </view>
    </view>
    <wd-toast />
  </view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useToast } from "wot-design-uni";
import AttachmentAPI from "@/api/product/attachment";
// H5 ä½¿ç”¨ VITE_APP_BASE_API ä½œä¸ºä»£ç†è·¯å¾„,其他平台使用 VITE_APP_API_URL ä½œä¸ºè¯·æ±‚路径
let baseUrlValue = import.meta.env.VITE_APP_API_URL || "";
// #ifdef H5
baseUrlValue = import.meta.env.VITE_APP_BASE_API || "";
// #endif
const baseUrl = ref(baseUrlValue); // ä½¿ç”¨ref使其在模板中可访问
// å¤–部参数
const props = defineProps({
  detailData: { type: Object, default: () => ({}) },
  isEdit: { type: Boolean, default: false },
  deviceType: { type: String, default: "" },
});
const toast = useToast();
// èŽ·å–åˆå§‹æ•°æ®
const getInitialData = () => {
  // å¤„理不同的数据结构
  let data = props.detailData;
  // å¦‚果是 ref å¯¹è±¡ï¼ŒèŽ·å–å…¶ value
  if (data && typeof data === "object" && "value" in data) {
    data = data.value;
  }
  // å¦‚果是数组,直接返回
  if (Array.isArray(data)) {
    return data.map((item) => ({
      ...item,
      loading: false,
      loadError: false,
    }));
  }
  // å¦‚果有 files å±žæ€§
  if (data && data.files) {
    const files = Array.isArray(data.files) ? data.files : [];
    return files.map((item) => ({
      ...item,
      loading: false,
      loadError: false,
    }));
  }
  return [];
};
const attachmentList = ref<any[]>(getInitialData());
const attachmentIds = ref<string[]>(attachmentList.value.map((item: any) => item.id) || []);
// ç›‘听 props.detailData å˜åŒ–
watch(
  () => props.detailData,
  (newVal) => {
    const newData = getInitialData();
    if (newData.length > 0) {
      attachmentList.value = newData.map((item) => ({
        ...item,
        loading: false,
        loadError: false,
      }));
      attachmentIds.value = newData.map((item: any) => item.id);
    }
  },
  { deep: true, immediate: false }
);
// èŽ·å–å®Œæ•´çš„å›¾ç‰‡/视频 URL
const getFullUrl = (url: string) => {
  if (!url) return "";
  // å¦‚果已经是完整的 URL(http æˆ– https å¼€å¤´ï¼‰ï¼Œç›´æŽ¥è¿”回
  if (url.startsWith("http://") || url.startsWith("https://")) {
    return url;
  }
  // æ£€æŸ¥ baseUrl æ˜¯å¦æœ‰æ•ˆ
  if (!baseUrl.value) {
    console.error("❌ baseUrl未配置,url:", url);
    return url;
  }
  // å¦‚果是相对路径,拼接基础 URL
  const separator = url.startsWith("/") || baseUrl.value.endsWith("/") ? "" : "/";
  return `${baseUrl.value}${separator}${url}`;
};
// å›¾ç‰‡åŠ è½½æˆåŠŸ
const onImageLoad = (item: any, index: number) => {
  item.loading = false;
  item.loadError = false;
  attachmentList.value = [...attachmentList.value];
};
// å›¾ç‰‡åŠ è½½å¤±è´¥
const onImageError = (item: any, index: number) => {
  console.error(`图片加载失败 [${index}]:`, item.url);
  item.loading = false;
  item.loadError = true;
  attachmentList.value = [...attachmentList.value];
};
// è§†é¢‘加载失败
const onVideoError = (item: any, index: number) => {
  console.error(`视频加载失败 [${index}]:`, item.url);
  item.loading = false;
  item.loadError = true;
  attachmentList.value = [...attachmentList.value];
};
// æ–°å¢žé™„ä»¶
const addAttachment = () => {
  // æ˜¾ç¤ºé€‰æ‹©æ–‡ä»¶ç±»åž‹çš„弹窗
  uni.showActionSheet({
    itemList: ["选择图片", /* "选择视频", */ "拍照" /* , "录像" */],
    success: (res) => {
      switch (res.tapIndex) {
        case 0: // é€‰æ‹©å›¾ç‰‡
          chooseImages();
          break;
        // case 1: // é€‰æ‹©è§†é¢‘
        //   chooseVideos();
        //   break;
        case 1: // æ‹ç…§
          takePhoto();
          break;
        // case 3: // å½•像
        //   recordVideo();
        //   break;
      }
    },
    fail: (error) => {
      console.error("选择文件类型失败:", error);
      toast.show("选择文件类型失败");
    },
  });
};
// é€‰æ‹©å›¾ç‰‡
const chooseImages = () => {
  uni.chooseImage({
    count: 9,
    sizeType: ["original", "compressed"],
    sourceType: ["album"],
    success: async (res) => {
      const filePaths = Array.isArray(res.tempFilePaths) ? res.tempFilePaths : [res.tempFilePaths];
      await handleFileUpload(filePaths);
    },
    fail: (error) => {
      console.error("选择图片失败:", error);
      toast.show("选择图片失败");
    },
  });
};
// é€‰æ‹©è§†é¢‘
const chooseVideos = () => {
  uni.chooseVideo({
    sourceType: ["album"],
    maxDuration: 60,
    camera: "back",
    success: async (res) => {
      await handleFileUpload([res.tempFilePath]);
    },
    fail: (error) => {
      console.error("选择视频失败:", error);
      toast.show("选择视频失败");
    },
  });
};
// æ‹ç…§
const takePhoto = () => {
  uni.chooseImage({
    count: 1,
    sizeType: ["original", "compressed"],
    sourceType: ["camera"],
    success: async (res) => {
      const filePaths = Array.isArray(res.tempFilePaths) ? res.tempFilePaths : [res.tempFilePaths];
      await handleFileUpload(filePaths);
    },
    fail: (error) => {
      console.error("拍照失败:", error);
      toast.show("拍照失败");
    },
  });
};
// å½•像
const recordVideo = () => {
  uni.chooseVideo({
    sourceType: ["camera"],
    maxDuration: 60,
    camera: "back",
    success: async (res) => {
      await handleFileUpload([res.tempFilePath]);
    },
    fail: (error) => {
      console.error("录像失败:", error);
      toast.show("录像失败");
    },
  });
};
// å¤„理文件上传
const handleFileUpload = async (filePaths: string[]) => {
  try {
    toast.show("正在上传...");
    // ä¸Šä¼ æ–‡ä»¶
    const uploadResults: any = await AttachmentAPI.uploadAttachmentFiles(filePaths);
    const result = uploadResults.map((it: any) => {
      return it.data;
    });
    // æ›´æ–°é™„件列表
    const flattenedResult = result.flat();
    attachmentList.value.push(...flattenedResult);
    // æå–附件ID
    attachmentIds.value = attachmentList.value.map((item: any) => item.id);
    toast.show("上传成功");
  } catch (error) {
    console.error("上传失败:", error);
    toast.show("上传失败");
  }
};
// åˆ é™¤é™„ä»¶
const deleteAttachment = async (aid: number) => {
  try {
    uni.showModal({
      title: "确认删除",
      content: "确定要删除这个附件吗?",
      success: async (res) => {
        if (res.confirm) {
          // å‰ç«¯æ‰‹åŠ¨åˆ é™¤ï¼šç›´æŽ¥ä»Žåˆ—è¡¨ä¸­ç§»é™¤è¿™æ¡æ•°æ®
          attachmentList.value = attachmentList.value.filter((item) => item.id !== aid);
          // èŽ·å–å‰©ä½™çš„é™„ä»¶ID
          attachmentIds.value = attachmentList.value.map((item) => item.id);
          toast.show("删除成功");
        }
      },
    });
  } catch (error) {
    console.error("删除失败:", error);
    toast.show("删除失败");
  }
};
// é¢„览附件
const previewAttachment = (item: any) => {
  // æ ¹æ®æ–‡ä»¶ç±»åž‹è¿›è¡Œé¢„览
  const fileType = getFileType(item.url);
  const fullUrl = getFullUrl(item.url);
  if (fileType.startsWith("image")) {
    // å›¾ç‰‡é¢„览
    uni.previewImage({
      urls: [fullUrl],
      current: fullUrl,
    });
  } else {
    // å…¶ä»–文件类型,可以下载或打开
    uni.downloadFile({
      url: fullUrl,
      success: (res) => {
        uni.openDocument({
          filePath: res.tempFilePath,
          success: () => {
            // æ‰“开文档成功
          },
          fail: (error) => {
            console.error("打开文档失败:", error);
            toast.show("无法预览此文件类型");
          },
        });
      },
      fail: (error) => {
        console.error("下载文件失败:", error);
        toast.show("下载文件失败");
      },
    });
  }
};
// ä»Ž URL æˆ–文件名中提取扩展名
const getExtension = (urlOrFileName: string) => {
  if (!urlOrFileName) return "";
  // ç§»é™¤æŸ¥è¯¢å‚数和哈希
  const cleanUrl = urlOrFileName.split("?")[0].split("#")[0];
  // èŽ·å–æœ€åŽä¸€ä¸ªç‚¹åŽé¢çš„å†…å®¹
  const extension = cleanUrl.split(".").pop()?.toLowerCase();
  return extension || "";
};
// åˆ¤æ–­æ˜¯å¦ä¸ºå›¾ç‰‡ç±»åž‹
const isImageType = (urlOrFileName: string) => {
  const extension = getExtension(urlOrFileName);
  return ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(extension);
};
// åˆ¤æ–­æ˜¯å¦ä¸ºè§†é¢‘类型
const isVideoType = (urlOrFileName: string) => {
  const extension = getExtension(urlOrFileName);
  return ["mp4", "mov", "avi", "wmv", "flv", "mkv", "webm"].includes(extension);
};
// èŽ·å–æ–‡ä»¶ç±»åž‹
const getFileType = (urlOrFileName: string) => {
  if (!urlOrFileName) return "unknown";
  const extension = getExtension(urlOrFileName);
  switch (extension) {
    case "jpg":
    case "jpeg":
    case "png":
    case "gif":
    case "bmp":
    case "webp":
      return "image";
    case "mp4":
    case "mov":
    case "avi":
    case "wmv":
    case "flv":
    case "mkv":
    case "webm":
      return "video";
    case "pdf":
      return "pdf";
    case "doc":
    case "docx":
      return "word";
    case "xls":
    case "xlsx":
      return "excel";
    case "ppt":
    case "pptx":
      return "powerpoint";
    case "txt":
      return "text";
    case "zip":
    case "rar":
      return "archive";
    default:
      return "file";
  }
};
// æ ¼å¼åŒ–文件大小
const formatFileSize = (size: number) => {
  if (size < 1024) return size + " B";
  if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KB";
  return (size / (1024 * 1024)).toFixed(1) + " MB";
};
// æ ¼å¼åŒ–æ—¶é—´
const formatTime = (time: string) => {
  const date = new Date(time);
  return date.toLocaleString();
};
// å¯¹å¤–暴露方法:获取所有需提交的文件
const getSubmitFiles = () => ({
  newFiles: attachmentIds.value || [],
});
defineExpose({ getSubmitFiles });
</script>
<style lang="scss" scoped>
.attachment-container {
  padding: 12px;
  background: #f3f9f8;
  min-height: 100vh;
}
.header-actions {
  margin-bottom: 12px;
  :deep(.add_btn) {
    background: #0d867f;
    color: white;
    border: none;
  }
}
.attachment-list {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  :deep(.status-tip-full) {
    grid-column: 1 / -1;
    width: 100%;
  }
  .attachment-card {
    width: 100%;
    position: relative;
    // ä½¿ç”¨ padding-top å®žçŽ°æ­£æ–¹å½¢ï¼ˆå…¼å®¹æ€§æ›´å¥½ï¼‰
    &::before {
      content: "";
      display: block;
      padding-top: 100%; // é«˜åº¦ç­‰äºŽå®½åº¦
    }
  }
}
.media-wrapper {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  border-radius: 8px;
  overflow: hidden;
  background: #f5f5f5;
  .media-preview {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }
  .loading-mask {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.3);
    z-index: 5;
    .loading-text {
      font-size: 12px;
      color: #fff;
    }
  }
  .file-icon-wrapper {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    padding: 8px;
    text-align: center;
    .file-name {
      margin-top: 8px;
      font-size: 12px;
      color: #666;
      word-break: break-all;
      display: -webkit-box;
      -webkit-line-clamp: 2;
      line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
      &.error-text {
        color: #ff4757;
      }
    }
    &.error-overlay {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      background: rgba(255, 255, 255, 0.9);
      z-index: 5;
    }
  }
  .delete-btn {
    position: absolute;
    top: 4px;
    right: 4px;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 10;
  }
}
</style>