功能优化
This commit is contained in:
105
frontend/app/web-gold/FRONTEND_LOGIC_REVIEW.md
Normal file
105
frontend/app/web-gold/FRONTEND_LOGIC_REVIEW.md
Normal 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. **优化用户体验**:根据文件类型自动推荐分类(可选)
|
||||
|
||||
1431
frontend/app/web-gold/public/digital-human-prototype.html
Normal file
1431
frontend/app/web-gold/public/digital-human-prototype.html
Normal file
File diff suppressed because it is too large
Load Diff
174
frontend/app/web-gold/src/api/material.js
Normal file
174
frontend/app/web-gold/src/api/material.js
Normal 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
|
||||
|
||||
49
frontend/app/web-gold/src/api/test.js
Normal file
49
frontend/app/web-gold/src/api/test.js
Normal 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
支持格式:视频(MP4、MOV、AVI等)、图片(JPG、PNG、GIF等)、音频(MP3、WAV等)
|
||||
</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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
1360
frontend/app/web-gold/src/views/dh/Video.vue
Normal file
1360
frontend/app/web-gold/src/views/dh/Video.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
296
frontend/app/web-gold/src/views/material/MaterialGroup.vue
Normal file
296
frontend/app/web-gold/src/views/material/MaterialGroup.vue
Normal 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>
|
||||
|
||||
482
frontend/app/web-gold/src/views/material/MaterialList.vue
Normal file
482
frontend/app/web-gold/src/views/material/MaterialList.vue
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user