<template>
|
<view class="camera-upload">
|
<!-- 拍照/拍视频按钮 -->
|
<view v-if="!disabled"
|
class="camera-buttons">
|
<view class="button-row">
|
<u-button type="primary"
|
@click="takePhoto"
|
:loading="uploading"
|
:disabled="fileList.length >= limit"
|
:customStyle="{ marginRight: '10px', flex: 1 }">
|
<u-icon name="camera"
|
size="18"
|
color="#fff"
|
style="margin-right: 5px;"></u-icon>
|
{{ uploading ? '上传中...' : '拍照' }}
|
</u-button>
|
<!-- <u-button
|
type="success"
|
@click="takeVideo"
|
:loading="uploading"
|
:disabled="fileList.length >= limit"
|
:customStyle="{ flex: 1 }"
|
>
|
<u-icon name="video" size="18" color="#fff" style="margin-right: 5px;"></u-icon>
|
{{ uploading ? '上传中...' : '拍视频' }}
|
</u-button> -->
|
</view>
|
</view>
|
<!-- 提示信息 -->
|
<view v-if="showTip && !disabled"
|
class="upload-tip">
|
请使用相机
|
<text v-if="fileSize"
|
class="tip-text">
|
拍摄大小不超过 <text class="tip-highlight">{{ fileSize }}MB</text>
|
</text>
|
的
|
<text class="tip-highlight">照片</text>
|
</view>
|
<!-- 媒体文件列表 -->
|
<view class="media-list">
|
<view v-for="(file, index) in fileList"
|
:key="file.uid || index"
|
class="media-item">
|
<!-- 预览区域 -->
|
<view class="media-preview"
|
@click="previewMedia(file, index)">
|
<image v-if="file.type === 'image'"
|
:src="file.url || file.tempFilePath"
|
class="preview-image"
|
mode="aspectFill"></image>
|
<video v-else-if="file.type === 'video'"
|
:src="file.url || file.tempFilePath"
|
class="preview-video"
|
:controls="false"></video>
|
<view class="media-type-icon">
|
<u-icon :name="file.type === 'image' ? 'photo' : 'video'"
|
size="12"
|
color="#fff"></u-icon>
|
</view>
|
</view>
|
<!-- 操作按钮 -->
|
<view class="media-actions"
|
v-if="!disabled">
|
<u-button type="error"
|
size="mini"
|
@click="handleDelete(index)"
|
:customStyle="{
|
minWidth: '40px',
|
height: '24px',
|
fontSize: '10px',
|
padding: '0 8px'
|
}">
|
删除
|
</u-button>
|
</view>
|
</view>
|
</view>
|
<!-- 上传进度 -->
|
<view v-if="uploading"
|
class="upload-progress">
|
<u-line-progress :percentage="uploadProgress"
|
:showText="true"
|
activeColor="#409eff"></u-line-progress>
|
</view>
|
</view>
|
</template>
|
|
<script setup>
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { getToken } from "@/utils/auth";
|
|
// Props 定义
|
const props = defineProps({
|
modelValue: [String, Object, Array],
|
action: { type: String, default: "/common/minioUploads" },
|
data: { type: Object },
|
limit: { type: Number, default: 5 },
|
fileSize: { type: Number, default: 10 }, // 默认10MB,适合视频
|
fileType: {
|
type: Array,
|
default: () => ["jpg", "jpeg", "png", "mp4", "mov"],
|
},
|
isShowTip: { type: Boolean, default: true },
|
disabled: { type: Boolean, default: false },
|
drag: { type: Boolean, default: false }, // 拍照不需要拖拽
|
statusType: { type: Number, default: "" }, // 用于区分不同状态的上传
|
maxVideoDuration: { type: Number, default: 30 }, // 最大视频时长(秒)
|
});
|
|
// 事件定义
|
const emit = defineEmits(["update:modelValue"]);
|
|
// 响应式数据
|
const number = ref(0);
|
const uploadList = ref([]);
|
const fileList = ref([]);
|
const uploading = ref(false);
|
const uploadProgress = ref(0);
|
|
// 计算属性
|
const uploadFileUrl = computed(() => {
|
// 获取基础API地址,适配uniapp环境
|
let baseUrl = "";
|
|
// 尝试多种方式获取baseUrl
|
if (process.env.VUE_APP_BASE_API) {
|
baseUrl = process.env.VUE_APP_BASE_API;
|
} else if (process.env.NODE_ENV === "development") {
|
baseUrl = "http://192.168.1.147:9036";
|
} else {
|
baseUrl = "http://192.168.1.147:9036";
|
}
|
|
const fullUrl = baseUrl + props.action;
|
return fullUrl;
|
});
|
const headers = computed(() => {
|
const token = getToken();
|
return token ? { Authorization: "Bearer " + token } : {};
|
});
|
const showTip = computed(
|
() => props.isShowTip && (props.fileType || props.fileSize)
|
);
|
// 初始化和编辑初始化方法
|
const init = () => {
|
fileList.value = [];
|
uploadList.value = [];
|
number.value = 0;
|
};
|
|
const editInit = val => {
|
fileList.value = [];
|
val.storageBlobDTO.forEach(element => {
|
// 确保文件数据包含所有必要字段,包括id
|
const fileData = {
|
...element,
|
id: element.id, // 保留服务器返回的id
|
url: element.url || element.downloadUrl,
|
bucketFilename: element.bucketFilename || element.name,
|
downloadUrl: element.downloadUrl || element.url,
|
type:
|
element.type ||
|
(element.url && element.url.includes("video") ? "video" : "image"),
|
name: element.name || element.bucketFilename || `文件_${Date.now()}`,
|
size: element.size || 0,
|
createTime: element.createTime || new Date().getTime(),
|
uid: element.uid || new Date().getTime() + Math.random(),
|
};
|
fileList.value.push(fileData);
|
uploadedSuccessfully();
|
});
|
};
|
|
// 测试服务器连接
|
const testServerConnection = () => {
|
return new Promise(resolve => {
|
uni.request({
|
url: uploadFileUrl.value.replace("/common/minioUploads", "/common/test"),
|
method: "GET",
|
timeout: 5000,
|
success: res => {
|
resolve(true);
|
},
|
fail: err => {
|
resolve(false);
|
},
|
});
|
});
|
};
|
|
// 组件销毁时的清理
|
onUnmounted(() => {
|
// 清理上传状态
|
if (uploading.value) {
|
uploading.value = false;
|
}
|
|
// 隐藏可能显示的加载提示
|
uni.hideLoading();
|
uni.hideToast();
|
});
|
|
// 暴露方法
|
defineExpose({ init, editInit, testServerConnection });
|
|
// 监听 modelValue 变化
|
watch(
|
() => props.modelValue,
|
val => {
|
if (val) {
|
let temp = 1;
|
let list = [];
|
|
if (Array.isArray(val)) {
|
list = val;
|
} else if (typeof val === "string") {
|
list = val.split(",").map(url => ({ url: url.trim() }));
|
}
|
|
fileList.value = list.map(item => {
|
if (typeof item === "string") {
|
item = { name: item, url: item };
|
}
|
// 确保每个文件都有必要的属性,包括id
|
return {
|
...item,
|
id: item.id, // 保留id字段
|
uid: item.uid || new Date().getTime() + temp++,
|
type:
|
item.type ||
|
(item.url && item.url.includes("video") ? "video" : "image"),
|
name: item.name || item.bucketFilename || `文件_${Date.now()}`,
|
size: item.size || 0,
|
createTime: item.createTime || new Date().getTime(),
|
};
|
});
|
} else {
|
fileList.value = [];
|
}
|
},
|
{ deep: true, immediate: true }
|
);
|
|
// 拍照
|
const takePhoto = () => {
|
if (fileList.value.length >= props.limit) {
|
uni.showToast({
|
title: `最多只能拍摄${props.limit}个文件`,
|
icon: "none",
|
});
|
return;
|
}
|
|
uni.chooseImage({
|
count: 1,
|
sizeType: ["compressed", "original"],
|
sourceType: ["camera"],
|
success: res => {
|
try {
|
if (!res.tempFilePaths || res.tempFilePaths.length === 0) {
|
throw new Error("未获取到图片文件");
|
}
|
|
const tempFilePath = res.tempFilePaths[0];
|
const tempFile =
|
res.tempFiles && res.tempFiles[0] ? res.tempFiles[0] : {};
|
|
const file = {
|
tempFilePath: tempFilePath,
|
type: "image",
|
name: `photo_${Date.now()}.jpg`,
|
size: tempFile.size || 0,
|
createTime: new Date().getTime(),
|
uid: Date.now() + Math.random(),
|
};
|
|
handleBeforeUpload(file);
|
} catch (error) {
|
console.error("处理拍照结果失败:", error);
|
uni.showToast({
|
title: "处理图片失败",
|
icon: "error",
|
});
|
}
|
},
|
fail: err => {
|
console.error("拍照失败:", err);
|
uni.showToast({
|
title: "拍照失败: " + (err.errMsg || "未知错误"),
|
icon: "error",
|
});
|
},
|
});
|
};
|
|
// 拍视频
|
const takeVideo = () => {
|
if (fileList.value.length >= props.limit) {
|
uni.showToast({
|
title: `最多只能拍摄${props.limit}个文件`,
|
icon: "none",
|
});
|
return;
|
}
|
|
uni.chooseVideo({
|
sourceType: ["camera"],
|
maxDuration: props.maxVideoDuration,
|
camera: "back",
|
success: res => {
|
try {
|
if (!res.tempFilePath) {
|
throw new Error("未获取到视频文件");
|
}
|
|
const file = {
|
tempFilePath: res.tempFilePath,
|
type: "video",
|
name: `video_${Date.now()}.mp4`,
|
size: res.size || 0,
|
duration: res.duration || 0,
|
createTime: new Date().getTime(),
|
uid: Date.now() + Math.random(),
|
};
|
|
handleBeforeUpload(file);
|
} catch (error) {
|
console.error("处理拍视频结果失败:", error);
|
uni.showToast({
|
title: "处理视频失败",
|
icon: "error",
|
});
|
}
|
},
|
fail: err => {
|
console.error("拍视频失败:", err);
|
uni.showToast({
|
title: "拍视频失败: " + (err.errMsg || "未知错误"),
|
icon: "error",
|
});
|
},
|
});
|
};
|
|
// 文件上传处理
|
const uploadFile = file => {
|
uploading.value = true;
|
uploadProgress.value = 0;
|
number.value++; // 增加上传计数
|
|
// 确保文件路径正确
|
const filePath = file.tempFilePath || file.path;
|
if (!filePath) {
|
handleUploadError("文件路径不存在");
|
return;
|
}
|
|
// 确保token存在
|
const token = getToken();
|
if (!token) {
|
handleUploadError("用户未登录");
|
return;
|
}
|
|
// 准备上传参数
|
const uploadParams = {
|
url: uploadFileUrl.value,
|
filePath: filePath,
|
name: "files",
|
formData: {
|
type: props.statusType || 0,
|
...(props.data || {}),
|
},
|
header: {
|
Authorization: `Bearer ${token}`,
|
},
|
};
|
|
const uploadTask = uni.uploadFile({
|
...uploadParams,
|
success: res => {
|
try {
|
if (res.statusCode === 200) {
|
const response = JSON.parse(res.data);
|
if (response.code === 200) {
|
handleUploadSuccess(response, file);
|
uni.showToast({
|
title: "上传成功",
|
icon: "success",
|
});
|
emit("update:modelValue", fileList.value);
|
} else {
|
handleUploadError(response.msg || "服务器返回错误");
|
}
|
} else {
|
handleUploadError(`服务器错误,状态码: ${res.statusCode}`);
|
}
|
} catch (e) {
|
console.error("解析响应失败:", e);
|
console.error("原始响应数据:", res.data);
|
handleUploadError("响应数据解析失败: " + e.message);
|
}
|
},
|
fail: err => {
|
console.error("上传失败:", err.errMsg || err);
|
number.value--; // 上传失败时减少计数
|
|
let errorMessage = "上传失败";
|
if (err.errMsg) {
|
if (err.errMsg.includes("statusCode: null")) {
|
errorMessage = "网络连接失败,请检查网络设置";
|
} else if (err.errMsg.includes("timeout")) {
|
errorMessage = "上传超时,请重试";
|
} else if (err.errMsg.includes("fail")) {
|
errorMessage = "上传失败,请检查网络连接";
|
} else {
|
errorMessage = err.errMsg;
|
}
|
}
|
|
handleUploadError(errorMessage);
|
},
|
complete: () => {
|
uploading.value = false;
|
uploadProgress.value = 0;
|
},
|
});
|
|
// 监听上传进度
|
if (uploadTask && uploadTask.onProgressUpdate) {
|
uploadTask.onProgressUpdate(res => {
|
uploadProgress.value = res.progress;
|
});
|
}
|
};
|
// 获取媒体文件名
|
const getMediaName = file => {
|
if (file.bucketFilename) {
|
return file.bucketFilename.length > 15
|
? file.bucketFilename.substring(0, 15) + "..."
|
: file.bucketFilename;
|
}
|
if (file.name) {
|
return file.name.length > 15
|
? file.name.substring(0, 15) + "..."
|
: file.name;
|
}
|
return file.type === "image" ? "照片" : "视频";
|
};
|
|
// 格式化文件大小
|
const formatFileSize = size => {
|
if (!size) return "";
|
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 = timestamp => {
|
if (!timestamp) return "";
|
const date = new Date(timestamp);
|
const now = new Date();
|
const diff = now - date;
|
|
if (diff < 60000) return "刚刚";
|
if (diff < 3600000) return Math.floor(diff / 60000) + "分钟前";
|
if (diff < 86400000) return Math.floor(diff / 3600000) + "小时前";
|
|
return date.toLocaleDateString();
|
};
|
|
// 预览媒体文件
|
const previewMedia = (file, index) => {
|
if (file.type === "image") {
|
// 预览图片
|
const urls = fileList.value
|
.filter(item => item.type === "image")
|
.map(item => item.url || item.tempFilePath);
|
|
uni.previewImage({
|
urls: urls,
|
current: file.url || file.tempFilePath,
|
});
|
} else if (file.type === "video") {
|
// 预览视频
|
uni.previewVideo({
|
sources: [
|
{
|
src: file.url || file.tempFilePath,
|
type: "mp4",
|
},
|
],
|
current: 0,
|
});
|
}
|
};
|
|
// 下载文件
|
const handleDownload = index => {
|
const file = fileList.value[index];
|
const url = file.url || file.downloadUrl;
|
|
if (!url) {
|
uni.showToast({
|
title: "文件链接不存在,无法下载",
|
icon: "none",
|
});
|
return;
|
}
|
|
// 使用uniapp的下载API
|
uni.downloadFile({
|
url: url,
|
success: res => {
|
if (res.statusCode === 200) {
|
// 保存到相册或文件系统
|
uni.saveFile({
|
tempFilePath: res.tempFilePath,
|
success: saveRes => {
|
uni.showToast({
|
title: "下载成功",
|
icon: "success",
|
});
|
},
|
fail: err => {
|
console.error("保存文件失败:", err);
|
uni.showToast({
|
title: "保存文件失败",
|
icon: "error",
|
});
|
},
|
});
|
}
|
},
|
fail: err => {
|
console.error("下载失败:", err);
|
uni.showToast({
|
title: "下载失败",
|
icon: "error",
|
});
|
},
|
});
|
};
|
|
// 检查网络连接
|
const checkNetworkConnection = () => {
|
return new Promise(resolve => {
|
uni.getNetworkType({
|
success: res => {
|
if (res.networkType === "none") {
|
resolve(false);
|
} else {
|
resolve(true);
|
}
|
},
|
fail: () => {
|
resolve(false);
|
},
|
});
|
});
|
};
|
|
// 上传前校验
|
const handleBeforeUpload = async file => {
|
// 检查网络连接
|
const hasNetwork = await checkNetworkConnection();
|
if (!hasNetwork) {
|
uni.showToast({
|
title: "网络连接不可用,请检查网络设置",
|
icon: "none",
|
});
|
return false;
|
}
|
|
// 校验文件大小
|
if (props.fileSize && file.size) {
|
const isLt = file.size / 1024 / 1024 < props.fileSize;
|
if (!isLt) {
|
uni.showToast({
|
title: `文件大小不能超过 ${props.fileSize} MB!`,
|
icon: "none",
|
});
|
return false;
|
}
|
}
|
|
// 校验视频时长
|
if (
|
file.type === "video" &&
|
file.duration &&
|
file.duration > props.maxVideoDuration
|
) {
|
uni.showToast({
|
title: `视频时长不能超过 ${props.maxVideoDuration} 秒!`,
|
icon: "none",
|
});
|
return false;
|
}
|
|
// 校验文件类型
|
if (
|
props.fileType &&
|
Array.isArray(props.fileType) &&
|
props.fileType.length > 0
|
) {
|
const fileName = file.name || "";
|
const fileExtension = fileName
|
? fileName.split(".").pop().toLowerCase()
|
: "";
|
|
// 根据文件类型确定期望的扩展名
|
let expectedTypes = [];
|
if (file.type === "image") {
|
expectedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
|
} else if (file.type === "video") {
|
expectedTypes = ["mp4", "mov", "avi", "wmv"];
|
}
|
|
// 检查文件扩展名是否在允许的类型中
|
if (fileExtension && expectedTypes.length > 0) {
|
const isAllowed = expectedTypes.some(
|
type => props.fileType.includes(type) && type === fileExtension
|
);
|
|
if (!isAllowed) {
|
uni.showToast({
|
title: `文件格式不支持,请拍摄 ${expectedTypes.join("/")} 格式的文件`,
|
icon: "none",
|
});
|
return false;
|
}
|
}
|
}
|
|
// 校验通过,开始上传
|
uploadFile(file);
|
return true;
|
};
|
|
// 上传失败处理
|
const handleUploadError = (message = "上传文件失败", showRetry = true) => {
|
if (showRetry) {
|
uni.showModal({
|
title: "上传失败",
|
content: message + ",是否重试?",
|
success: res => {
|
if (res.confirm) {
|
// 用户选择重试,这里可以重新触发上传
|
}
|
},
|
});
|
} else {
|
uni.showToast({
|
title: message,
|
icon: "error",
|
});
|
}
|
};
|
|
// 上传成功回调
|
const handleUploadSuccess = (res, file) => {
|
if (
|
res.code === 200 &&
|
res.data &&
|
Array.isArray(res.data) &&
|
res.data.length > 0
|
) {
|
const uploadedFile = res.data[0];
|
// 确保上传的文件数据完整,包含id
|
const fileData = {
|
...file,
|
id: uploadedFile.id, // 添加服务器返回的id
|
url: uploadedFile.url || uploadedFile.downloadUrl,
|
bucketFilename: uploadedFile.bucketFilename || file.name,
|
downloadUrl: uploadedFile.downloadUrl || uploadedFile.url,
|
size: uploadedFile.size || file.size,
|
createTime: uploadedFile.createTime || new Date().getTime(),
|
};
|
|
uploadList.value.push(fileData);
|
uploadedSuccessfully();
|
} else {
|
number.value--; // 上传失败时减少计数
|
handleUploadError(res.msg || "上传失败");
|
}
|
};
|
|
// 删除文件
|
const handleDelete = index => {
|
uni.showModal({
|
title: "确认删除",
|
content: "确定要删除这个文件吗?",
|
success: res => {
|
if (res.confirm) {
|
fileList.value.splice(index, 1);
|
emit("update:modelValue", listToString(fileList.value));
|
uni.showToast({
|
title: "删除成功",
|
icon: "success",
|
});
|
}
|
},
|
});
|
};
|
|
// 上传结束处理
|
const uploadedSuccessfully = () => {
|
if (number.value > 0 && uploadList.value.length === number.value) {
|
// 合并已存在的文件和刚上传的文件
|
const existingFiles = fileList.value.filter(f => f.url !== undefined);
|
fileList.value = [...existingFiles, ...uploadList.value];
|
|
// 重置状态
|
uploadList.value = [];
|
number.value = 0;
|
|
// 触发更新事件,传递完整的文件列表
|
emit("update:modelValue", fileList.value);
|
}
|
};
|
|
const listToString = (list, separator = ",") => {
|
const strs = list
|
.filter(item => item.url)
|
.map(item => item.url)
|
.join(separator);
|
return strs;
|
};
|
</script>
|
<style scoped lang="scss">
|
.camera-upload {
|
width: 100%;
|
}
|
|
.camera-buttons {
|
margin-bottom: 15px;
|
|
.button-row {
|
display: flex;
|
gap: 10px;
|
}
|
}
|
|
.upload-tip {
|
font-size: 12px;
|
color: #909399;
|
margin-bottom: 15px;
|
text-align: center;
|
line-height: 1.5;
|
|
.tip-text {
|
margin: 0 2px;
|
}
|
|
.tip-highlight {
|
color: #f56c6c;
|
font-weight: bold;
|
}
|
}
|
|
.media-list {
|
margin-top: 10px;
|
display: flex;
|
flex-wrap: wrap;
|
gap: 8px;
|
}
|
|
.media-item {
|
position: relative;
|
width: 80px;
|
height: 80px;
|
border-radius: 8px;
|
overflow: hidden;
|
background-color: #f5f5f5;
|
border: 2px solid #e9ecef;
|
transition: all 0.3s ease;
|
|
&:hover {
|
border-color: #409eff;
|
transform: scale(1.02);
|
}
|
}
|
|
.media-preview {
|
position: relative;
|
width: 100%;
|
height: 100%;
|
cursor: pointer;
|
|
.preview-image,
|
.preview-video {
|
width: 100%;
|
height: 100%;
|
object-fit: cover;
|
}
|
|
.media-type-icon {
|
position: absolute;
|
top: 4px;
|
right: 4px;
|
width: 20px;
|
height: 20px;
|
background-color: rgba(0, 0, 0, 0.6);
|
border-radius: 50%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
}
|
|
.media-actions {
|
position: absolute;
|
bottom: 0;
|
left: 0;
|
right: 0;
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
padding: 4px;
|
display: flex;
|
justify-content: center;
|
opacity: 0;
|
transition: opacity 0.3s ease;
|
|
.media-item:hover & {
|
opacity: 1;
|
}
|
}
|
|
.upload-progress {
|
margin-top: 15px;
|
padding: 0 10px;
|
}
|
</style>
|