feat: enhance sidebar quota display with progress bar and improve upload modal UX
- Replace percentage-based quota with point-based display in sidebar - Add visual progress bar for remaining quota with gradient styling - Implement upload progress tracking in material upload modal - Add loading indicators and progress information during file uploads - Prevent modal interaction while uploading by disabling close controls - Show current upload status including file index and completion percentage
This commit is contained in:
@@ -66,7 +66,12 @@ const remainingPercent = computed(() => {
|
||||
>
|
||||
<div class="user-card">
|
||||
<div class="user-card__mobile">{{ maskedMobile }}</div>
|
||||
<div class="user-card__quota">剩余额度 {{ remainingPercent }}%</div>
|
||||
<div class="user-card__quota">
|
||||
<span>剩余额度 {{ userStore.remainingPoints }} 点</span>
|
||||
<div class="quota-progress">
|
||||
<div class="quota-progress__bar" :style="{ width: remainingPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</aside>
|
||||
@@ -187,4 +192,33 @@ const remainingPercent = computed(() => {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.quota-progress {
|
||||
margin-top: 6px;
|
||||
height: 6px;
|
||||
background: var(--color-gray-100);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.quota-progress__bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.quota-progress__bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
title="上传素材"
|
||||
:width="600"
|
||||
:footer="false"
|
||||
:closable="!uploading"
|
||||
:maskClosable="!uploading"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<div class="upload-modal-content">
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="upload-area">
|
||||
<div class="upload-area" v-if="!uploading">
|
||||
<a-upload-dragger
|
||||
v-model:file-list="fileList"
|
||||
name="file"
|
||||
@@ -33,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 已选文件列表 -->
|
||||
<div v-if="fileList.length > 0" class="upload-file-list">
|
||||
<div v-if="fileList.length > 0 && !uploading" class="upload-file-list">
|
||||
<div class="upload-file-list-title">
|
||||
已选择 {{ fileList.length }} 个文件,总大小:{{ getTotalSize() }}
|
||||
<span v-if="getTotalSizeBytes() > MAX_TOTAL_SIZE" style="color: #ff4d4f; margin-left: 8px;">
|
||||
@@ -61,11 +63,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传进度区域 -->
|
||||
<div v-if="uploading" class="upload-progress-area">
|
||||
<div class="upload-progress-header">
|
||||
<LoadingOutlined spin class="upload-spinner" />
|
||||
<span>正在上传 {{ currentUploadIndex }} / {{ totalUploadCount }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 总体进度 -->
|
||||
<div class="upload-total-progress">
|
||||
<div class="progress-info">
|
||||
<span>总体进度</span>
|
||||
<span>{{ totalProgress }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="totalProgress"
|
||||
:show-info="false"
|
||||
:stroke-color="{
|
||||
'0%': '#3B82F6',
|
||||
'100%': '#10B981'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 当前文件进度 -->
|
||||
<div class="upload-current-file">
|
||||
<div class="current-file-name">
|
||||
<FileOutlined />
|
||||
<span>{{ currentFileName }}</span>
|
||||
</div>
|
||||
<div class="current-file-progress">
|
||||
<div class="progress-info">
|
||||
<span>{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize) }}</span>
|
||||
<span>{{ currentFileProgress }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="currentFileProgress"
|
||||
:show-info="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已完成文件列表 -->
|
||||
<div v-if="completedFiles.length > 0" class="completed-files">
|
||||
<div class="completed-header">
|
||||
<CheckCircleOutlined style="color: #10B981" />
|
||||
<span>已完成 {{ completedFiles.length }} 个文件</span>
|
||||
</div>
|
||||
<div class="completed-list">
|
||||
<div v-for="file in completedFiles" :key="file.name" class="completed-item">
|
||||
<CheckOutlined style="color: #10B981; font-size: 12px" />
|
||||
<span>{{ file.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="upload-actions">
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">取消</a-button>
|
||||
<a-button v-if="!uploading" @click="handleCancel">取消</a-button>
|
||||
<a-button
|
||||
v-if="!uploading"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="fileList.length === 0"
|
||||
@@ -73,6 +133,7 @@
|
||||
>
|
||||
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
|
||||
</a-button>
|
||||
<span v-else class="upload-tip">上传中请勿关闭窗口...</span>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,11 +141,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UploadOutlined, FileOutlined } from '@ant-design/icons-vue'
|
||||
import { UploadOutlined, FileOutlined, LoadingOutlined, CheckCircleOutlined, CheckOutlined } from '@ant-design/icons-vue'
|
||||
import { isVideoFile, extractVideoCover } from '@/utils/video-cover'
|
||||
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@@ -105,7 +167,10 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
|
||||
const emit = defineEmits(['update:visible', 'confirm', 'cancel', 'complete'])
|
||||
|
||||
// Hooks
|
||||
const { upload } = useUpload()
|
||||
|
||||
// 数据
|
||||
const fileList = ref([])
|
||||
@@ -117,6 +182,24 @@ const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png
|
||||
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024 // 1GB
|
||||
const MAX_SINGLE_FILE_SIZE = 1 * 1024 * 1024 * 1024 // 1GB (单个文件最大1GB)
|
||||
|
||||
// 上传状态
|
||||
const uploading = ref(false)
|
||||
const currentUploadIndex = ref(0)
|
||||
const totalUploadCount = ref(0)
|
||||
const currentFileName = ref('')
|
||||
const currentFileProgress = ref(0)
|
||||
const currentFileSize = ref(0)
|
||||
const currentFileUploaded = ref(0)
|
||||
const completedFiles = ref([])
|
||||
|
||||
// 计算总进度
|
||||
const totalProgress = computed(() => {
|
||||
if (totalUploadCount.value === 0) return 0
|
||||
const baseProgress = ((currentUploadIndex.value - 1) / totalUploadCount.value) * 100
|
||||
const currentContribution = (currentFileProgress.value / 100) / totalUploadCount.value * 100
|
||||
return Math.round(baseProgress + currentContribution)
|
||||
})
|
||||
|
||||
// 计算总大小(字节)
|
||||
const getTotalSizeBytes = () => {
|
||||
return fileList.value.reduce((total, item) => {
|
||||
@@ -134,9 +217,22 @@ watch(() => props.visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
fileList.value = []
|
||||
fileCoverMap.value.clear()
|
||||
resetUploadState()
|
||||
}
|
||||
})
|
||||
|
||||
// 重置上传状态
|
||||
const resetUploadState = () => {
|
||||
uploading.value = false
|
||||
currentUploadIndex.value = 0
|
||||
totalUploadCount.value = 0
|
||||
currentFileName.value = ''
|
||||
currentFileProgress.value = 0
|
||||
currentFileSize.value = 0
|
||||
currentFileUploaded.value = 0
|
||||
completedFiles.value = []
|
||||
}
|
||||
|
||||
// 上传前处理
|
||||
const handleBeforeUpload = (file) => {
|
||||
// 检查单个文件大小
|
||||
@@ -172,7 +268,7 @@ const handleBeforeUpload = (file) => {
|
||||
// 文件列表变化
|
||||
const handleFileChange = async (info) => {
|
||||
const { file, fileList: newFileList } = info
|
||||
|
||||
|
||||
if (file && file.status !== 'uploading') {
|
||||
fileList.value = newFileList
|
||||
.map(item => {
|
||||
@@ -182,12 +278,12 @@ const handleFileChange = async (info) => {
|
||||
return item
|
||||
})
|
||||
.filter(item => item.status !== 'removed')
|
||||
|
||||
|
||||
// 如果是视频文件,自动提取封面
|
||||
const fileObj = file.file || file.originFileObj || file
|
||||
if (fileObj instanceof File && isVideoFile(fileObj)) {
|
||||
const fileKey = file.uid || fileObj.name
|
||||
|
||||
|
||||
if (!fileCoverMap.value.has(fileKey)) {
|
||||
try {
|
||||
const coverResult = await extractVideoCover(fileObj, {
|
||||
@@ -206,8 +302,8 @@ const handleFileChange = async (info) => {
|
||||
|
||||
// 移除文件
|
||||
const handleRemove = (fileItem) => {
|
||||
const index = fileList.value.findIndex(item =>
|
||||
(item.uid && item.uid === fileItem.uid) ||
|
||||
const index = fileList.value.findIndex(item =>
|
||||
(item.uid && item.uid === fileItem.uid) ||
|
||||
(getFileName(item) === getFileName(fileItem))
|
||||
)
|
||||
if (index > -1) {
|
||||
@@ -219,7 +315,7 @@ const handleRemove = (fileItem) => {
|
||||
}
|
||||
|
||||
// 确认上传
|
||||
const handleConfirm = () => {
|
||||
const handleConfirm = async () => {
|
||||
if (fileList.value.length === 0) {
|
||||
message.warning('请选择文件')
|
||||
return
|
||||
@@ -250,9 +346,82 @@ const handleConfirm = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 开始上传
|
||||
await performUpload(filesWithCover)
|
||||
}
|
||||
|
||||
// 执行上传
|
||||
const performUpload = async (filesWithCover) => {
|
||||
uploading.value = true
|
||||
totalUploadCount.value = filesWithCover.length
|
||||
currentUploadIndex.value = 0
|
||||
completedFiles.value = []
|
||||
|
||||
// 使用传入的fileCategory,如果没有则使用默认值
|
||||
const category = props.fileCategory || DEFAULT_FILE_CATEGORY
|
||||
emit('confirm', filesWithCover, category, props.groupId)
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (let i = 0; i < filesWithCover.length; i++) {
|
||||
const fileWithCover = filesWithCover[i]
|
||||
currentUploadIndex.value = i + 1
|
||||
currentFileName.value = fileWithCover.file.name
|
||||
currentFileSize.value = fileWithCover.file.size
|
||||
currentFileUploaded.value = 0
|
||||
currentFileProgress.value = 0
|
||||
|
||||
try {
|
||||
await upload(fileWithCover.file, {
|
||||
fileCategory: category,
|
||||
groupId: props.groupId,
|
||||
coverBase64: fileWithCover.coverBase64,
|
||||
onProgress: (progress) => {
|
||||
currentFileProgress.value = progress
|
||||
currentFileUploaded.value = Math.round((progress / 100) * currentFileSize.value)
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('文件上传成功:', fileWithCover.file.name)
|
||||
successCount++
|
||||
completedFiles.value.push({
|
||||
name: fileWithCover.file.name,
|
||||
success: true
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('文件上传失败:', fileWithCover.file.name, error)
|
||||
failCount++
|
||||
completedFiles.value.push({
|
||||
name: fileWithCover.file.name,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('上传异常:', error)
|
||||
failCount++
|
||||
completedFiles.value.push({
|
||||
name: fileWithCover.file.name,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
uploading.value = false
|
||||
|
||||
// 显示结果
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
message.success(`成功上传 ${successCount} 个文件`)
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
message.warning(`上传完成:${successCount} 个成功,${failCount} 个失败`)
|
||||
} else {
|
||||
message.error('上传失败,请重试')
|
||||
}
|
||||
|
||||
// 通知父组件上传完成
|
||||
emit('complete', { successCount, failCount })
|
||||
|
||||
// 关闭弹窗
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 处理 visible 变化
|
||||
@@ -262,6 +431,7 @@ const handleVisibleChange = (value) => {
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
if (uploading.value) return
|
||||
emit('update:visible', false)
|
||||
emit('cancel')
|
||||
}
|
||||
@@ -335,13 +505,104 @@ const handleCancel = () => {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.upload-tips {
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
.upload-tip {
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-tips > div {
|
||||
margin-bottom: 4px;
|
||||
/* 上传进度区域 */
|
||||
.upload-progress-area {
|
||||
padding: 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.upload-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.upload-spinner {
|
||||
font-size: 20px;
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
.upload-total-progress {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.upload-current-file {
|
||||
padding: 12px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.current-file-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.current-file-name span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.current-file-progress .progress-info {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.completed-files {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.completed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.completed-list {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.completed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.completed-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -173,6 +173,9 @@ router.beforeEach(async (to, from, next) => {
|
||||
await userStore.fetchUserInfo()
|
||||
}
|
||||
|
||||
// 4. 路由切换时更新用户额度
|
||||
userStore.fetchUserProfile()
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
@@ -241,8 +241,8 @@
|
||||
<MaterialUploadModal
|
||||
v-model:visible="uploadModalVisible"
|
||||
:group-id="selectedGroupId"
|
||||
:uploading="uploadLoading"
|
||||
@confirm="handleFileUpload"
|
||||
:file-category="activeCategory === 'DIGITAL_HUMAN' ? 'digital_human' : 'video'"
|
||||
@complete="handleUploadComplete"
|
||||
/>
|
||||
|
||||
<!-- 新建分组弹窗 -->
|
||||
@@ -284,7 +284,6 @@ import {
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue';
|
||||
import MaterialService, { MaterialGroupService } from '@/api/material';
|
||||
import { useUpload } from '@/composables/useUpload';
|
||||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
@@ -302,7 +301,6 @@ const searchKeyword = ref('')
|
||||
|
||||
// 模态框状态
|
||||
const uploadModalVisible = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
const createGroupModalVisible = ref(false)
|
||||
|
||||
// 表单数据
|
||||
@@ -329,9 +327,6 @@ const pagination = reactive({
|
||||
total: 0
|
||||
})
|
||||
|
||||
// Hooks
|
||||
const { upload } = useUpload()
|
||||
|
||||
// 方法
|
||||
const handleCategoryChange = async (category) => {
|
||||
activeCategory.value = category
|
||||
@@ -557,46 +552,15 @@ const handleOpenUploadModal = () => {
|
||||
uploadModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleFileUpload = async (filesWithCover, category, groupId) => {
|
||||
try {
|
||||
uploadLoading.value = true
|
||||
|
||||
// 根据当前分类确定 fileCategory
|
||||
// 混剪素材用 video,数字人素材用 digital_human
|
||||
const fileCategory = activeCategory.value === 'DIGITAL_HUMAN' ? 'digital_human' : 'video'
|
||||
// 数字人素材不关联分组
|
||||
const uploadGroupId = activeCategory.value === 'DIGITAL_HUMAN' ? null : groupId
|
||||
|
||||
for (const fileWithCover of filesWithCover) {
|
||||
await upload(fileWithCover.file, {
|
||||
fileCategory,
|
||||
groupId: uploadGroupId,
|
||||
coverBase64: fileWithCover.coverBase64,
|
||||
onStart: () => {},
|
||||
onProgress: () => {},
|
||||
onSuccess: (id) => {
|
||||
console.log('文件上传成功:', id)
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error.message || '上传失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
message.success(`成功上传 ${filesWithCover.length} 个文件`)
|
||||
uploadModalVisible.value = false
|
||||
await loadFileList()
|
||||
// 刷新存储配额
|
||||
await userStore.fetchUserProfile()
|
||||
// 混剪素材才刷新分组列表
|
||||
if (activeCategory.value === 'MIX') {
|
||||
await loadGroupList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("文件上传失败:", error)
|
||||
message.error("文件上传失败: " + (error.message || "未知错误"))
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
// 上传完成后的回调
|
||||
const handleUploadComplete = async ({ successCount, failCount }) => {
|
||||
// 刷新文件列表
|
||||
await loadFileList()
|
||||
// 刷新存储配额
|
||||
await userStore.fetchUserProfile()
|
||||
// 混剪素材才刷新分组列表
|
||||
if (activeCategory.value === 'MIX') {
|
||||
await loadGroupList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user