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:
2026-03-03 22:15:06 +08:00
parent 2c8664b41e
commit 7b32191987
4 changed files with 329 additions and 67 deletions

View File

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

View File

@@ -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) => {
// 检查单个文件大小
@@ -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>

View File

@@ -173,6 +173,9 @@ router.beforeEach(async (to, from, next) => {
await userStore.fetchUserInfo()
}
// 4. 路由切换时更新用户额度
userStore.fetchUserProfile()
next()
})

View File

@@ -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,34 +552,9 @@ 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
// 上传完成后的回调
const handleUploadComplete = async ({ successCount, failCount }) => {
// 刷新文件列表
await loadFileList()
// 刷新存储配额
await userStore.fetchUserProfile()
@@ -592,12 +562,6 @@ const handleFileUpload = async (filesWithCover, category, groupId) => {
if (activeCategory.value === 'MIX') {
await loadGroupList()
}
} catch (error) {
console.error("文件上传失败:", error)
message.error("文件上传失败: " + (error.message || "未知错误"))
} finally {
uploadLoading.value = false
}
}