<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>
|