Files
sionrui/frontend/app/web-gold/src/views/material/MaterialList.vue
2025-11-22 01:42:20 +08:00

748 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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
type="primary"
ghost
:disabled="selectedFileIds.length === 0"
@click="handleOpenMixModal"
>
素材混剪
</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-select
v-model:value="filters.fileCategory"
style="width: 120px"
placeholder="文件分类"
@change="handleFilterChange"
>
<a-select-option value="">全部分类</a-select-option>
<a-select-option value="video">视频</a-select-option>
<a-select-option value="generate">生成</a-select-option>
<a-select-option value="audio">音频</a-select-option>
<a-select-option value="mix">混剪</a-select-option>
<a-select-option value="voice">配音</a-select-option>
</a-select>
<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"
placeholder="['开始日期', '结束日期']"
@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">
<!-- 视频文件统一使用 coverBase64如果没有则使用 coverUrl最后使用视频标签显示第一帧 -->
<img
v-if="file.isVideo && (file.coverBase64 || file.coverUrl)"
:src="file.coverBase64"
:alt="file.fileName"
@error="handleImageError"
/>
<!-- 视频文件如果没有封面使用视频标签显示第一帧 -->
<video
v-else-if="file.isVideo && file.previewUrl"
:src="file.previewUrl"
muted
preload="metadata"
class="preview-video"
/>
<!-- 图片或其他文件使用图片标签 -->
<!-- 优先级coverUrl > coverBase64 > previewUrl -->
<img
v-else-if="file.coverUrl || file.coverBase64 || file.previewUrl"
:src="file.coverUrl || file.coverBase64 || 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__delete" @click.stop="handleDeleteFile(file)">
<DeleteOutlined />
</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"
/>
<a-modal
v-model:open="mixModalVisible"
title="素材混剪"
centered
:confirm-loading="mixing"
ok-text="提交混剪"
cancel-text="取消"
@ok="handleMixConfirm"
@cancel="handleMixCancel"
>
<div class="mix-modal__summary">
<p>选中素材:{{ selectedFiles.length }} 个</p>
<p>视频素材:{{ selectedVideoUrls.length }} 个</p>
<p>背景音乐:{{ selectedAudioUrls.length }} 个</p>
</div>
<a-form layout="vertical">
<a-form-item label="视频标题" required>
<a-input v-model:value="mixForm.title" placeholder="请输入生成视频标题" />
</a-form-item>
<a-form-item label="文案内容" required>
<a-textarea
v-model:value="mixForm.text"
placeholder="请输入文案每句话换行以便自动拆分"
:rows="4"
/>
</a-form-item>
<a-form-item label="生成成片数量" required>
<a-input-number
v-model:value="mixForm.produceCount"
:min="1"
:max="10"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
UploadOutlined,
SearchOutlined,
FileOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import { MaterialService } from '@/api/material'
import { MixService } from '@/api/mix'
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue'
import { formatFileSize, formatDate } from '@/utils/file'
// 数据
const loading = ref(false)
const fileList = ref([])
const selectedFileIds = ref([])
const uploadModalVisible = ref(false)
const uploading = ref(false)
const mixModalVisible = ref(false)
const mixing = ref(false)
// 筛选条件
const filters = reactive({
fileCategory: 'video', // 默认分类为视频
fileName: '',
createTime: undefined
})
// 分页
const pagination = reactive({
pageNo: 1,
pageSize: 20,
total: 0
})
// 构建查询参数
const buildQueryParams = () => {
const params = {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
fileCategory: filters.fileCategory || undefined,
fileName: filters.fileName || undefined
}
// 处理日期范围
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`
]
}
return params
}
// 加载文件列表
const loadFileList = async () => {
loading.value = true
try {
const res = await MaterialService.getFilePage(buildQueryParams())
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 (filesWithCover, fileCategory) => {
if (!filesWithCover?.length) {
message.warning('请选择文件')
return
}
uploading.value = true
let successCount = 0
let failCount = 0
try {
for (const { file, coverBase64 } of filesWithCover) {
try {
await MaterialService.uploadFile(file, fileCategory, coverBase64)
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 = () => {
if (selectedFileIds.value.length === 0) return
// 二次确认弹窗
Modal.confirm({
title: '确认删除',
content: `确定要删除选中的 ${selectedFileIds.value.length} 个文件吗?删除后无法恢复。`,
okText: '确定删除',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await MaterialService.deleteFiles(selectedFileIds.value)
message.success('删除成功')
selectedFileIds.value = []
loadFileList()
} catch (error) {
console.error('删除失败:', error)
message.error(error.message || '删除失败,请重试')
}
}
})
}
// 删除单个文件
const handleDeleteFile = (file) => {
if (!file?.id) return
// 二次确认弹窗
Modal.confirm({
title: '确认删除',
content: `确定要删除文件 "${file.fileName}" 删除后无法恢复`,
okText: '确定删除',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await MaterialService.deleteFiles([file.id])
message.success('删除成功')
// 如果在选中列表中,也移除
const index = selectedFileIds.value.indexOf(file.id)
if (index > -1) {
selectedFileIds.value.splice(index, 1)
}
loadFileList()
} catch (error) {
console.error('删除失败:', error)
message.error(error.message || '删除失败,请重试')
}
}
})
}
// 文件点击
const handleFileClick = (file) => {
const isSelected = selectedFileIds.value.includes(file.id)
// 切换选中状态
if (isSelected) {
selectedFileIds.value = selectedFileIds.value.filter(id => id !== file.id)
} else {
selectedFileIds.value.push(file.id)
}
}
// 筛选
const handleFilterChange = () => {
pagination.pageNo = 1
loadFileList()
}
const handleResetFilters = () => {
filters.fileCategory = 'video'
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 handleImageError = (e) => {
const img = e.target
const currentSrc = img.src
// 找到对应的文件对象
const file = fileList.value.find(f =>
f.coverBase64 === currentSrc ||
f.coverUrl === currentSrc ||
f.previewUrl === currentSrc
)
if (file) {
// 视频文件:优先使用 coverBase64如果失败则尝试 coverUrl最后尝试 previewUrl
if (file.isVideo) {
if (currentSrc === file.coverBase64 && file.coverUrl && file.coverUrl !== currentSrc) {
// coverBase64 失败,尝试 coverUrl
img.src = file.coverUrl
return
}
if ((currentSrc === file.coverBase64 || currentSrc === file.coverUrl) && file.previewUrl && file.previewUrl !== currentSrc) {
// coverBase64 和 coverUrl 都失败,尝试 previewUrl视频第一帧
img.src = file.previewUrl
return
}
} else {
// 非视频文件:尝试其他图片源
if (file.coverUrl && file.coverUrl !== currentSrc) {
img.src = file.coverUrl
return
}
if (file.coverBase64 && file.coverBase64 !== currentSrc && file.coverBase64.startsWith('data:image')) {
img.src = file.coverBase64
return
}
if (file.previewUrl && file.previewUrl !== currentSrc) {
img.src = file.previewUrl
return
}
}
}
// 如果所有图片源都失败,隐藏图片
img.style.display = 'none'
}
const selectedFiles = computed(() =>
fileList.value.filter((file) => selectedFileIds.value.includes(file.id))
)
const isVideoFile = (file) => {
if (!file) return false
if (file.isVideo) return true
if (file.fileCategory === 'video') return true
if (typeof file.fileType === 'string' && file.fileType.startsWith('video')) return true
return false
}
const isAudioFile = (file) => {
if (!file) return false
if (file.fileCategory === 'audio') return true
if (file.fileType === 'audio') return true
if (typeof file.fileType === 'string' && file.fileType.startsWith('audio')) return true
return false
}
const selectedVideoUrls = computed(() =>
selectedFiles.value.map((file) => (isVideoFile(file) ? file?.fileUrl || file?.previewUrl : null)).filter(Boolean)
)
const selectedAudioUrls = computed(() =>
selectedFiles.value.map((file) => (isAudioFile(file) ? file?.fileUrl || file?.previewUrl : null)).filter(Boolean)
)
const mixForm = reactive({
title: '',
text: '',
produceCount: 1
})
const resetMixForm = () => {
mixForm.title = ''
mixForm.text = ''
mixForm.produceCount = 1
}
const handleOpenMixModal = () => {
if (selectedFileIds.value.length === 0) {
message.warning('请先选择至少一个素材')
return
}
if (selectedVideoUrls.value.length === 0) {
message.warning('请至少选择一个视频素材')
return
}
if (selectedAudioUrls.value.length === 0) {
message.warning('请至少选择一个背景音乐素材')
return
}
mixModalVisible.value = true
}
const handleMixCancel = () => {
mixModalVisible.value = false
}
const handleMixConfirm = async () => {
const title = mixForm.title.trim()
const text = mixForm.text.trim()
if (!title) {
message.warning('请输入视频标题')
return
}
if (!text) {
message.warning('请输入文案内容')
return
}
const produceCount = Math.max(1, Math.min(10, Number(mixForm.produceCount) || 1))
if (selectedVideoUrls.value.length === 0) {
message.warning('请至少选择一个视频素材')
return
}
if (selectedAudioUrls.value.length === 0) {
message.warning('请至少选择一个背景音乐素材')
return
}
mixing.value = true
try {
const { data } = await MixService.batchProduceAlignment({
title,
text,
videoUrls: selectedVideoUrls.value,
bgMusicUrls: selectedAudioUrls.value,
produceCount
})
const jobIds = Array.isArray(data) ? data : []
message.success(
jobIds.length > 0
? `混剪任务提交成功JobId${jobIds.join(', ')}`
: '混剪任务提交成功'
)
mixModalVisible.value = false
resetMixForm()
} catch (error) {
console.error('混剪失败:', error)
message.error(error?.message || '混剪任务提交失败,请重试')
} finally {
mixing.value = false
}
}
// 初始化
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;
}
.material-item__content {
background: var(--color-surface);
border: 2px solid transparent;
border-radius: var(--radius-card);
overflow: hidden;
transition: border-color 0.2s;
}
.material-item--selected .material-item__content {
border-color: var(--color-primary);
}
.material-item__preview {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 */
background: var(--color-bg-2);
overflow: hidden;
}
.material-item__preview img,
.material-item__preview video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.material-item__preview video {
pointer-events: none; /* 禁用视频交互,避免点击播放 */
}
.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__delete {
position: absolute;
bottom: 8px;
right: 8px;
width: 28px;
height: 28px;
background: rgba(255, 77, 79, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
opacity: 0;
transition: all 0.3s;
font-size: 16px;
}
.material-item:hover .material-item__delete {
opacity: 1;
}
.material-item__delete:hover {
background: rgb(255, 77, 79);
transform: scale(1.1);
}
.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);
}
.mix-modal__summary {
margin-bottom: 16px;
padding: 12px;
background: var(--color-bg-2);
border: 1px dashed var(--color-border);
border-radius: var(--radius-card);
font-size: 13px;
color: var(--color-text-2);
}
.mix-modal__summary p {
margin: 0;
line-height: 1.6;
}
</style>