zhxy-jsd/src/components/ImageVideoUpload/ImageVideoUpload.vue
2025-10-07 08:58:02 +08:00

1177 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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