1177 lines
30 KiB
Vue
1177 lines
30 KiB
Vue
<template>
|
||
<view class="image-video-upload">
|
||
<!-- 图片上传区域 -->
|
||
<view class="upload-section" v-if="enableImage">
|
||
<view class="section-title">
|
||
<text class="title-text">图片</text>
|
||
<text class="count-text">({{ imageList.length }}/{{ maxImageCount }})</text>
|
||
</view>
|
||
|
||
<view class="image-list">
|
||
<view
|
||
class="image-item"
|
||
v-for="(image, index) in imageList"
|
||
:key="index"
|
||
>
|
||
<image
|
||
:src="image.url || image.tempPath"
|
||
class="image-preview"
|
||
@click="previewImage(index)"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="image-actions">
|
||
<view class="delete-btn" @click="removeImage(index)">
|
||
<uni-icons type="close" size="16" color="#fff"></uni-icons>
|
||
</view>
|
||
</view>
|
||
<view class="image-status" v-if="image.isCompressed">
|
||
<text class="compressed-tag">已压缩</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view
|
||
class="add-btn"
|
||
v-if="imageList.length < maxImageCount"
|
||
@click="chooseImage"
|
||
>
|
||
<uni-icons type="camera" size="24" color="#999"></uni-icons>
|
||
<text class="add-text">添加图片</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 视频上传区域 -->
|
||
<view class="upload-section" v-if="enableVideo">
|
||
<view class="section-title">
|
||
<text class="title-text">视频</text>
|
||
<text class="count-text">({{ videoList.length }}/{{ maxVideoCount }})</text>
|
||
</view>
|
||
|
||
<view class="video-list">
|
||
<view
|
||
class="video-item"
|
||
v-for="(video, index) in videoList"
|
||
:key="index"
|
||
>
|
||
<view class="video-preview" @click="previewVideo(index)">
|
||
<image
|
||
:src="video.thumbnail || '/static/icons/video-placeholder.png'"
|
||
class="video-thumbnail"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="play-icon">
|
||
<uni-icons type="play-filled" size="24" color="#fff"></uni-icons>
|
||
</view>
|
||
<view class="video-duration" v-if="video.duration">
|
||
{{ formatDuration(video.duration) }}
|
||
</view>
|
||
</view>
|
||
<view class="video-actions">
|
||
<view class="delete-btn" @click="removeVideo(index)">
|
||
<uni-icons type="close" size="16" color="#fff"></uni-icons>
|
||
</view>
|
||
</view>
|
||
<view class="video-info">
|
||
<text class="video-name">{{ video.name }}</text>
|
||
<text class="video-size" v-if="video.size">{{ formatFileSize(video.size) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view
|
||
class="add-btn"
|
||
v-if="videoList.length < maxVideoCount"
|
||
@click="chooseVideo"
|
||
>
|
||
<uni-icons type="videocam" size="24" color="#999"></uni-icons>
|
||
<text class="add-text">添加视频</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 文件上传区域 -->
|
||
<view class="upload-section" v-if="enableFile">
|
||
<view class="section-title">
|
||
<text class="title-text">文件</text>
|
||
<text class="count-text">({{ fileList.length }}/{{ maxFileCount }})</text>
|
||
</view>
|
||
|
||
<view class="file-list">
|
||
<view
|
||
class="file-item"
|
||
v-for="(file, index) in fileList"
|
||
:key="index"
|
||
>
|
||
<view class="file-preview" @click="previewFile(index)">
|
||
<view class="file-icon">
|
||
<uni-icons :type="getFileIcon(file.extension || '')" size="32" color="#666"></uni-icons>
|
||
</view>
|
||
<view class="file-info">
|
||
<text class="file-name">{{ file.name }}</text>
|
||
<text class="file-size" v-if="file.size">{{ formatFileSize(file.size) }}</text>
|
||
<text class="file-type">{{ file.extension?.toUpperCase() || 'FILE' }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="file-actions">
|
||
<view class="delete-btn" @click="removeFile(index)">
|
||
<uni-icons type="close" size="16" color="#fff"></uni-icons>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view
|
||
class="add-btn"
|
||
v-if="fileList.length < maxFileCount"
|
||
@click="chooseFile"
|
||
>
|
||
<uni-icons type="paperclip" size="24" color="#999"></uni-icons>
|
||
<text class="add-text">添加文件</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch } from 'vue'
|
||
import { imagUrl } from '@/utils'
|
||
import {
|
||
previewFile as previewFileUtil
|
||
} from '@/utils/filePreview'
|
||
|
||
// 接口定义
|
||
interface ImageItem {
|
||
tempPath?: string
|
||
url?: string
|
||
name?: string
|
||
originalPath?: string
|
||
isCompressed?: boolean
|
||
}
|
||
|
||
interface VideoItem {
|
||
tempPath?: string
|
||
url?: string
|
||
name?: string
|
||
duration?: number
|
||
size?: number
|
||
thumbnail?: string
|
||
}
|
||
|
||
interface FileItem {
|
||
tempPath?: string
|
||
url?: string
|
||
name?: string
|
||
type?: string
|
||
size?: number
|
||
extension?: string
|
||
mimeType?: string
|
||
}
|
||
|
||
// Props
|
||
interface Props {
|
||
// 图片相关
|
||
enableImage?: boolean
|
||
maxImageCount?: number
|
||
imageList?: ImageItem[]
|
||
|
||
// 视频相关
|
||
enableVideo?: boolean
|
||
maxVideoCount?: number
|
||
videoList?: VideoItem[]
|
||
|
||
// 文件相关
|
||
enableFile?: boolean
|
||
maxFileCount?: number
|
||
fileList?: FileItem[]
|
||
allowedFileTypes?: string[]
|
||
|
||
// 压缩配置
|
||
compressConfig?: {
|
||
image: {
|
||
quality: number
|
||
maxWidth: number
|
||
maxHeight: number
|
||
maxSize: number
|
||
minQuality: number
|
||
}
|
||
video: {
|
||
maxDuration: number
|
||
maxSize: number
|
||
quality: string
|
||
}
|
||
}
|
||
|
||
// 上传配置
|
||
autoUpload?: boolean
|
||
uploadApi?: (file: any) => Promise<any>
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
enableImage: true,
|
||
maxImageCount: 5,
|
||
imageList: () => [],
|
||
|
||
enableVideo: true,
|
||
maxVideoCount: 3,
|
||
videoList: () => [],
|
||
|
||
enableFile: false,
|
||
maxFileCount: 5,
|
||
fileList: () => [],
|
||
allowedFileTypes: () => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'mp3', 'wav', 'zip', 'rar'],
|
||
|
||
compressConfig: () => ({
|
||
image: {
|
||
quality: 60,
|
||
maxWidth: 1280,
|
||
maxHeight: 720,
|
||
maxSize: 200 * 1024, // 200KB
|
||
minQuality: 20
|
||
},
|
||
video: {
|
||
maxDuration: 60,
|
||
maxSize: 10 * 1024 * 1024, // 10MB
|
||
quality: 'medium'
|
||
}
|
||
}),
|
||
|
||
autoUpload: true,
|
||
uploadApi: undefined
|
||
})
|
||
|
||
// Emits
|
||
const emit = defineEmits<{
|
||
'update:imageList': [images: ImageItem[]]
|
||
'update:videoList': [videos: VideoItem[]]
|
||
'update:fileList': [files: FileItem[]]
|
||
'image-upload-success': [image: ImageItem, index: number]
|
||
'image-upload-error': [error: any, index: number]
|
||
'video-upload-success': [video: VideoItem, index: number]
|
||
'video-upload-error': [error: any, index: number]
|
||
'file-upload-success': [file: FileItem, index: number]
|
||
'file-upload-error': [error: any, index: number]
|
||
'upload-progress': [type: 'image' | 'video' | 'file', current: number, total: number]
|
||
}>()
|
||
|
||
// 响应式数据
|
||
const imageList = ref<ImageItem[]>([...props.imageList])
|
||
const videoList = ref<VideoItem[]>([...props.videoList])
|
||
const fileList = ref<FileItem[]>([...props.fileList])
|
||
|
||
// 监听props变化
|
||
watch(() => props.imageList, (newList) => {
|
||
imageList.value = [...newList]
|
||
}, { deep: true })
|
||
|
||
watch(() => props.videoList, (newList) => {
|
||
videoList.value = [...newList]
|
||
}, { deep: true })
|
||
|
||
watch(() => props.fileList, (newList) => {
|
||
fileList.value = [...newList]
|
||
}, { deep: true })
|
||
|
||
// 压缩配置
|
||
const COMPRESS_CONFIG = computed(() => props.compressConfig)
|
||
|
||
// 获取文件信息
|
||
const getFileInfo = (filePath: string): Promise<any> => {
|
||
return new Promise((resolve, reject) => {
|
||
uni.getFileInfo({
|
||
filePath: filePath,
|
||
success: resolve,
|
||
fail: reject
|
||
})
|
||
})
|
||
}
|
||
|
||
// 图片压缩函数 - 支持H5和APP
|
||
const compressImage = (src: string, quality: number = COMPRESS_CONFIG.value.image.quality): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
// 检查是否在H5环境
|
||
// @ts-ignore
|
||
if (typeof window !== 'undefined' && window.uni && window.uni.compressImage) {
|
||
// APP环境使用uni.compressImage
|
||
uni.compressImage({
|
||
src: src,
|
||
quality: quality,
|
||
success: (res) => {
|
||
resolve(res.tempFilePath)
|
||
},
|
||
fail: (error) => {
|
||
reject(error)
|
||
}
|
||
})
|
||
} else {
|
||
// H5环境使用Canvas压缩
|
||
compressImageWithCanvas(src, quality).then(resolve).catch(reject)
|
||
}
|
||
})
|
||
}
|
||
|
||
// H5环境下的Canvas压缩函数
|
||
const compressImageWithCanvas = (src: string, quality: number): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image()
|
||
img.crossOrigin = 'anonymous'
|
||
|
||
img.onload = () => {
|
||
try {
|
||
// 计算压缩后的尺寸
|
||
let { width, height } = calculateCompressedSize(img.width, img.height)
|
||
|
||
// 创建Canvas
|
||
const canvas = document.createElement('canvas')
|
||
const ctx = canvas.getContext('2d')
|
||
|
||
if (!ctx) {
|
||
reject(new Error('无法获取Canvas上下文'))
|
||
return
|
||
}
|
||
|
||
canvas.width = width
|
||
canvas.height = height
|
||
|
||
// 绘制压缩后的图片
|
||
ctx.drawImage(img, 0, 0, width, height)
|
||
|
||
// 转换为Blob
|
||
canvas.toBlob((blob) => {
|
||
if (blob) {
|
||
// 创建临时URL
|
||
const compressedUrl = URL.createObjectURL(blob)
|
||
resolve(compressedUrl)
|
||
} else {
|
||
reject(new Error('Canvas压缩失败'))
|
||
}
|
||
}, 'image/jpeg', quality / 100)
|
||
|
||
} catch (error) {
|
||
reject(error)
|
||
}
|
||
}
|
||
|
||
img.onerror = () => {
|
||
reject(new Error('图片加载失败'))
|
||
}
|
||
|
||
img.src = src
|
||
})
|
||
}
|
||
|
||
// 计算压缩后的尺寸
|
||
const calculateCompressedSize = (originalWidth: number, originalHeight: number): { width: number, height: number } => {
|
||
const maxWidth = COMPRESS_CONFIG.value.image.maxWidth
|
||
const maxHeight = COMPRESS_CONFIG.value.image.maxHeight
|
||
|
||
let width = originalWidth
|
||
let height = originalHeight
|
||
|
||
// 按比例缩放
|
||
if (width > maxWidth || height > maxHeight) {
|
||
const ratio = Math.min(maxWidth / width, maxHeight / height)
|
||
width = Math.floor(width * ratio)
|
||
height = Math.floor(height * ratio)
|
||
}
|
||
|
||
return { width, height }
|
||
}
|
||
|
||
// 智能图片压缩
|
||
const smartCompressImage = async (filePath: string): Promise<string> => {
|
||
try {
|
||
const fileInfo = await getFileInfo(filePath)
|
||
const originalSize = fileInfo.size
|
||
|
||
// 如果文件已经很小,直接返回
|
||
if (originalSize <= COMPRESS_CONFIG.value.image.maxSize) {
|
||
return filePath
|
||
}
|
||
|
||
// 根据文件大小计算压缩质量
|
||
let quality = COMPRESS_CONFIG.value.image.quality
|
||
if (originalSize > COMPRESS_CONFIG.value.image.maxSize * 10) {
|
||
quality = 15
|
||
} else if (originalSize > COMPRESS_CONFIG.value.image.maxSize * 5) {
|
||
quality = 25
|
||
} else if (originalSize > COMPRESS_CONFIG.value.image.maxSize * 3) {
|
||
quality = 35
|
||
} else if (originalSize > COMPRESS_CONFIG.value.image.maxSize * 2) {
|
||
quality = 45
|
||
} else if (originalSize > COMPRESS_CONFIG.value.image.maxSize) {
|
||
quality = 55
|
||
}
|
||
|
||
// 执行压缩
|
||
const compressedPath = await compressImage(filePath, quality)
|
||
|
||
// 检查压缩后的文件大小
|
||
const compressedInfo = await getFileInfo(compressedPath)
|
||
|
||
// 如果还是太大,进一步压缩
|
||
if (compressedInfo.size > COMPRESS_CONFIG.value.image.maxSize && quality > COMPRESS_CONFIG.value.image.minQuality) {
|
||
const newQuality = Math.max(quality - 10, COMPRESS_CONFIG.value.image.minQuality)
|
||
return await smartCompressImage(compressedPath)
|
||
}
|
||
|
||
return compressedPath
|
||
} catch (error) {
|
||
console.error('智能压缩失败:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 检查视频文件大小
|
||
const checkVideoSize = async (filePath: string): Promise<boolean> => {
|
||
try {
|
||
const fileInfo = await getFileInfo(filePath)
|
||
return fileInfo.size <= COMPRESS_CONFIG.value.video.maxSize
|
||
} catch (error) {
|
||
console.error('获取视频文件信息失败:', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 选择图片
|
||
const chooseImage = () => {
|
||
uni.chooseImage({
|
||
count: props.maxImageCount - imageList.value.length,
|
||
sizeType: ['original'],
|
||
sourceType: ['album', 'camera'],
|
||
success: async (res) => {
|
||
const tempFilePaths = res.tempFilePaths as string[]
|
||
|
||
showLoading('压缩图片中...')
|
||
|
||
try {
|
||
const compressedImages = []
|
||
|
||
// 并行压缩所有图片
|
||
const compressPromises = tempFilePaths.map(async (path, index) => {
|
||
try {
|
||
showLoading(`压缩图片中... (${index + 1}/${tempFilePaths.length})`)
|
||
|
||
const compressedPath = await smartCompressImage(path)
|
||
|
||
return {
|
||
tempPath: compressedPath,
|
||
name: path.split('/').pop() || 'image.jpg',
|
||
originalPath: path,
|
||
isCompressed: true
|
||
}
|
||
} catch (error) {
|
||
console.error(`图片 ${path} 压缩失败:`, error)
|
||
return {
|
||
tempPath: path,
|
||
name: path.split('/').pop() || 'image.jpg',
|
||
isCompressed: false
|
||
}
|
||
}
|
||
})
|
||
|
||
const results = await Promise.all(compressPromises)
|
||
compressedImages.push(...results)
|
||
|
||
// 添加到列表
|
||
imageList.value = [...imageList.value, ...compressedImages]
|
||
emit('update:imageList', imageList.value)
|
||
|
||
hideLoading()
|
||
|
||
// 显示压缩结果统计
|
||
const compressedCount = results.filter(r => r.isCompressed).length
|
||
if (compressedCount > 0) {
|
||
showToast({
|
||
title: `已压缩 ${compressedCount} 张图片`,
|
||
icon: 'success',
|
||
duration: 2000
|
||
})
|
||
}
|
||
|
||
// 自动上传
|
||
if (props.autoUpload && props.uploadApi) {
|
||
await uploadImages(compressedImages)
|
||
}
|
||
} catch (error) {
|
||
hideLoading()
|
||
showToast({ title: '图片处理失败', icon: 'none' })
|
||
console.error('图片处理失败:', error)
|
||
}
|
||
},
|
||
fail: (error) => {
|
||
console.error('选择图片失败:', error)
|
||
showToast({ title: '选择图片失败', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
|
||
// 选择视频
|
||
const chooseVideo = () => {
|
||
uni.chooseVideo({
|
||
sourceType: ['album', 'camera'],
|
||
maxDuration: COMPRESS_CONFIG.value.video.maxDuration,
|
||
camera: 'back',
|
||
success: async (res) => {
|
||
const tempFilePath = res.tempFilePath
|
||
|
||
try {
|
||
// 检查视频文件大小
|
||
const isValidSize = await checkVideoSize(tempFilePath)
|
||
|
||
if (!isValidSize) {
|
||
const fileInfo = await getFileInfo(tempFilePath)
|
||
const fileSizeMB = (fileInfo.size / 1024 / 1024).toFixed(1)
|
||
|
||
uni.showModal({
|
||
title: '视频文件过大',
|
||
content: `视频文件大小为 ${fileSizeMB}MB,超过限制的 ${(COMPRESS_CONFIG.value.video.maxSize / 1024 / 1024).toFixed(1)}MB,请选择较小的视频文件。`,
|
||
showCancel: false,
|
||
confirmText: '知道了'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 文件大小合适,添加到列表
|
||
const newVideo = {
|
||
tempPath: tempFilePath,
|
||
name: tempFilePath.split('/').pop() || 'video.mp4',
|
||
duration: res.duration,
|
||
size: res.size
|
||
}
|
||
|
||
videoList.value = [...videoList.value, newVideo]
|
||
emit('update:videoList', videoList.value)
|
||
|
||
// 自动上传
|
||
if (props.autoUpload && props.uploadApi) {
|
||
await uploadVideos([newVideo])
|
||
}
|
||
} catch (error) {
|
||
console.error('视频处理失败:', error)
|
||
showToast({ title: '视频处理失败', icon: 'none' })
|
||
}
|
||
},
|
||
fail: (error) => {
|
||
console.error('选择视频失败:', error)
|
||
showToast({ title: '选择视频失败', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
|
||
// 选择文件
|
||
const chooseFile = () => {
|
||
uni.chooseFile({
|
||
count: props.maxFileCount - fileList.value.length,
|
||
type: 'all',
|
||
success: async (res) => {
|
||
const tempFiles = res.tempFiles
|
||
if (Array.isArray(tempFiles) && tempFiles.length > 0) {
|
||
try {
|
||
for (const file of tempFiles) {
|
||
const fileInfo = file as any
|
||
const fileName = fileInfo.name || ''
|
||
const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''
|
||
|
||
// 检查文件类型是否被允许
|
||
if (props.allowedFileTypes && props.allowedFileTypes.length > 0) {
|
||
if (!props.allowedFileTypes.includes(fileExtension)) {
|
||
showToast({
|
||
title: `不支持的文件类型: ${fileExtension}`,
|
||
icon: 'none'
|
||
})
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 确定文件类型
|
||
let fileType = 'document'
|
||
if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(fileExtension)) {
|
||
fileType = 'image'
|
||
} else if (['mp4', 'mov', 'avi', 'wmv', 'flv'].includes(fileExtension)) {
|
||
fileType = 'video'
|
||
} else if (['mp3', 'wav', 'aac', 'ogg'].includes(fileExtension)) {
|
||
fileType = 'audio'
|
||
}
|
||
|
||
// 创建文件项
|
||
const fileItem: FileItem = {
|
||
tempPath: fileInfo.path,
|
||
name: fileName,
|
||
type: fileType,
|
||
size: fileInfo.size,
|
||
extension: fileExtension,
|
||
mimeType: fileInfo.type || ''
|
||
}
|
||
|
||
// 添加到文件列表
|
||
fileList.value.push(fileItem)
|
||
|
||
// 如果启用自动上传
|
||
if (props.autoUpload && props.uploadApi) {
|
||
await uploadFile(fileItem, fileList.value.length - 1)
|
||
}
|
||
}
|
||
|
||
// 更新父组件
|
||
emit('update:fileList', fileList.value)
|
||
|
||
} catch (error) {
|
||
console.error('文件处理失败:', error)
|
||
showToast({ title: '文件处理失败', icon: 'none' })
|
||
}
|
||
}
|
||
},
|
||
fail: (error) => {
|
||
console.error('选择文件失败:', error)
|
||
if (error.errMsg && !error.errMsg.includes('cancel')) {
|
||
showToast({ title: '选择文件失败', icon: 'none' })
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 上传文件
|
||
const uploadFile = async (fileItem: FileItem, index: number) => {
|
||
if (!props.uploadApi || !fileItem.tempPath) return
|
||
|
||
try {
|
||
const result = await props.uploadApi(fileItem.tempPath)
|
||
|
||
if (result && result.resultCode === 1 && result.result && result.result.length > 0) {
|
||
const uploadedFile = result.result[0]
|
||
|
||
// 更新文件项
|
||
fileItem.url = uploadedFile.filePath
|
||
fileItem.tempPath = undefined // 清除临时路径
|
||
|
||
// 触发成功事件
|
||
emit('file-upload-success', fileItem, index)
|
||
|
||
showToast({ title: '文件上传成功', icon: 'success' })
|
||
} else {
|
||
throw new Error('上传失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('文件上传失败:', error)
|
||
emit('file-upload-error', error, index)
|
||
showToast({ title: '文件上传失败', icon: 'none' })
|
||
|
||
// 移除上传失败的文件
|
||
fileList.value.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
// 预览文件
|
||
const previewFile = (index: number) => {
|
||
const file = fileList.value[index]
|
||
if (!file) return
|
||
|
||
if (file.type === 'image' && file.url) {
|
||
// 图片预览
|
||
uni.previewImage({
|
||
urls: [file.url],
|
||
current: file.url
|
||
})
|
||
} else if (file.url) {
|
||
// 使用公文预览方式预览其他文件
|
||
const fileUrl = imagUrl(file.url)
|
||
const fileName = file.name || '未知文件'
|
||
const fileExtension = file.extension || file.url.split('.').pop() || ''
|
||
|
||
// 统一使用 kkview 预览
|
||
const fullFileName = fileExtension ? `${fileName}.${fileExtension}` : fileName
|
||
previewFileUtil(fileUrl, fullFileName, fileExtension)
|
||
.catch((error: any) => {
|
||
uni.showToast({
|
||
title: '预览失败',
|
||
icon: 'error'
|
||
})
|
||
})
|
||
} else {
|
||
// 其他文件类型显示提示
|
||
showToast({
|
||
title: `预览 ${file.name} 功能待实现`,
|
||
icon: 'none'
|
||
})
|
||
}
|
||
}
|
||
|
||
// 移除文件
|
||
const removeFile = (index: number) => {
|
||
fileList.value.splice(index, 1)
|
||
emit('update:fileList', fileList.value)
|
||
}
|
||
|
||
// 获取文件图标
|
||
const getFileIcon = (extension: string): string => {
|
||
const iconMap: { [key: string]: string } = {
|
||
// 文档
|
||
'pdf': 'book',
|
||
'doc': 'book',
|
||
'docx': 'book',
|
||
'txt': 'book',
|
||
// 表格
|
||
'xls': 'table',
|
||
'xlsx': 'table',
|
||
// 演示文稿
|
||
'ppt': 'slideshow',
|
||
'pptx': 'slideshow',
|
||
// 音频
|
||
'mp3': 'mic',
|
||
'wav': 'mic',
|
||
'aac': 'mic',
|
||
'ogg': 'mic',
|
||
// 压缩文件
|
||
'zip': 'folder',
|
||
'rar': 'folder',
|
||
'7z': 'folder',
|
||
// 其他
|
||
'default': 'paperclip'
|
||
}
|
||
|
||
return iconMap[extension.toLowerCase()] || iconMap['default']
|
||
}
|
||
|
||
// 上传图片
|
||
const uploadImages = async (images: ImageItem[]) => {
|
||
if (!props.uploadApi) return
|
||
|
||
try {
|
||
showLoading('上传图片中...')
|
||
|
||
let successCount = 0
|
||
let failCount = 0
|
||
|
||
for (let i = 0; i < images.length; i++) {
|
||
const image = images[i]
|
||
if (image.tempPath) {
|
||
try {
|
||
showLoading(`上传图片中... (${i + 1}/${images.length})`)
|
||
emit('upload-progress', 'image', i + 1, images.length)
|
||
|
||
const uploadResult = await props.uploadApi(image.tempPath)
|
||
|
||
if (uploadResult && uploadResult.resultCode === 1 && uploadResult.result && uploadResult.result.length > 0) {
|
||
const serverPath = uploadResult.result[0].filePath
|
||
|
||
// 更新图片对象
|
||
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath)
|
||
if (index !== -1) {
|
||
imageList.value[index].url = serverPath
|
||
delete imageList.value[index].tempPath
|
||
delete imageList.value[index].originalPath
|
||
}
|
||
|
||
emit('image-upload-success', imageList.value[index], index)
|
||
successCount++
|
||
} else {
|
||
throw new Error('上传响应格式异常')
|
||
}
|
||
} catch (error) {
|
||
console.error('图片上传失败:', error)
|
||
emit('image-upload-error', error, i)
|
||
|
||
const index = imageList.value.findIndex(img => img.tempPath === image.tempPath)
|
||
if (index !== -1) {
|
||
imageList.value.splice(index, 1)
|
||
}
|
||
|
||
failCount++
|
||
}
|
||
}
|
||
}
|
||
|
||
emit('update:imageList', imageList.value)
|
||
hideLoading()
|
||
|
||
if (failCount === 0) {
|
||
showToast({ title: `成功上传 ${successCount} 张图片`, icon: 'success' })
|
||
} else {
|
||
showToast({
|
||
title: `上传完成:成功 ${successCount} 张,失败 ${failCount} 张`,
|
||
icon: 'none',
|
||
duration: 3000
|
||
})
|
||
}
|
||
} catch (error) {
|
||
hideLoading()
|
||
console.error('批量上传图片失败:', error)
|
||
showToast({ title: '图片上传失败,请重试', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
// 上传视频
|
||
const uploadVideos = async (videos: VideoItem[]) => {
|
||
if (!props.uploadApi) return
|
||
|
||
try {
|
||
showLoading('上传视频中...')
|
||
|
||
let successCount = 0
|
||
let failCount = 0
|
||
|
||
for (let i = 0; i < videos.length; i++) {
|
||
const video = videos[i]
|
||
if (video.tempPath) {
|
||
try {
|
||
showLoading(`上传视频中... (${i + 1}/${videos.length})`)
|
||
emit('upload-progress', 'video', i + 1, videos.length)
|
||
|
||
const uploadResult = await props.uploadApi(video.tempPath)
|
||
|
||
if (uploadResult && uploadResult.resultCode === 1 && uploadResult.result && uploadResult.result.length > 0) {
|
||
const serverPath = uploadResult.result[0].filePath
|
||
|
||
// 更新视频对象
|
||
const index = videoList.value.findIndex(v => v.tempPath === video.tempPath)
|
||
if (index !== -1) {
|
||
videoList.value[index].url = serverPath
|
||
delete videoList.value[index].tempPath
|
||
}
|
||
|
||
emit('video-upload-success', videoList.value[index], index)
|
||
successCount++
|
||
} else {
|
||
throw new Error('上传响应格式异常')
|
||
}
|
||
} catch (error) {
|
||
console.error('视频上传失败:', error)
|
||
emit('video-upload-error', error, i)
|
||
|
||
const index = videoList.value.findIndex(v => v.tempPath === video.tempPath)
|
||
if (index !== -1) {
|
||
videoList.value.splice(index, 1)
|
||
}
|
||
|
||
failCount++
|
||
}
|
||
}
|
||
}
|
||
|
||
emit('update:videoList', videoList.value)
|
||
hideLoading()
|
||
|
||
if (failCount === 0) {
|
||
showToast({ title: `成功上传 ${successCount} 个视频`, icon: 'success' })
|
||
} else {
|
||
showToast({
|
||
title: `上传完成:成功 ${successCount} 个,失败 ${failCount} 个`,
|
||
icon: 'none',
|
||
duration: 3000
|
||
})
|
||
}
|
||
} catch (error) {
|
||
hideLoading()
|
||
console.error('批量上传视频失败:', error)
|
||
showToast({ title: '视频上传失败,请重试', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
// 预览图片
|
||
const previewImage = (index: number) => {
|
||
const urls = imageList.value.map(img => img.url || img.tempPath).filter(Boolean)
|
||
uni.previewImage({
|
||
urls: urls,
|
||
current: index
|
||
})
|
||
}
|
||
|
||
// 预览视频
|
||
const previewVideo = (index: number) => {
|
||
const video = videoList.value[index]
|
||
if (video.url || video.tempPath) {
|
||
uni.previewVideo({
|
||
sources: [{
|
||
src: video.url || video.tempPath,
|
||
type: 'mp4'
|
||
}]
|
||
})
|
||
}
|
||
}
|
||
|
||
// 删除图片
|
||
const removeImage = (index: number) => {
|
||
imageList.value.splice(index, 1)
|
||
emit('update:imageList', imageList.value)
|
||
}
|
||
|
||
// 删除视频
|
||
const removeVideo = (index: number) => {
|
||
videoList.value.splice(index, 1)
|
||
emit('update:videoList', videoList.value)
|
||
}
|
||
|
||
// 格式化时长
|
||
const formatDuration = (seconds: number): string => {
|
||
const mins = Math.floor(seconds / 60)
|
||
const secs = Math.floor(seconds % 60)
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
// 格式化文件大小
|
||
const formatFileSize = (bytes: number): string => {
|
||
if (bytes < 1024) return bytes + 'B'
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
|
||
return (bytes / 1024 / 1024).toFixed(1) + 'MB'
|
||
}
|
||
|
||
// 工具函数
|
||
const showLoading = (title: string) => {
|
||
uni.showLoading({ title })
|
||
}
|
||
|
||
const hideLoading = () => {
|
||
uni.hideLoading()
|
||
}
|
||
|
||
const showToast = (options: { title: string; icon?: string; duration?: number }) => {
|
||
uni.showToast(options)
|
||
}
|
||
|
||
// 暴露方法给父组件
|
||
defineExpose({
|
||
chooseImage,
|
||
chooseVideo,
|
||
chooseFile,
|
||
uploadImages,
|
||
uploadVideos,
|
||
uploadFile,
|
||
removeImage,
|
||
removeVideo,
|
||
removeFile
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.image-video-upload {
|
||
width: 100%;
|
||
}
|
||
|
||
.upload-section {
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.title-text {
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
color: #333;
|
||
}
|
||
|
||
.count-text {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
margin-left: 8rpx;
|
||
}
|
||
|
||
.image-list, .video-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.image-item, .video-item {
|
||
position: relative;
|
||
width: 160rpx;
|
||
height: 160rpx;
|
||
border-radius: 8rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.image-preview {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.video-preview {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.video-thumbnail {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.play-icon {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: rgba(0, 0, 0, 0.6);
|
||
border-radius: 50%;
|
||
width: 48rpx;
|
||
height: 48rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.video-duration {
|
||
position: absolute;
|
||
bottom: 4rpx;
|
||
right: 4rpx;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
color: #fff;
|
||
font-size: 20rpx;
|
||
padding: 2rpx 6rpx;
|
||
border-radius: 4rpx;
|
||
}
|
||
|
||
.image-actions, .video-actions {
|
||
position: absolute;
|
||
top: 4rpx;
|
||
right: 4rpx;
|
||
}
|
||
|
||
.delete-btn {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.image-status {
|
||
position: absolute;
|
||
bottom: 4rpx;
|
||
left: 4rpx;
|
||
}
|
||
|
||
.compressed-tag {
|
||
background: #4CAF50;
|
||
color: #fff;
|
||
font-size: 20rpx;
|
||
padding: 2rpx 6rpx;
|
||
border-radius: 4rpx;
|
||
}
|
||
|
||
.video-info {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||
padding: 8rpx;
|
||
}
|
||
|
||
.video-name, .video-size {
|
||
color: #fff;
|
||
font-size: 20rpx;
|
||
display: block;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.video-size {
|
||
margin-top: 2rpx;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.add-btn {
|
||
width: 160rpx;
|
||
height: 160rpx;
|
||
border: 2rpx dashed #ddd;
|
||
border-radius: 8rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.add-text {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
/* 文件相关样式 */
|
||
.file-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.file-item {
|
||
display: flex;
|
||
align-items: center;
|
||
background: #f8f9fa;
|
||
border-radius: 8rpx;
|
||
padding: 16rpx;
|
||
border: 1rpx solid #e9ecef;
|
||
position: relative;
|
||
}
|
||
|
||
.file-preview {
|
||
display: flex;
|
||
align-items: center;
|
||
flex: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.file-icon {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
background: white;
|
||
border-radius: 8rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-right: 16rpx;
|
||
border: 1rpx solid #dee2e6;
|
||
}
|
||
|
||
.file-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.file-name {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
display: block;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
margin-bottom: 4rpx;
|
||
}
|
||
|
||
.file-size {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
display: block;
|
||
margin-bottom: 2rpx;
|
||
}
|
||
|
||
.file-type {
|
||
font-size: 20rpx;
|
||
color: #666;
|
||
display: block;
|
||
}
|
||
|
||
.file-actions {
|
||
position: absolute;
|
||
top: 8rpx;
|
||
right: 8rpx;
|
||
}
|
||
|
||
.file-actions .delete-btn {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
background: rgba(255, 59, 48, 0.8);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
</style>
|