hookehuyr

feat(上传功能): 添加媒体上传组件和弹窗封装

实现完整的媒体上传功能,包括:
1. 新增 PopupWrapper 作为通用弹窗组件
2. 新增 UploadMediaPopup 作为上传弹窗容器
3. 新增 UploadMediaComponent 实现具体上传逻辑
4. 在 FamilyAlbum 中集成上传功能
5. 更新组件类型声明

支持图片和视频上传,包含预览、删除和提交功能
......@@ -29,6 +29,7 @@ declare module 'vue' {
NutToast: typeof import('@nutui/nutui-taro')['Toast']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PointsCollector: typeof import('./src/components/PointsCollector.vue')['default']
PopupWrapper: typeof import('./src/components/PopupWrapper.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
PrimaryButton: typeof import('./src/components/PrimaryButton.vue')['default']
RankingCard: typeof import('./src/components/RankingCard.vue')['default']
......@@ -37,6 +38,8 @@ declare module 'vue' {
ShareButton: typeof import('./src/components/ShareButton/index.vue')['default']
TabBar: typeof import('./src/components/TabBar.vue')['default']
TotalPointsDisplay: typeof import('./src/components/TotalPointsDisplay.vue')['default']
UploadMediaComponent: typeof import('./src/components/UploadMediaComponent.vue')['default']
UploadMediaPopup: typeof import('./src/components/UploadMediaPopup.vue')['default']
WeRunAuth: typeof import('./src/components/WeRunAuth.vue')['default']
}
}
......
......@@ -109,6 +109,15 @@
@fullscreenchange="handleFullscreenChange"
/>
</view>
<!-- 上传媒体弹窗 -->
<UploadMediaPopup
v-model:visible="uploadPopupVisible"
from="family"
:id="''"
@success="handleUploadSuccess"
@close="closeUploadPopup"
/>
</view>
</template>
......@@ -118,12 +127,16 @@ import Taro, { useDidShow } from '@tarojs/taro';
import { Close, Photograph, IconFont } from '@nutui/icons-vue-taro';
import { getPhotoListAPI } from '@/api/photo';
import UploadMediaPopup from '@/components/UploadMediaPopup.vue';
// 视频播放相关状态
const videoVisible = ref(false);
const currentVideo = ref(null);
const videoId = ref(0);
// 上传弹窗相关状态
const uploadPopupVisible = ref(false);
/**
* 处理媒体项点击事件
* @param {Object} item - 媒体项
......@@ -239,10 +252,35 @@ const openAlbumList = () => {
};
/**
* 跳转到上传媒体页面
* 打开上传弹窗
*/
const navigateToUpload = () => {
Taro.navigateTo({ url: '/pages/UploadMedia/index' });
uploadPopupVisible.value = true;
};
/**
* 关闭上传弹窗
*/
const closeUploadPopup = () => {
uploadPopupVisible.value = false;
};
/**
* 上传成功回调
* @param {Object} data - 上传成功的数据
*/
const handleUploadSuccess = (data) => {
console.log('上传成功:', data);
// 刷新相册数据
refreshData();
// 关闭弹窗
closeUploadPopup();
// 显示成功提示
Taro.showToast({
title: '上传成功',
icon: 'success',
duration: 2000
});
};
// 组件挂载时获取数据
......
......@@ -126,7 +126,7 @@ const sourceTypeMap = {
'CHECK_IN_COUNT': '完成活动',
'FAMILY_SIZE': '家庭成员',
'COMPANION_PHOTO': '陪伴拍照',
'WHEELCHAIR_COMPANION': '陪伴轮椅'
'WHEELCHAIR_COMPANION': '特殊陪伴'
}
/**
......
<template>
<nut-popup
:visible="visibleModel"
:position="position"
:style="popupStyle"
:closeable="closeable"
:close-on-click-overlay="closeOnClickOverlay"
:round="round"
:z-index="zIndex"
@close="handleClose"
@open="handleOpen"
@opened="handleOpened"
@closed="handleClosed"
>
<!-- 自定义头部 -->
<view v-if="showHeader" class="popup-header">
<view class="flex items-center justify-between p-4 border-b border-gray-200">
<view class="text-lg font-medium">{{ title }}</view>
<view v-if="showCloseButton" @tap="handleClose" class="w-8 h-8 flex items-center justify-center"></view>
</view>
</view>
<!-- 内容区域 -->
<view class="popup-content" :style="contentStyle">
<slot></slot>
</view>
</nut-popup>
</template>
<script setup>
import { ref, computed, defineProps, defineEmits } from 'vue';
import { Close } from '@nutui/icons-vue-taro';
// 定义props
const props = defineProps({
// 控制弹窗显示/隐藏
visible: {
type: Boolean,
default: false
},
// 弹窗位置
position: {
type: String,
default: 'center',
validator: (value) => ['center', 'top', 'bottom', 'left', 'right'].includes(value)
},
// 弹窗标题
title: {
type: String,
default: ''
},
// 是否显示头部
showHeader: {
type: Boolean,
default: true
},
// 是否显示关闭按钮
showCloseButton: {
type: Boolean,
default: true
},
// 是否可关闭
closeable: {
type: Boolean,
default: true
},
// 点击遮罩是否关闭
closeOnClickOverlay: {
type: Boolean,
default: false
},
// 是否圆角
round: {
type: Boolean,
default: false
},
// 层级
zIndex: {
type: Number,
default: 2000
},
// 自定义宽度
width: {
type: String,
default: '100%'
},
// 自定义高度
height: {
type: String,
default: '100%'
},
// 是否全屏
fullscreen: {
type: Boolean,
default: false
}
});
// 定义emits
const emit = defineEmits(['update:visible', 'close', 'open', 'opened', 'closed']);
// 计算属性处理双向绑定
const visibleModel = computed({
get() {
return props.visible;
},
set(value) {
emit('update:visible', value);
}
});
// 计算弹窗样式
const popupStyle = computed(() => {
const style = {};
if (props.fullscreen) {
style.width = '100%';
style.height = '100%';
} else {
if (props.width) style.width = props.width;
if (props.height) style.height = props.height;
}
return style;
});
// 计算内容区域样式
const contentStyle = computed(() => {
const style = {};
if (props.showHeader) {
style.height = 'calc(100% - 60px)';
} else {
style.height = '100%';
}
return style;
});
/**
* 处理关闭事件
*/
const handleClose = () => {
emit('update:visible', false);
emit('close');
};
/**
* 处理打开事件
*/
const handleOpen = () => {
emit('open');
};
/**
* 处理已打开事件
*/
const handleOpened = () => {
emit('opened');
};
/**
* 处理已关闭事件
*/
const handleClosed = () => {
emit('closed');
};
</script>
<style lang="less" scoped>
.popup-header {
background-color: #fff;
border-bottom: 1px solid #e5e7eb;
}
.popup-content {
background-color: #f8f9fa;
overflow-y: auto;
}
</style>
<template>
<view class="upload-media-component">
<!-- Upload Area -->
<view class="p-4">
<!-- Upload Button -->
<view
v-if="!uploadedFile"
class="border border-dashed border-gray-300 rounded-lg p-8 flex flex-col items-center justify-center mb-4 bg-white"
@tap="chooseMedia"
>
<view class="text-gray-400 mb-4">
<Photograph size="48" />
</view>
<view class="text-center text-gray-600 mb-2 text-sm">选择图片或视频</view>
<view class="text-center text-gray-400 text-sm">
支持图片格式(jpg、png)最大10MB或60秒内视频
</view>
</view>
<!-- Preview Area -->
<view v-if="uploadedFile" class="mb-4">
<!-- Image Preview -->
<view v-if="uploadedFile.type === 'image'" class="relative rounded-lg overflow-hidden bg-white shadow-sm">
<image
:src="uploadedFile.url"
class="w-full h-64 object-cover cursor-pointer"
mode="widthFix"
@tap="previewImage"
/>
<view
@tap="removeFile"
class="absolute top-2 right-2 w-8 h-8 bg-black bg-opacity-50 rounded-full flex items-center justify-center"
>
<Close size="16" class="text-white" />
</view>
</view>
<!-- Video Preview -->
<view v-if="uploadedFile.type === 'video'" class="relative rounded-lg overflow-hidden bg-white shadow-sm">
<view
class="relative w-full h-64 bg-black rounded-lg flex items-center justify-center"
@tap="playVideo"
>
<image
v-if="uploadedFile.thumbnail"
:src="uploadedFile.thumbnail"
class="w-full h-full object-cover"
mode="widthFix"
/>
<view class="absolute inset-0 flex items-center justify-center">
<view class="w-16 h-16 bg-black bg-opacity-60 rounded-full flex items-center justify-center">
<image :src="playIcon" class="w-6 h-6" />
</view>
</view>
</view>
<view
@tap="removeFile"
class="absolute top-2 right-2 w-8 h-8 bg-black bg-opacity-50 rounded-full flex items-center justify-center"
>
<Close size="16" class="text-white" />
</view>
</view>
<!-- File Info -->
<view class="mt-3 p-3 bg-white rounded-lg">
<view class="text-sm text-gray-600">文件大小: {{ formatFileSize(uploadedFile.size) }}</view>
<view v-if="uploadedFile.type === 'video'" class="text-sm text-gray-600 mt-1">
时长: {{ formatDuration(uploadedFile.duration) }}
</view>
</view>
</view>
<!-- Action Buttons -->
<view class="flex gap-3">
<view
@tap="chooseMedia"
class="flex-1 bg-gray-100 text-gray-700 py-3 rounded-lg text-center text-sm"
>
{{ uploadedFile ? '重新选择' : '选择文件' }}
</view>
<view
v-if="uploadedFile"
@tap="saveMedia"
class="flex-1 bg-blue-500 text-white py-3 rounded-lg text-center text-sm"
>
提交
</view>
</view>
<view class="mt-6 text-sm text-gray-500">
<view class="mb-2">积分注意事项:</view>
<view class="text-xs mb-1">• 每张图片或视频积分100分</view>
<view class="text-xs mb-1">• 每天最多积分100分</view>
</view>
</view>
<!-- Video Player Modal -->
<view
v-if="videoVisible"
class="fixed inset-0 bg-black"
style="z-index: 9999;"
@tap="closeVideo"
>
<!-- Close Button -->
<view
@tap.stop="closeVideo"
class="absolute top-4 right-4 w-10 h-10 bg-black bg-opacity-50 rounded-full flex items-center justify-center"
style="z-index: 10000;"
>
<Close size="24" class="text-white" />
</view>
<!-- Video Player -->
<video
v-if="uploadedFile && uploadedFile.type === 'video'"
:id="'upload-video-' + videoId"
:src="uploadedFile.url"
:poster="uploadedFile.thumbnail"
:controls="true"
:autoplay="false"
:show-center-play-btn="true"
:show-play-btn="true"
:object-fit="'contain'"
:show-fullscreen-btn="true"
style="width: 100vw; height: 50vh; position: absolute; top: 20vh; left: 0;"
@tap.stop
@play="handleVideoPlay"
@pause="handleVideoPause"
@error="handleVideoError"
@fullscreenchange="handleFullscreenChange"
/>
</view>
<!-- 图片预览 -->
<nut-image-preview
v-model:show="previewVisible"
:images="previewImages"
:init-no="previewIndex"
:show-index="false"
@close="closePreview"
/>
</view>
</template>
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
import Taro from '@tarojs/taro';
import { Photograph, Close } from '@nutui/icons-vue-taro';
import BASE_URL from '@/utils/config';
import { savePhotoAPI } from '@/api/photo';
// 定义props
const props = defineProps({
from: {
type: String,
default: ''
},
id: {
type: String,
default: ''
}
});
// 定义emits
const emit = defineEmits(['success', 'close']);
// 播放图标
const playIcon = 'https://cdn.ipadbiz.cn/lls_prog/icon/play.svg';
const callbackUrl = BASE_URL + '/srv/?f=walk&a=media&t=qiniu_audit_notify'
// 响应式数据
const uploadedFile = ref(null);
const videoVisible = ref(false);
const videoId = ref(Date.now());
// 图片预览相关
const previewVisible = ref(false);
const previewImages = ref([]);
const previewIndex = ref(0);
/**
* 选择媒体文件(图片或视频)
*/
const chooseMedia = () => {
Taro.chooseMedia({
count: 1,
mediaType: ['image', 'video'],
sourceType: ['album', 'camera'],
maxDuration: 60,
sizeType: ['compressed'],
camera: 'back',
success: async (res) => {
const file = res.tempFiles[0];
// 检查文件大小(仅对图片进行10MB限制,视频不检查大小)
if (file.fileType === 'image' && file.size > 10 * 1024 * 1024) {
Taro.showToast({
title: '图片大小不能超过10MB',
icon: 'none',
duration: 2000
});
return;
}
// 检查视频长度(仅对视频进行60秒限制)
if (file.fileType === 'video' && file.duration > 60) {
Taro.showToast({
title: '视频时长不能超过60秒',
icon: 'none',
duration: 2000
});
return;
}
// 显示上传进度
Taro.showLoading({ title: '上传中...' });
try {
// 立即上传文件到服务器
const serverUrl = await uploadFileToServer(file.tempFilePath, file.fileType);
// 根据文件类型设置不同的信息,包含服务器URL
if (file.fileType === 'image') {
uploadedFile.value = {
type: 'image',
url: file.tempFilePath,
serverUrl: serverUrl.src,
qiniu_audit: serverUrl.qiniu_audit,
size: file.size,
name: `image_${Date.now()}.jpg`
};
} else if (file.fileType === 'video') {
uploadedFile.value = {
type: 'video',
url: file.tempFilePath,
serverUrl: serverUrl.src,
qiniu_audit: serverUrl.qiniu_audit,
thumbnail: file.thumbTempFilePath,
duration: Math.floor(file.duration),
size: file.size,
name: `video_${Date.now()}.mp4`,
};
}
Taro.hideLoading();
Taro.showToast({
title: '上传成功',
icon: 'success',
duration: 1500
});
} catch (error) {
console.error('上传失败:', error);
Taro.hideLoading();
Taro.showToast({
title: '上传失败,请重试',
icon: 'none',
duration: 2000
});
}
},
fail: (err) => {
console.error('选择媒体文件失败:', err);
Taro.showToast({
title: '选择文件失败',
icon: 'error',
duration: 2000
});
}
});
};
/**
* 移除文件
*/
const removeFile = () => {
uploadedFile.value = null;
};
/**
* 播放视频
*/
const playVideo = () => {
if (uploadedFile.value && uploadedFile.value.type === 'video') {
videoId.value = Date.now();
videoVisible.value = true;
}
};
/**
* 关闭视频播放
*/
const closeVideo = () => {
videoVisible.value = false;
};
/**
* 预览图片
*/
const previewImage = () => {
if (!uploadedFile.value || uploadedFile.value.type !== 'image') {
Taro.showToast({
title: '暂无图片可预览',
icon: 'error',
duration: 2000
});
return;
}
previewImages.value = [{ src: uploadedFile.value.url }];
previewIndex.value = 0;
previewVisible.value = true;
};
/**
* 关闭图片预览
*/
const closePreview = () => {
previewVisible.value = false;
};
/**
* 处理视频播放
*/
const handleVideoPlay = () => {
console.log('视频开始播放');
};
/**
* 处理视频暂停
*/
const handleVideoPause = () => {
console.log('视频暂停播放');
};
/**
* 处理全屏状态变化
* @param {Event} event - 全屏事件
*/
const handleFullscreenChange = (event) => {
console.log('全屏状态变化:', event.detail);
};
/**
* 处理视频播放错误
* @param {Event} error - 错误事件
*/
const handleVideoError = (error) => {
console.error('视频播放错误:', error);
Taro.showToast({
title: '视频播放失败',
icon: 'error',
duration: 2000
});
closeVideo();
};
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string} 格式化后的文件大小
*/
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
/**
* 格式化视频时长
* @param {number} seconds - 秒数
* @returns {string} 格式化后的时长
*/
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
/**
* 上传文件到服务器
* @param {string} filePath - 文件路径
* @param {string} fileType - 文件类型
*/
const uploadFileToServer = (filePath, fileType) => {
return new Promise((resolve, reject) => {
// 视频上传需要判断回调
let video_params = ''
if (fileType === 'video') {
video_params = '&callback_rul=' + encodeURIComponent(callbackUrl)
}
Taro.uploadFile({
url: BASE_URL + '/admin/?m=srv&a=upload&image_audit=1' + video_params,
filePath,
name: 'file',
header: {
'content-type': 'multipart/form-data',
},
success: function (res) {
try {
const upload_data = JSON.parse(res.data);
if (upload_data.code === 0 && upload_data.data) {
resolve({ src: upload_data.data.src, qiniu_audit: upload_data.data.audit_result});
} else {
reject(new Error(upload_data.msg || '服务器错误'));
}
} catch (error) {
reject(new Error('解析响应数据失败'));
}
},
fail: function (error) {
reject(error);
}
});
});
};
/**
* 保存媒体文件
*/
const saveMedia = async () => {
if (!uploadedFile.value) {
Taro.showToast({
title: '请先选择文件',
icon: 'error',
duration: 2000
});
return;
}
if (!uploadedFile.value.serverUrl) {
Taro.showToast({
title: '文件上传未完成,请重新选择',
icon: 'error',
duration: 2000
});
return;
}
Taro.showLoading({
title: '保存中...',
mask: true
});
try {
// 调用后端接口保存媒体信息
const saveData = {
media_type: uploadedFile.value.type === 'image' ? 'IMAGE' : 'VIDEO',
media_url: uploadedFile.value.serverUrl,
source_type: props.from === 'checkin' ? 'CHECK_IN' : 'COMPANION',
source_id: props.id || '0',
qiniu_audit: uploadedFile.value.qiniu_audit || '',
};
const result = await savePhotoAPI(saveData);
if (result.code) {
Taro.hideLoading();
Taro.showToast({
title: '保存成功',
icon: 'success',
duration: 2000
});
// 触发成功事件,传递保存结果
emit('success', {
...saveData,
result: result
});
// 清空上传的文件
uploadedFile.value = null;
} else {
throw new Error(result.msg || '保存失败');
}
} catch (error) {
console.error('保存失败:', error);
Taro.hideLoading();
Taro.showToast({
title: error.message || '保存失败,请重试',
icon: 'error',
duration: 2000
});
}
};
</script>
<style lang="less" scoped>
.upload-media-component {
height: 100%;
background-color: #f8f9fa;
overflow-y: auto;
}
</style>
\ No newline at end of file
<template>
<PopupWrapper
v-model:visible="popupVisible"
position="right"
title="上传照片/视频"
:fullscreen="true"
:close-on-click-overlay="false"
@close="handleClose"
>
<UploadMediaComponent
:from="from"
:id="id"
@success="handleSuccess"
@close="handleClose"
/>
</PopupWrapper>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch } from 'vue';
import PopupWrapper from '@/components/PopupWrapper.vue';
import UploadMediaComponent from '@/components/UploadMediaComponent.vue';
// 定义props
const props = defineProps({
// 控制弹窗显示/隐藏
visible: {
type: Boolean,
default: false
},
// 来源标识
from: {
type: String,
default: ''
},
// ID参数
id: {
type: String,
default: ''
}
});
// 定义emits
const emit = defineEmits(['update:visible', 'success', 'close']);
// 内部弹窗状态
const popupVisible = ref(false);
// 监听外部visible变化
watch(() => props.visible, (newVal) => {
popupVisible.value = newVal;
}, { immediate: true });
// 监听内部弹窗状态变化
watch(popupVisible, (newVal) => {
emit('update:visible', newVal);
});
/**
* 处理上传成功
* @param {Object} data - 上传成功的数据
*/
const handleSuccess = (data) => {
emit('success', data);
handleClose();
};
/**
* 处理关闭弹窗
*/
const handleClose = () => {
popupVisible.value = false;
emit('close');
};
</script>
<style lang="less" scoped>
// 组件样式由PopupWrapper和UploadMediaComponent提供
</style>
\ No newline at end of file