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