功能优化

This commit is contained in:
2025-11-16 19:35:55 +08:00
parent c2bd94cfad
commit bdbe74cebb
53 changed files with 8235 additions and 107 deletions

View File

@@ -0,0 +1,105 @@
# 前端上传逻辑检查报告
## 📋 代码检查结果
### ✅ 正常逻辑
1. **MaterialUploadModal.vue**
- 文件上传组件逻辑清晰
- 文件列表管理合理
- 文件大小和类型校验完善
2. **MaterialList.vue**
- 上传流程完整
- 错误处理合理
- 批量上传逻辑正确
### ⚠️ 发现的问题
#### 1. 冗余代码
**MaterialUploadModal.vue**:
- `handleDrop` 方法第188-191行只是打印日志没有实际作用
```javascript
const handleDrop = (e) => {
// a-upload-dragger 会自动处理拖拽的文件,通过 change 事件触发
console.log('Drop event:', e)
}
```
**建议**:删除此方法,因为 `a-upload-dragger` 会自动处理拖拽
#### 2. 逻辑问题
**MaterialList.vue**:
- `uploadFileCategory` 固定为 'video'第147行
```javascript
const uploadFileCategory = ref('video') // 固定为 video不需要用户选择
```
**问题**:用户无法选择文件分类,所有文件都上传到 video 分类
**建议**:添加文件分类选择功能,或者根据文件类型自动判断分类
#### 3. 代码优化建议
**MaterialUploadModal.vue**:
- `handleFileChange` 方法第160-174行逻辑可以简化
- 文件对象提取逻辑第201-217行虽然复杂但是必要的因为 Ant Design Vue 的文件对象结构复杂)
## 🎯 优化建议
### 1. 删除冗余的 handleDrop 方法
```javascript
// 删除这个方法,因为 a-upload-dragger 会自动处理拖拽
// const handleDrop = (e) => {
// console.log('Drop event:', e)
// }
```
### 2. 添加文件分类选择功能
在 `MaterialUploadModal.vue` 中添加分类选择:
```vue
<!-- 文件分类选择 -->
<div class="upload-category-select">
<div class="upload-label">文件分类:</div>
<a-select
v-model="fileCategory"
placeholder="请选择文件分类"
style="width: 100%"
size="large"
>
<a-option value="video">视频集</a-option>
<a-option value="generate">生成集</a-option>
<a-option value="audio">配音集</a-option>
<a-option value="mix">混剪集</a-option>
<a-option value="voice">声音集</a-option>
</a-select>
</div>
```
然后在 `MaterialList.vue` 中接收分类参数:
```javascript
const handleConfirmUpload = async (files, fileCategory) => {
// 使用传入的分类,而不是固定的 'video'
await MaterialService.uploadFile(file, fileCategory)
}
```
## 📊 总结
| 项目 | 状态 | 说明 |
|------|------|------|
| 上传逻辑 | ✅ 正常 | 文件上传流程完整 |
| 错误处理 | ✅ 正常 | 错误提示和异常处理完善 |
| 文件校验 | ✅ 正常 | 文件大小和类型校验合理 |
| 冗余代码 | ⚠️ 1处 | `handleDrop` 方法无实际作用 |
| 功能缺失 | ⚠️ 1处 | 缺少文件分类选择功能 |
## 🔧 建议修复
1. **删除冗余代码**:移除 `handleDrop` 方法
2. **添加分类选择**:在 `MaterialUploadModal` 中添加文件分类选择功能
3. **优化用户体验**:根据文件类型自动推荐分类(可选)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
/**
* 素材库 API 服务
* 对应后端 tik 模块的文件管理接口
*/
import http from './http'
import { API_BASE } from '@gold/config/api'
// 统一使用 /api/tik 前缀,与 tikhub 保持一致
const BASE_URL = `${API_BASE.APP}/api/tik/file`
/**
* 素材库 API 服务
*/
export const MaterialService = {
/**
* 分页查询文件列表
* @param {Object} params - 查询参数
* @param {number} params.pageNo - 页码
* @param {number} params.pageSize - 每页数量
* @param {string} params.fileCategory - 文件分类video/generate/audio/mix/voice
* @param {string} params.fileName - 文件名(模糊查询)
* @param {string} params.fileType - 文件类型
* @param {number} params.groupId - 分组编号
* @param {Array} params.createTime - 创建时间范围 [开始时间, 结束时间]
* @returns {Promise}
*/
getFilePage(params) {
return http.get(`${BASE_URL}/page`, { params })
},
/**
* 上传文件
* @param {FormData} formData - 文件表单数据
* @param {string} fileCategory - 文件分类video/generate/audio/mix/voice
* @returns {Promise}
*/
uploadFile(file, fileCategory) {
const formData = new FormData()
formData.append('file', file)
formData.append('fileCategory', fileCategory)
return http.post(`${BASE_URL}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
/**
* 删除文件(批量)
* @param {Array<number>} fileIds - 文件编号列表
* @returns {Promise}
*/
deleteFiles(fileIds) {
// 根据后端接口,使用 delete-batch 接口
return http.delete(`${BASE_URL}/delete-batch`, {
params: { ids: fileIds.join(',') }
})
},
/**
* 获取视频播放URL
* @param {number} fileId - 文件编号
* @returns {Promise}
*/
getVideoPlayUrl(fileId) {
return http.get(`${BASE_URL}/video/play-url`, {
params: { id: fileId }
})
},
/**
* 获取音频播放URL
* @param {number} fileId - 文件编号
* @returns {Promise}
*/
getAudioPlayUrl(fileId) {
return http.get(`${BASE_URL}/audio/play-url`, {
params: { id: fileId }
})
},
/**
* 获取预览URL
* @param {number} fileId - 文件编号
* @param {string} type - 预览类型thumbnail/cover
* @returns {Promise}
*/
getPreviewUrl(fileId, type = 'thumbnail') {
return http.get(`${BASE_URL}/preview-url`, {
params: { id: fileId, type }
})
}
}
/**
* 素材分组 API 服务
*/
const GROUP_BASE_URL = `${API_BASE.APP}/api/tik/file/group`
export const MaterialGroupService = {
/**
* 创建分组
* @param {Object} data - 分组数据
* @param {string} data.name - 分组名称
* @param {string} data.description - 分组描述
* @param {number} data.sort - 排序
* @param {string} data.icon - 分组图标
* @param {number} data.parentId - 父分组编号
* @returns {Promise}
*/
createGroup(data) {
return http.post(`${GROUP_BASE_URL}/create`, data)
},
/**
* 更新分组
* @param {Object} data - 分组数据
* @param {number} data.id - 分组编号
* @param {string} data.name - 分组名称
* @param {string} data.description - 分组描述
* @param {number} data.sort - 排序
* @param {string} data.icon - 分组图标
* @param {number} data.parentId - 父分组编号
* @returns {Promise}
*/
updateGroup(data) {
return http.put(`${GROUP_BASE_URL}/update`, data)
},
/**
* 删除分组
* @param {number} groupId - 分组编号
* @returns {Promise}
*/
deleteGroup(groupId) {
return http.delete(`${GROUP_BASE_URL}/delete`, {
params: { id: groupId }
})
},
/**
* 查询分组列表
* @returns {Promise}
*/
getGroupList() {
return http.get(`${GROUP_BASE_URL}/list`)
},
/**
* 将文件添加到分组
* @param {Object} data - 请求数据
* @param {Array<number>} data.fileIds - 文件编号列表
* @param {Array<number>} data.groupIds - 分组编号列表
* @returns {Promise}
*/
addFilesToGroups(data) {
return http.post(`${GROUP_BASE_URL}/add-files`, data)
},
/**
* 从分组移除文件
* @param {Object} data - 请求数据
* @param {Array<number>} data.fileIds - 文件编号列表
* @param {Array<number>} data.groupIds - 分组编号列表
* @returns {Promise}
*/
removeFilesFromGroups(data) {
return http.post(`${GROUP_BASE_URL}/remove-files`, data)
}
}
export default MaterialService

View File

@@ -0,0 +1,49 @@
/**
* 测试 API 服务
* 用于测试升级会员和创建OSS目录
*/
import http from './http'
import { API_BASE } from '@gold/config/api'
const BASE_URL = `${API_BASE.APP}/api/tik/test`
/**
* 测试 API 服务
*/
export const TestService = {
/**
* 升级会员
* @param {Object} params - 参数
* @param {number} params.vipLevel - VIP等级
* @param {number} params.totalStorage - 总存储空间(字节)
* @param {number} params.totalQuota - 总配额(积分/额度)
* @returns {Promise}
*/
upgradeVip(params = {}) {
return http.post(`${BASE_URL}/upgrade-vip`, null, { params })
},
/**
* 初始化OSS目录
* @returns {Promise}
*/
initOss() {
return http.post(`${BASE_URL}/init-oss`)
},
/**
* 一键测试(升级会员 + 初始化OSS
* @param {Object} params - 参数
* @param {number} params.vipLevel - VIP等级
* @param {number} params.totalStorage - 总存储空间(字节)
* @param {number} params.totalQuota - 总配额(积分/额度)
* @returns {Promise}
*/
testAll(params = {}) {
return http.post(`${BASE_URL}/test-all`, null, { params })
}
}
export default TestService

View File

@@ -1,9 +1,11 @@
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 单色 SVG 图标(填充 currentColor可继承文本色
const icons = {
@@ -12,12 +14,14 @@ const icons = {
text: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M4 7h16"/><path d="M4 12h10"/><path d="M4 17h14"/></svg>',
mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><path d="M12 19v3"/></svg>',
wave: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M2 12s2-4 5-4 3 8 6 8 3-8 6-8 3 4 3 4"/></svg>',
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="7" r="4"/><path d="M5.5 21a8.38 8.38 0 0 1 13 0"/></svg>'
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="7" r="4"/><path d="M5.5 21a8.38 8.38 0 0 1 13 0"/></svg>',
video: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="m22 8-6 4 6 4V8Z"/><rect x="2" y="6" width="14" height="12" rx="2" ry="2"/></svg>',
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>'
}
const items = computed(() => {
// 小标题(功能) + 模块(子菜单)的形式;使用单色 SVG 图标
return [
const allItems = [
{
title: '功能',
children: [
@@ -28,10 +32,18 @@ const items = computed(() => {
]
},
{
title: '配音',
title: '数字人',
children: [
{ path: '/digital-human/voice-copy', label: '人声克隆', icon: 'mic' },
{ path: '/digital-human/voice-generate', label: '生成配音', icon: 'wave' },
{ path: '/digital-human/video', label: '数字人视频', icon: 'video' },
]
},
{
title: '素材库',
children: [
{ path: '/material/list', label: '素材列表', icon: 'grid' },
{ path: '/material/group', label: '素材分组', icon: 'folder' },
]
},
{
@@ -47,6 +59,13 @@ const items = computed(() => {
// ]
// },
]
// 如果未登录,过滤掉"系统"菜单组
if (!userStore.isLoggedIn) {
return allItems.filter(item => item.title !== '系统')
}
return allItems
})
function go(p) {

View File

@@ -1,8 +1,10 @@
<script setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { useUserStore } from '@/stores/user'
import LoginModal from '@/components/LoginModal.vue'
import UserDropdown from '@/components/UserDropdown.vue'
import TestService from '@/api/test'
const styles = {
background: 'var(--color-surface)',
@@ -11,6 +13,34 @@ const styles = {
// const route = useRoute()
const userStore = useUserStore()
const showLogin = ref(false)
const testLoading = ref(false)
// 测试按钮点击事件
const handleTest = async () => {
if (testLoading.value) return
testLoading.value = true
try {
// 调用一键测试接口(升级会员 + 初始化OSS
const res = await TestService.testAll({
vipLevel: 1,
totalStorage: 10 * 1024 * 1024 * 1024, // 10GB
totalQuota: 10000
})
if (res.code === 0) {
message.success('测试成功已升级会员并创建OSS目录', 3)
console.log('OSS初始化信息:', res.data)
} else {
message.error(res.msg || '测试失败', 3)
}
} catch (error) {
console.error('测试失败:', error)
message.error(error?.response?.data?.msg || error?.message || '测试失败', 3)
} finally {
testLoading.value = false
}
}
// 计算是否应该显示用户组件
// 判断用户是否有用户名,有用户名说明用户信息已加载完成
@@ -39,6 +69,15 @@ const shouldShowUser = computed(() => {
<!-- 左侧可放 logo 或其他内容 -->
</div>
<div class="flex items-center gap-4 pr-[35px]">
<!-- 测试按钮仅开发环境显示 -->
<button
v-if="shouldShowUser"
class="btn-test-nav"
:disabled="testLoading"
@click="handleTest"
>
{{ testLoading ? '测试中...' : '测试' }}
</button>
<template v-if="shouldShowUser">
<UserDropdown />
@@ -112,4 +151,28 @@ const shouldShowUser = computed(() => {
box-shadow: var(--glow-primary);
filter: brightness(1.03);
}
.btn-test-nav {
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all .2s ease;
}
.btn-test-nav:hover:not(:disabled) {
background: var(--color-bg);
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-test-nav:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,322 @@
<template>
<a-modal
:visible="visible"
title="上传素材"
:width="600"
:footer="false"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
>
<div class="upload-modal-content">
<!-- 文件上传区域 -->
<div class="upload-area">
<a-upload-dragger
v-model:file-list="fileList"
name="file"
:multiple="true"
:accept="acceptTypes"
action=""
:before-upload="handleBeforeUpload"
:show-upload-list="false"
@change="handleFileChange"
>
<p class="ant-upload-drag-icon">
<UploadOutlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到此处上传</p>
<p class="ant-upload-hint">
支持多文件上传单个文件不超过 500MB
<br />
支持格式视频MP4MOVAVI等图片JPGPNGGIF等音频MP3WAV等
</p>
</a-upload-dragger>
</div>
<!-- 已选文件列表 -->
<div v-if="fileList.length > 0" class="upload-file-list">
<div class="upload-file-list-title">已选择 {{ fileList.length }} 个文件</div>
<div class="upload-file-items">
<div
v-for="(fileItem, index) in fileList"
:key="fileItem.uid || index"
class="upload-file-item"
>
<FileOutlined class="file-icon" />
<span class="file-name">{{ getFileName(fileItem) }}</span>
<span class="file-size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
<a-button
type="text"
status="danger"
size="small"
@click="handleRemove(fileItem)"
>
删除
</a-button>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="upload-actions">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:loading="uploading"
:disabled="fileList.length === 0 || !fileCategory"
@click="handleConfirm"
>
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
</a-button>
</a-space>
</div>
</div>
</a-modal>
</template>
<script setup>
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined, FileOutlined } from '@ant-design/icons-vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
uploading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
// 数据
const fileList = ref([])
const fileCategory = ref('video') // 文件分类,默认为视频集
// 支持的文件类型
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
// 监听 visible 变化,重置文件列表和分类
watch(() => props.visible, (newVal) => {
if (!newVal) {
fileList.value = []
fileCategory.value = 'video' // 重置为默认分类
}
})
// 获取文件名
const getFileName = (fileItem) => {
if (fileItem instanceof File) {
return fileItem.name
}
return fileItem.name || fileItem.file?.name || fileItem.originFileObj?.name || '未知文件'
}
// 获取文件大小
const getFileSize = (fileItem) => {
if (fileItem instanceof File) {
return fileItem.size
}
return fileItem.size || fileItem.file?.size || fileItem.originFileObj?.size || 0
}
// 格式化文件大小
const formatFileSize = (bytes) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
// 上传前处理
const handleBeforeUpload = (file) => {
// 检查文件大小500MB
if (file.size > 500 * 1024 * 1024) {
message.warning(`文件 ${file.name} 超过 500MB已跳过`)
return false
}
// 检查是否已存在相同文件(使用当前的 fileList
const exists = fileList.value.some(item => {
const itemName = getFileName(item)
const itemSize = getFileSize(item)
return itemName === file.name && itemSize === file.size
})
if (exists) {
message.warning(`文件 ${file.name} 已存在,已跳过`)
return false
}
// 阻止自动上传,文件会通过 change 事件添加到列表
return false
}
// 文件列表变化
const handleFileChange = (info) => {
// 使用 v-model:file-list 后fileList 会自动更新
// 这里只需要处理文件验证和状态
const { file, fileList: newFileList } = info
if (file && file.status !== 'uploading') {
// 确保文件对象正确保存
fileList.value = newFileList.map(item => {
if (!item.file && item.originFileObj) {
item.file = item.originFileObj
}
return item
}).filter(item => item.status !== 'removed')
}
}
// 移除文件
const handleRemove = (fileItem) => {
const index = fileList.value.findIndex(item =>
(item.uid && item.uid === fileItem.uid) ||
(getFileName(item) === getFileName(fileItem))
)
if (index > -1) {
fileList.value.splice(index, 1)
}
}
// 确认上传
const handleConfirm = () => {
if (fileList.value.length === 0) {
message.warning('请选择文件')
return
}
// 提取文件对象,优先使用 file其次 originFileObj最后是 item 本身
const files = fileList.value
.map(item => {
// 优先使用 file 属性
if (item.file instanceof File) {
return item.file
}
// 其次使用 originFileObj
if (item.originFileObj instanceof File) {
return item.originFileObj
}
// 最后尝试 item 本身(如果是 File 对象)
if (item instanceof File) {
return item
}
return null
})
.filter(file => file instanceof File)
if (files.length === 0) {
message.error('无法获取文件对象,请重新选择文件')
return
}
if (!fileCategory.value) {
message.warning('请选择文件分类')
return
}
emit('confirm', files, fileCategory.value)
}
// 处理 visible 变化
const handleVisibleChange = (value) => {
emit('update:visible', value)
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
emit('cancel')
}
</script>
<style scoped>
.upload-modal-content {
padding: 8px 0;
}
.upload-category-select {
margin-bottom: 24px;
}
.upload-label {
margin-bottom: 8px;
font-weight: 500;
color: var(--color-text);
}
.upload-area {
margin-bottom: 24px;
}
.upload-file-list {
margin-bottom: 24px;
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
}
.upload-file-list-title {
font-weight: 500;
margin-bottom: 12px;
color: var(--color-text);
}
.upload-file-items {
max-height: 200px;
overflow-y: auto;
}
.upload-file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: 4px;
transition: background 0.2s;
}
.upload-file-item:hover {
background: var(--color-bg-2);
}
.file-icon {
font-size: 20px;
color: var(--color-text-3);
}
.file-name {
flex: 1;
font-size: 14px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: var(--color-text-3);
}
.upload-actions {
text-align: right;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
.upload-tips {
font-size: 12px;
line-height: 1.8;
}
.upload-tips > div {
margin-bottom: 4px;
}
</style>

View File

@@ -35,6 +35,16 @@ const routes = [
{ path: 'voice-copy', name: '人声克隆', component: () => import('../views/dh/VoiceCopy.vue') },
{ path: 'voice-generate', name: '生成配音', component: () => import('../views/dh/VoiceGenerate.vue') },
{ path: 'avatar', name: '生成数字人', component: () => import('../views/dh/Avatar.vue') },
{ path: 'video', name: '数字人视频', component: () => import('../views/dh/Video.vue') },
]
},
{
path: '/material',
name: '素材库',
children: [
{ path: '', redirect: '/material/list' },
{ path: 'list', name: '素材列表', component: () => import('../views/material/MaterialList.vue') },
{ path: 'group', name: '素材分组', component: () => import('../views/material/MaterialGroup.vue') },
]
},
{
@@ -62,10 +72,13 @@ const router = createRouter({
let userInfoInitialized = false
/**
* 路由导航守卫:初始化用户信息
* 路由导航守卫:初始化用户信息 + 登录验证
* 在首次路由跳转时,如果已登录(有 token则获取用户信息
* 如果未登录访问系统相关路由,则重定向到首页
*/
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 只在首次路由跳转时初始化用户信息
if (!userInfoInitialized) {
userInfoInitialized = true
@@ -73,7 +86,6 @@ router.beforeEach(async (to, from, next) => {
const token = getToken()
if (token) {
try {
const userStore = useUserStore()
// 如果 store 中已标记为登录,则获取用户信息
if (userStore.isLoggedIn) {
userStore.fetchUserInfo()
@@ -90,6 +102,22 @@ router.beforeEach(async (to, from, next) => {
}
}
// 检查访问系统相关路由时是否已登录
if (to.path.startsWith('/system')) {
// 等待 store 从本地存储恢复完成最多等待500ms
let waitCount = 0
while (!userStore.isHydrated && waitCount < 50) {
await new Promise(resolve => setTimeout(resolve, 10))
waitCount++
}
// 如果未登录,重定向到首页
if (!userStore.isLoggedIn) {
next({ path: '/content-style/benchmark', replace: true })
return
}
}
// 继续路由跳转
next()
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
<template>
<div class="material-group">
<div class="material-group__header">
<h1 class="material-group__title">素材分组</h1>
<a-button type="primary" @click="handleCreateGroup">
<template #icon>
<PlusOutlined />
</template>
新建分组
</a-button>
</div>
<!-- 分组列表 -->
<div class="material-group__content">
<a-spin :spinning="loading" tip="加载中..." style="width: 100%; min-height: 400px;">
<template v-if="groupList.length > 0">
<a-list
:data="groupList"
:bordered="false"
>
<template #item="{ item }">
<a-list-item class="group-item">
<a-list-item-meta>
<template #title>
<div class="group-item__header">
<span class="group-item__name">{{ item.name }}</span>
<a-tag>{{ item.fileCount || 0 }} 个文件</a-tag>
</div>
</template>
<template #description>
<div class="group-item__description">
{{ item.description || '暂无描述' }}
</div>
<div class="group-item__meta">
<span>创建时间{{ formatDate(item.createTime) }}</span>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a-button type="text" @click="handleEditGroup(item)">
<template #icon>
<EditOutlined />
</template>
编辑
</a-button>
<a-button type="text" status="danger" @click="handleDeleteGroup(item)">
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</template>
<template v-else>
<a-empty description="暂无分组" />
</template>
</a-spin>
</div>
<!-- 创建/编辑分组对话框 -->
<a-modal
v-model:visible="groupModalVisible"
:title="groupModalTitle"
@ok="handleSaveGroup"
@cancel="handleCancelGroup"
>
<a-form :model="groupForm" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-form-item label="分组名称" field="name" :rules="[{ required: true, message: '请输入分组名称' }]">
<a-input v-model="groupForm.name" placeholder="请输入分组名称" />
</a-form-item>
<a-form-item label="分组描述" field="description">
<a-textarea
v-model="groupForm.description"
placeholder="请输入分组描述"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
<a-form-item label="排序" field="sort">
<a-input-number v-model="groupForm.sort" :min="0" placeholder="排序值" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import { MaterialGroupService } from '@/api/material'
// 数据
const loading = ref(false)
const groupList = ref([])
const groupModalVisible = ref(false)
const groupModalTitle = ref('新建分组')
const isEdit = ref(false)
// 表单
const groupForm = reactive({
id: undefined,
name: '',
description: '',
sort: 0
})
// 加载分组列表
const loadGroupList = async () => {
if (loading.value) return // 防止重复请求
loading.value = true
try {
const res = await MaterialGroupService.getGroupList()
if (res.code === 0) {
groupList.value = res.data || []
} else {
message.error(res.msg || '加载失败')
groupList.value = []
}
} catch (error) {
console.error('加载分组列表失败:', error)
message.error('加载失败,请重试')
groupList.value = []
} finally {
loading.value = false
}
}
// 创建分组
const handleCreateGroup = () => {
isEdit.value = false
groupModalTitle.value = '新建分组'
groupForm.id = undefined
groupForm.name = ''
groupForm.description = ''
groupForm.sort = 0
groupModalVisible.value = true
}
// 编辑分组
const handleEditGroup = (group) => {
isEdit.value = true
groupModalTitle.value = '编辑分组'
groupForm.id = group.id
groupForm.name = group.name
groupForm.description = group.description || ''
groupForm.sort = group.sort || 0
groupModalVisible.value = true
}
// 保存分组
const handleSaveGroup = async () => {
if (!groupForm.name.trim()) {
message.warning('请输入分组名称')
return
}
try {
if (isEdit.value) {
await MaterialGroupService.updateGroup(groupForm)
message.success('更新成功')
} else {
await MaterialGroupService.createGroup(groupForm)
message.success('创建成功')
}
groupModalVisible.value = false
loadGroupList()
} catch (error) {
console.error('保存分组失败:', error)
message.error(error.message || '保存失败,请重试')
}
}
// 取消
const handleCancelGroup = () => {
groupModalVisible.value = false
groupForm.id = undefined
groupForm.name = ''
groupForm.description = ''
groupForm.sort = 0
}
// 删除分组
const handleDeleteGroup = (group) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除分组"${group.name}"吗?删除后分组内的文件不会被删除。`,
onOk: async () => {
try {
await MaterialGroupService.deleteGroup(group.id)
message.success('删除成功')
loadGroupList()
} catch (error) {
console.error('删除分组失败:', error)
message.error(error.message || '删除失败,请重试')
}
}
})
}
// 工具函数
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 初始化
onMounted(() => {
loadGroupList()
})
</script>
<style scoped>
.material-group {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
}
.material-group__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.material-group__title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.material-group__content {
flex: 1;
overflow-y: auto;
position: relative;
}
.material-group__content :deep(.arco-spin) {
min-height: 400px;
}
.material-group__content :deep(.arco-spin-content) {
min-height: 400px;
}
.group-item {
padding: 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
margin-bottom: 12px;
transition: all 0.2s;
}
.group-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.group-item__header {
display: flex;
align-items: center;
gap: 12px;
}
.group-item__name {
font-size: 16px;
font-weight: 500;
}
.group-item__description {
margin-top: 8px;
color: var(--color-text-2);
}
.group-item__meta {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-3);
}
</style>

View File

@@ -0,0 +1,482 @@
<template>
<div class="material-list">
<div class="material-list__header">
<h1 class="material-list__title">素材列表</h1>
<div class="material-list__actions">
<a-button type="primary" @click="handleOpenUploadModal">
<template #icon>
<UploadOutlined />
</template>
上传素材
</a-button>
<a-button
v-if="selectedFileIds.length > 0"
type="primary"
status="danger"
@click="handleBatchDelete"
>
批量删除 ({{ selectedFileIds.length }})
</a-button>
</div>
</div>
<!-- 筛选条件 -->
<div class="material-list__filters">
<a-space>
<a-input
v-model="filters.fileName"
placeholder="搜索文件名"
style="width: 200px"
allow-clear
@press-enter="handleFilterChange"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-range-picker
v-model:value="filters.createTime"
style="width: 300px"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="handleFilterChange"
/>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
<a-button @click="handleResetFilters">重置</a-button>
</a-space>
</div>
<!-- 文件列表 -->
<div class="material-list__content">
<a-spin :spinning="loading" tip="加载中..." style="width: 100%; min-height: 400px;">
<template v-if="fileList.length > 0">
<div class="material-grid">
<div
v-for="file in fileList"
:key="file.id"
class="material-item"
:class="{ 'material-item--selected': selectedFileIds.includes(file.id) }"
@click="handleFileClick(file)"
>
<div class="material-item__content">
<!-- 预览图 -->
<div class="material-item__preview">
<img
v-if="file.previewUrl"
:src="file.previewUrl"
:alt="file.fileName"
@error="handleImageError"
/>
<div v-else class="material-item__placeholder">
<FileOutlined />
</div>
<!-- 文件类型标识 -->
<div class="material-item__badge">
<a-tag v-if="file.isVideo" color="red">视频</a-tag>
<a-tag v-else-if="file.isImage" color="blue">图片</a-tag>
<a-tag v-else color="gray">文件</a-tag>
</div>
<!-- 选中复选框 -->
<div class="material-item__checkbox">
<a-checkbox
:model-value="selectedFileIds.includes(file.id)"
@click.stop
@change="(checked) => handleSelectFile(file.id, checked)"
/>
</div>
</div>
<!-- 文件信息 -->
<div class="material-item__info">
<div class="material-item__name" :title="file.fileName">
{{ file.fileName }}
</div>
<div class="material-item__meta">
<span>{{ formatFileSize(file.fileSize) }}</span>
<span>{{ formatDate(file.createTime) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<a-empty description="暂无素材" />
</template>
</a-spin>
</div>
<!-- 分页 -->
<div class="material-list__pagination">
<a-pagination
v-model:current="pagination.pageNo"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:show-total="(total) => `${total}`"
:show-size-changer="true"
@change="handlePageChange"
/>
</div>
<!-- 上传对话框 -->
<MaterialUploadModal
v-model:visible="uploadModalVisible"
:uploading="uploading"
@confirm="handleConfirmUpload"
@cancel="handleUploadCancel"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
UploadOutlined,
SearchOutlined,
FileOutlined
} from '@ant-design/icons-vue'
import { MaterialService } from '@/api/material'
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue'
// 数据
const loading = ref(false)
const fileList = ref([])
const selectedFileIds = ref([])
const uploadModalVisible = ref(false)
const uploading = ref(false)
// 筛选条件
const filters = reactive({
fileCategory: undefined,
fileName: '',
createTime: undefined
})
// 分页
const pagination = reactive({
pageNo: 1,
pageSize: 20,
total: 0
})
// 加载文件列表
const loadFileList = async () => {
loading.value = true
try {
const params = {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
...filters
}
// 处理日期范围a-range-picker返回的是数组格式的字符串
// 日期区间:将开始日期设置为 00:00:00结束日期设置为 23:59:59
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
params.createTime = [
filters.createTime[0] + ' 00:00:00',
filters.createTime[1] + ' 23:59:59'
]
}
const res = await MaterialService.getFilePage(params)
if (res.code === 0) {
fileList.value = res.data.list || []
pagination.total = res.data.total || 0
} else {
message.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载文件列表失败:', error)
message.error('加载失败,请重试')
} finally {
loading.value = false
}
}
// 打开上传对话框
const handleOpenUploadModal = () => {
uploadModalVisible.value = true
}
// 确认上传
const handleConfirmUpload = async (files, fileCategory) => {
if (!files || files.length === 0) {
message.warning('请选择文件')
return
}
if (!fileCategory) {
message.warning('请选择文件分类')
return
}
uploading.value = true
let successCount = 0
let failCount = 0
try {
// 逐个上传文件,显示进度
for (const file of files) {
try {
await MaterialService.uploadFile(file, fileCategory)
successCount++
} catch (error) {
console.error('文件上传失败:', file.name, error)
failCount++
}
}
if (successCount > 0) {
message.success(`成功上传 ${successCount} 个文件${failCount > 0 ? `${failCount} 个失败` : ''}`)
uploadModalVisible.value = false
loadFileList()
} else {
message.error('所有文件上传失败,请重试')
}
} catch (error) {
console.error('上传失败:', error)
message.error(error.message || '上传失败,请重试')
} finally {
uploading.value = false
}
}
// 取消上传
const handleUploadCancel = () => {
uploadModalVisible.value = false
}
// 删除文件
const handleBatchDelete = async () => {
if (selectedFileIds.value.length === 0) {
return
}
try {
await MaterialService.deleteFiles(selectedFileIds.value)
message.success('删除成功')
selectedFileIds.value = []
loadFileList()
} catch (error) {
console.error('删除失败:', error)
message.error(error.message || '删除失败,请重试')
}
}
// 选择文件
const handleSelectFile = (fileId, checked) => {
if (checked) {
if (!selectedFileIds.value.includes(fileId)) {
selectedFileIds.value.push(fileId)
}
} else {
const index = selectedFileIds.value.indexOf(fileId)
if (index > -1) {
selectedFileIds.value.splice(index, 1)
}
}
}
// 文件点击
const handleFileClick = (file) => {
// TODO: 打开文件详情或预览
console.log('点击文件:', file)
}
// 筛选
const handleFilterChange = () => {
pagination.pageNo = 1
loadFileList()
}
const handleResetFilters = () => {
filters.fileCategory = undefined
filters.fileName = ''
filters.createTime = undefined
pagination.pageNo = 1
loadFileList()
}
// 分页
const handlePageChange = (page, pageSize) => {
pagination.pageNo = page
if (pageSize && pageSize !== pagination.pageSize) {
pagination.pageSize = pageSize
pagination.pageNo = 1
}
loadFileList()
}
// 工具函数
const formatFileSize = (bytes) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
const handleImageError = (e) => {
e.target.style.display = 'none'
}
// 初始化
onMounted(() => {
loadFileList()
})
</script>
<style scoped>
.material-list {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
}
.material-list__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.material-list__title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.material-list__actions {
display: flex;
gap: 12px;
}
.material-list__filters {
margin-bottom: 24px;
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
}
.material-list__content {
flex: 1;
overflow-y: auto;
margin-bottom: 24px;
position: relative;
}
.material-list__content :deep(.arco-spin) {
min-height: 400px;
}
.material-list__content :deep(.arco-spin-content) {
min-height: 400px;
}
.material-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.material-list__pagination {
display: flex;
justify-content: center;
}
.material-item {
cursor: pointer;
transition: all 0.2s;
}
.material-item:hover {
transform: translateY(-2px);
}
.material-item--selected {
border: 2px solid var(--color-primary);
}
.material-item__content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
overflow: hidden;
transition: all 0.2s;
}
.material-item:hover .material-item__content {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.material-item__preview {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 */
background: var(--color-bg-2);
overflow: hidden;
}
.material-item__preview img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.material-item__placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 48px;
color: var(--color-text-3);
}
.material-item__badge {
position: absolute;
top: 8px;
left: 8px;
}
.material-item__checkbox {
position: absolute;
top: 8px;
right: 8px;
}
.material-item__info {
padding: 12px;
}
.material-item__name {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.material-item__meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--color-text-3);
}
</style>