1438 lines
37 KiB
Vue
1438 lines
37 KiB
Vue
<template>
|
||
<FullWidthLayout :show-padding="false" class="material-list-layout">
|
||
<div class="material-list-container">
|
||
<!-- 左侧分组面板 - 仅混剪素材显示 -->
|
||
<transition name="sidebar-slide">
|
||
<div class="material-sidebar" v-show="activeCategory === 'MIX'">
|
||
<!-- 分组列表 -->
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section__header">
|
||
<span>分组</span>
|
||
<a-button type="link" size="small" @click="handleOpenCreateGroupModal">
|
||
<PlusOutlined />
|
||
</a-button>
|
||
</div>
|
||
<div class="sidebar-group-list">
|
||
<div
|
||
v-for="group in groupList"
|
||
:key="group.id"
|
||
class="sidebar-group-item"
|
||
:class="{ 'sidebar-group-item--active': selectedGroupId === group.id }"
|
||
@click="handleSelectGroup(group)"
|
||
>
|
||
<div class="sidebar-group-item__content">
|
||
<FolderOutlined class="sidebar-group-item__icon" />
|
||
<template v-if="editingGroupId !== group.id">
|
||
<span class="sidebar-group-item__name">{{ group.name }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<a-input
|
||
v-model:value="editingGroupName"
|
||
size="small"
|
||
class="sidebar-group-item__edit-input"
|
||
@blur="handleSaveGroupName(group)"
|
||
@press-enter="handleSaveGroupName(group)"
|
||
@click.stop
|
||
autofocus
|
||
/>
|
||
</template>
|
||
</div>
|
||
<div class="sidebar-group-item__actions">
|
||
<template v-if="editingGroupId !== group.id">
|
||
<span class="sidebar-group-item__count">{{ group.fileCount }}</span>
|
||
<EditOutlined
|
||
class="sidebar-group-item__action-btn"
|
||
@click.stop="handleEditGroup(group, $event)"
|
||
/>
|
||
<DeleteOutlined
|
||
class="sidebar-group-item__action-btn sidebar-group-item__action-btn--danger"
|
||
@click.stop="handleDeleteGroup(group, $event)"
|
||
/>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- 右侧内容区域 -->
|
||
<div class="material-content">
|
||
<!-- 顶部工具栏 -->
|
||
<div class="material-toolbar">
|
||
<!-- 分类切换器 -->
|
||
<div class="category-switcher">
|
||
<div
|
||
class="category-switcher__item"
|
||
:class="{ 'category-switcher__item--active': activeCategory === 'MIX' }"
|
||
@click="handleCategoryChange('MIX')"
|
||
>
|
||
<VideoCameraOutlined class="category-switcher__icon" />
|
||
<span>混剪素材</span>
|
||
</div>
|
||
<div
|
||
class="category-switcher__item"
|
||
:class="{ 'category-switcher__item--active': activeCategory === 'DIGITAL_HUMAN' }"
|
||
@click="handleCategoryChange('DIGITAL_HUMAN')"
|
||
>
|
||
<UserOutlined class="category-switcher__icon" />
|
||
<span>数字人素材</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索和操作区 -->
|
||
<div class="toolbar-actions">
|
||
<!-- 存储配额显示 -->
|
||
<div class="storage-quota">
|
||
<span class="storage-quota__label">存储空间</span>
|
||
<span class="storage-quota__value">{{ userStore.usedStorage.toFixed(2) }} / {{ userStore.totalStorage }} GB</span>
|
||
<div class="storage-quota__bar">
|
||
<div class="storage-quota__used" :style="{ width: `${Math.min((userStore.usedStorage / userStore.totalStorage) * 100, 100)}%` }"></div>
|
||
</div>
|
||
</div>
|
||
<a-input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索文件名..."
|
||
class="search-input"
|
||
allow-clear
|
||
@press-enter="handleSearch"
|
||
>
|
||
<template #prefix>
|
||
<SearchOutlined />
|
||
</template>
|
||
</a-input>
|
||
<a-button
|
||
type="primary"
|
||
class="upload-btn"
|
||
:disabled="activeCategory === 'MIX' && (!selectedGroupId || groupList.length === 0)"
|
||
@click="handleOpenUploadModal"
|
||
>
|
||
<UploadOutlined />
|
||
上传
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 批量操作栏 -->
|
||
<transition name="fade-slide">
|
||
<div class="batch-actions" v-if="selectedFileIds.length > 0">
|
||
<div class="batch-actions__info">
|
||
<CheckSquareOutlined />
|
||
<span>已选择 <strong>{{ selectedFileIds.length }}</strong> 个文件</span>
|
||
</div>
|
||
<div class="batch-actions__btns">
|
||
<a-button size="small" @click="selectedFileIds = []">取消选择</a-button>
|
||
<a-button type="primary" danger size="small" @click="handleBatchDelete">
|
||
<DeleteOutlined /> 删除
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- 文件列表 -->
|
||
<div class="material-content__list">
|
||
<a-spin :spinning="loading" tip="加载中..." class="loading-spinner">
|
||
<template v-if="fileList.length > 0">
|
||
<div class="material-grid">
|
||
<div
|
||
v-for="file in fileList"
|
||
:key="file.id"
|
||
:data-file-id="file.id"
|
||
class="material-item"
|
||
:class="{ 'material-item--selected': selectedFileIds.includes(file.id) }"
|
||
@click="handleFileClick(file)"
|
||
>
|
||
<!-- 选择指示器 -->
|
||
<div class="material-item__check">
|
||
<div class="check-circle" :class="{ 'check-circle--checked': selectedFileIds.includes(file.id) }">
|
||
<CheckOutlined v-if="selectedFileIds.includes(file.id)" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 预览图 -->
|
||
<div class="material-item__preview">
|
||
<img
|
||
v-if="file.coverBase64"
|
||
:src="file.coverBase64"
|
||
:alt="file.fileName"
|
||
@error="handleImageError"
|
||
loading="lazy"
|
||
/>
|
||
<div v-else class="material-item__preview-placeholder">
|
||
<FileOutlined />
|
||
</div>
|
||
<div class="material-item__overlay">
|
||
<span class="material-item__type-tag">{{ getFileTypeText(file.fileName) }}</span>
|
||
</div>
|
||
<!-- hover 播放按钮 -->
|
||
<div class="material-item__play-overlay" @click.stop="handlePreview(file)">
|
||
<PlayCircleOutlined />
|
||
<span v-if="file.duration" class="duration-tag">{{ formatDuration(file.duration) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件信息 -->
|
||
<div class="material-item__info">
|
||
<div
|
||
class="material-item__name"
|
||
:title="file.displayName"
|
||
@click.stop="handleEditName(file)"
|
||
>
|
||
<template v-if="editingFileId !== file.id">
|
||
{{ file.displayName }}
|
||
</template>
|
||
<template v-else>
|
||
<a-input
|
||
ref="nameInputRef"
|
||
v-model:value="editingDisplayName"
|
||
size="small"
|
||
@blur="handleSaveName(file)"
|
||
@press-enter="handleSaveName(file)"
|
||
@click.stop
|
||
autofocus
|
||
/>
|
||
</template>
|
||
</div>
|
||
<div class="material-item__meta">
|
||
<span>{{ formatFileSize(file.fileSize) }}</span>
|
||
<span class="meta-dot">·</span>
|
||
<span>{{ formatDate(file.createTime) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="empty-state">
|
||
<div class="empty-state__icon">
|
||
<FolderOpenOutlined />
|
||
</div>
|
||
<p class="empty-state__text">暂无素材</p>
|
||
<a-button type="primary" @click="handleOpenUploadModal">
|
||
<UploadOutlined /> 上传素材
|
||
</a-button>
|
||
</div>
|
||
</a-spin>
|
||
</div>
|
||
|
||
<!-- 底部操作栏 -->
|
||
<div class="material-footer">
|
||
<div class="footer-left">
|
||
<a-checkbox
|
||
:checked="selectedFileIds.length === fileList.length && fileList.length > 0"
|
||
:indeterminate="selectedFileIds.length > 0 && selectedFileIds.length < fileList.length"
|
||
@change="handleSelectAll"
|
||
>
|
||
全选
|
||
</a-checkbox>
|
||
<span class="footer-count" v-if="pagination.total > 0">
|
||
共 {{ pagination.total }} 个文件
|
||
</span>
|
||
</div>
|
||
<a-pagination
|
||
v-model:current="pagination.current"
|
||
v-model:page-size="pagination.pageSize"
|
||
:total="pagination.total"
|
||
:show-size-changer="true"
|
||
:show-quick-jumper="true"
|
||
:show-total="false"
|
||
size="small"
|
||
@change="handlePageChange"
|
||
@show-size-change="handlePageSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 上传素材弹窗 -->
|
||
<MaterialUploadModal
|
||
v-model:visible="uploadModalVisible"
|
||
:group-id="selectedGroupId"
|
||
:file-category="activeCategory === 'DIGITAL_HUMAN' ? 'digital_human' : 'video'"
|
||
@complete="handleUploadComplete"
|
||
/>
|
||
|
||
<!-- 新建分组弹窗 -->
|
||
<a-modal
|
||
v-model:visible="createGroupModalVisible"
|
||
title="新建分组"
|
||
@ok="handleCreateGroup"
|
||
@cancel="createGroupModalVisible = false"
|
||
>
|
||
<a-form :model="createGroupForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||
<a-form-item label="分组名称" name="name" :rules="[{ required: true, message: '请输入分组名称' }]">
|
||
<a-input v-model:value="createGroupForm.name" placeholder="请输入分组名称" />
|
||
</a-form-item>
|
||
<a-form-item label="分组描述">
|
||
<a-textarea v-model:value="createGroupForm.description" placeholder="请输入分组描述(可选)" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 视频预览弹窗 -->
|
||
<VideoPreviewModal
|
||
v-model:open="previewVisible"
|
||
:video-url="previewUrl"
|
||
:title="previewTitle"
|
||
/>
|
||
</div>
|
||
</FullWidthLayout>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, watch, nextTick } from 'vue';
|
||
import {
|
||
UploadOutlined,
|
||
SearchOutlined,
|
||
FileOutlined,
|
||
FolderOutlined,
|
||
FolderOpenOutlined,
|
||
PlusOutlined,
|
||
EditOutlined,
|
||
DeleteOutlined,
|
||
VideoCameraOutlined,
|
||
UserOutlined,
|
||
CheckOutlined,
|
||
CheckSquareOutlined,
|
||
PlayCircleOutlined
|
||
} from '@ant-design/icons-vue';
|
||
import { message, Modal } from 'ant-design-vue';
|
||
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue';
|
||
import MaterialService, { MaterialGroupService } from '@/api/material';
|
||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue';
|
||
import { useUserStore } from '@/stores/user';
|
||
import VideoPreviewModal from '@/components/VideoPreviewModal.vue';
|
||
|
||
// 用户状态(获取存储配额)
|
||
const userStore = useUserStore()
|
||
|
||
// 状态管理
|
||
const loading = ref(false)
|
||
const activeCategory = ref('MIX')
|
||
const selectedGroupId = ref(null)
|
||
const groupList = ref([])
|
||
const fileList = ref([])
|
||
const totalFileCount = ref(0)
|
||
const searchKeyword = ref('')
|
||
|
||
// 模态框状态
|
||
const uploadModalVisible = ref(false)
|
||
const createGroupModalVisible = ref(false)
|
||
|
||
// 表单数据
|
||
const createGroupForm = reactive({
|
||
name: '',
|
||
description: '',
|
||
category: 'MIX'
|
||
})
|
||
|
||
// 文件选择
|
||
const selectedFileIds = ref([])
|
||
|
||
// 视频预览状态
|
||
const previewVisible = ref(false)
|
||
const previewUrl = ref('')
|
||
const previewTitle = ref('')
|
||
|
||
// 编辑状态
|
||
const editingFileId = ref(null)
|
||
const editingDisplayName = ref('')
|
||
const nameInputRef = ref(null)
|
||
const editingGroupId = ref(null)
|
||
const editingGroupName = ref('')
|
||
|
||
// 分页
|
||
const pagination = reactive({
|
||
current: 1,
|
||
pageSize: 20,
|
||
total: 0
|
||
})
|
||
|
||
// 方法
|
||
const handleCategoryChange = async (category) => {
|
||
activeCategory.value = category
|
||
selectedGroupId.value = null
|
||
createGroupForm.category = category
|
||
|
||
// 混剪素材需要加载分组,数字人素材不需要
|
||
if (category === 'MIX') {
|
||
await loadGroupList()
|
||
} else {
|
||
await loadFileList()
|
||
}
|
||
}
|
||
|
||
const handleSelectGroup = (group) => {
|
||
// 编辑中则先保存
|
||
if (editingGroupId.value) {
|
||
const currentGroup = groupList.value.find(g => g.id === editingGroupId.value)
|
||
if (currentGroup) {
|
||
handleSaveGroupName(currentGroup)
|
||
}
|
||
}
|
||
|
||
// 已是选中状态则不处理
|
||
if (selectedGroupId.value === group.id) return
|
||
|
||
selectedGroupId.value = group.id
|
||
loadFileList()
|
||
}
|
||
|
||
const handleEditGroup = async (group, event) => {
|
||
event?.stopPropagation?.()
|
||
editingGroupId.value = group.id
|
||
editingGroupName.value = group.name
|
||
await nextTick()
|
||
}
|
||
|
||
const handleSaveGroupName = async (group) => {
|
||
const newName = editingGroupName.value.trim()
|
||
|
||
if (!newName) {
|
||
message.warning('分组名称不能为空')
|
||
editingGroupId.value = null
|
||
return
|
||
}
|
||
|
||
if (newName === group.name) {
|
||
editingGroupId.value = null
|
||
return
|
||
}
|
||
|
||
try {
|
||
await MaterialGroupService.updateGroup({
|
||
id: group.id,
|
||
name: newName,
|
||
description: group.description || '',
|
||
sort: group.sort || 0,
|
||
icon: group.icon || 'folder',
|
||
parentId: group.parentId || 0
|
||
})
|
||
|
||
group.name = newName
|
||
message.success('分组重命名成功')
|
||
} catch (error) {
|
||
console.error('分组重命名失败:', error)
|
||
message.error('分组重命名失败,请重试')
|
||
} finally {
|
||
editingGroupId.value = null
|
||
}
|
||
}
|
||
|
||
// 通用的确认删除弹窗
|
||
const showConfirmDelete = (title, content) => {
|
||
return new Promise((resolve) => {
|
||
Modal.confirm({
|
||
title,
|
||
content,
|
||
okText: '确认删除',
|
||
cancelText: '取消',
|
||
okType: 'danger',
|
||
centered: true,
|
||
onOk: () => resolve(true),
|
||
onCancel: () => resolve(false),
|
||
})
|
||
})
|
||
}
|
||
|
||
const handleDeleteGroup = async (group, event) => {
|
||
event?.stopPropagation?.()
|
||
|
||
// 校验:分组内还有文件时不允许删除
|
||
if (group.fileCount > 0) {
|
||
message.warning(`分组「${group.name}」内还有 ${group.fileCount} 个文件,请先删除文件后再删除分组`)
|
||
return
|
||
}
|
||
|
||
const confirmed = await showConfirmDelete('删除分组', `确定要删除分组「${group.name}」吗?此操作不可恢复。`)
|
||
if (!confirmed) return
|
||
|
||
try {
|
||
loading.value = true
|
||
await MaterialGroupService.deleteGroup(group.id)
|
||
|
||
// 移除已删除的分组并重新选择
|
||
groupList.value = groupList.value.filter(g => g.id !== group.id)
|
||
if (selectedGroupId.value === group.id) {
|
||
selectedGroupId.value = groupList.value[0]?.id ?? null
|
||
await loadFileList()
|
||
}
|
||
|
||
message.success('分组删除成功')
|
||
} catch (error) {
|
||
console.error('分组删除失败:', error)
|
||
message.error('分组删除失败,请重试')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const handleOpenCreateGroupModal = () => {
|
||
createGroupForm.name = ''
|
||
createGroupForm.description = ''
|
||
createGroupForm.category = activeCategory.value
|
||
createGroupModalVisible.value = true
|
||
}
|
||
|
||
const handleCreateGroup = async () => {
|
||
try {
|
||
await MaterialGroupService.createGroup(createGroupForm)
|
||
message.success('分组创建成功')
|
||
createGroupModalVisible.value = false
|
||
await loadGroupList()
|
||
} catch (error) {
|
||
message.error('分组创建失败')
|
||
}
|
||
}
|
||
|
||
const loadGroupList = async () => {
|
||
try {
|
||
const result = await MaterialGroupService.getGroupListByCategory(activeCategory.value)
|
||
groupList.value = result.data || result || []
|
||
} catch (error) {
|
||
console.error('加载分组列表失败:', error)
|
||
try {
|
||
const allGroups = await MaterialGroupService.getGroupList()
|
||
groupList.value = allGroups.data || allGroups || []
|
||
} catch (fallbackError) {
|
||
console.error('加载所有分组也失败:', fallbackError)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 默认选中第一个分组
|
||
if (groupList.value.length > 0 && selectedGroupId.value === null) {
|
||
selectedGroupId.value = groupList.value[0].id
|
||
}
|
||
|
||
await loadFileList()
|
||
}
|
||
|
||
const loadFileList = async () => {
|
||
loading.value = true
|
||
try {
|
||
// 根据分类选择不同的 fileCategory
|
||
// 混剪素材用 video,数字人素材用 digital_human
|
||
const fileCategory = activeCategory.value === 'DIGITAL_HUMAN' ? 'digital_human' : 'video'
|
||
|
||
const params = {
|
||
pageNo: pagination.current,
|
||
pageSize: pagination.pageSize,
|
||
fileCategory,
|
||
fileName: searchKeyword.value || undefined,
|
||
// 数字人素材不传 groupId
|
||
groupId: activeCategory.value === 'DIGITAL_HUMAN' ? undefined : (selectedGroupId.value || undefined)
|
||
}
|
||
|
||
const result = await MaterialService.getFilePage(params)
|
||
fileList.value = result.data?.list || result.list || []
|
||
const total = result.data?.total || result.total || 0
|
||
pagination.total = total
|
||
totalFileCount.value = total
|
||
} catch (error) {
|
||
console.error('加载文件列表失败:', error)
|
||
message.error('加载文件列表失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const handleSearch = () => {
|
||
pagination.current = 1
|
||
loadFileList()
|
||
}
|
||
|
||
const handlePageChange = (page, pageSize) => {
|
||
pagination.current = page
|
||
pagination.pageSize = pageSize
|
||
loadFileList()
|
||
}
|
||
|
||
const handlePageSizeChange = (current, size) => {
|
||
pagination.current = 1
|
||
pagination.pageSize = size
|
||
loadFileList()
|
||
}
|
||
|
||
const handleFileClick = (file) => {
|
||
const index = selectedFileIds.value.indexOf(file.id)
|
||
if (index > -1) {
|
||
selectedFileIds.value.splice(index, 1)
|
||
} else {
|
||
selectedFileIds.value.push(file.id)
|
||
}
|
||
}
|
||
|
||
const handleOpenUploadModal = () => {
|
||
uploadModalVisible.value = true
|
||
}
|
||
|
||
// 上传完成后的回调
|
||
const handleUploadComplete = async ({ successCount, failCount }) => {
|
||
// 刷新文件列表
|
||
await loadFileList()
|
||
// 刷新存储配额
|
||
await userStore.fetchUserProfile()
|
||
// 混剪素材才刷新分组列表
|
||
if (activeCategory.value === 'MIX') {
|
||
await loadGroupList()
|
||
}
|
||
}
|
||
|
||
|
||
const handleSelectAll = (event) => {
|
||
const checked = event.target.checked
|
||
if (checked) {
|
||
selectedFileIds.value = fileList.value.map(file => file.id)
|
||
} else {
|
||
selectedFileIds.value = []
|
||
}
|
||
}
|
||
|
||
const handleBatchDelete = async () => {
|
||
if (selectedFileIds.value.length === 0) {
|
||
message.warning('请先选择要删除的文件')
|
||
return
|
||
}
|
||
|
||
const count = selectedFileIds.value.length
|
||
const fileIdsToDelete = [...selectedFileIds.value]
|
||
|
||
const confirmed = await showConfirmDelete('确认删除', `确定要删除选中的 ${count} 个文件吗?此操作不可恢复。`)
|
||
if (!confirmed) return
|
||
|
||
try {
|
||
loading.value = true
|
||
await MaterialService.deleteFiles(fileIdsToDelete)
|
||
|
||
// 移除已删除的文件并更新状态
|
||
fileList.value = fileList.value.filter(file => !fileIdsToDelete.includes(file.id))
|
||
totalFileCount.value = Math.max(0, totalFileCount.value - count)
|
||
selectedFileIds.value = []
|
||
|
||
// 刷新存储配额
|
||
await userStore.fetchUserProfile()
|
||
|
||
// 如果删除后当前页没有数据了,则加载上一页
|
||
if (fileList.value.length === 0 && pagination.current > 1) {
|
||
pagination.current--
|
||
await loadFileList()
|
||
}
|
||
|
||
message.success(`成功删除 ${count} 个文件`)
|
||
} catch (error) {
|
||
console.error('批量删除失败:', error)
|
||
message.error('删除失败,请重试')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const formatFileSize = (bytes) => {
|
||
if (!bytes) return '0 B'
|
||
const units = ['B', 'KB', 'MB', 'GB']
|
||
const index = Math.floor(Math.log(bytes) / Math.log(1024))
|
||
const size = bytes / Math.pow(1024, index)
|
||
return `${size.toFixed(1)} ${units[index]}`
|
||
}
|
||
|
||
const formatDate = (date) => {
|
||
return new Date(date).toLocaleDateString()
|
||
}
|
||
|
||
const getFileTypeText = (fileName) => {
|
||
if (!fileName) return ''
|
||
const ext = fileName.split('.').pop()
|
||
return ext ? `${ext.toLowerCase()}` : ''
|
||
}
|
||
|
||
const handleEditName = async (file) => {
|
||
editingFileId.value = file.id
|
||
editingDisplayName.value = file.displayName || file.fileName
|
||
await nextTick()
|
||
|
||
// 查找当前编辑文件的输入框
|
||
const nameElement = document.querySelector(`[data-file-id="${file.id}"] .material-item__name`)
|
||
if (nameElement) {
|
||
const input = nameElement.querySelector('input')
|
||
if (input) {
|
||
input.focus()
|
||
input.select()
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleSaveName = async (file) => {
|
||
const newName = editingDisplayName.value.trim()
|
||
|
||
if (!newName) {
|
||
message.warning('名称不能为空')
|
||
editingFileId.value = null
|
||
return
|
||
}
|
||
|
||
if (newName === file.displayName) {
|
||
editingFileId.value = null
|
||
return
|
||
}
|
||
|
||
try {
|
||
await MaterialService.updateDisplayName(file.id, newName)
|
||
file.displayName = newName
|
||
message.success('重命名成功')
|
||
|
||
// 成功动画效果
|
||
const nameElement = document.querySelector(`[data-file-id="${file.id}"] .material-item__name`)
|
||
if (nameElement) {
|
||
nameElement.style.transition = 'all 0.3s ease'
|
||
nameElement.style.transform = 'scale(1.05)'
|
||
setTimeout(() => {
|
||
nameElement.style.transform = 'scale(1)'
|
||
}, 300)
|
||
}
|
||
} catch (error) {
|
||
console.error('重命名失败:', error)
|
||
message.error('重命名失败,请重试')
|
||
} finally {
|
||
editingFileId.value = null
|
||
}
|
||
}
|
||
|
||
const handleImageError = (e) => {
|
||
e.target.style.display = 'none'
|
||
}
|
||
|
||
// 视频预览
|
||
const handlePreview = async (file) => {
|
||
if (!file.id) {
|
||
message.warning('文件信息不完整')
|
||
return
|
||
}
|
||
|
||
// 显示加载提示
|
||
const hideLoading = message.loading('正在获取视频地址...', 0)
|
||
|
||
try {
|
||
// 调用后端API获取带签名的视频播放URL
|
||
const res = await MaterialService.getVideoPlayUrl(file.id)
|
||
hideLoading()
|
||
|
||
if (res.code === 0 && res.data) {
|
||
previewUrl.value = res.data
|
||
previewTitle.value = file.displayName || file.fileName || '视频预览'
|
||
previewVisible.value = true
|
||
} else {
|
||
message.error(res.msg || '获取视频地址失败')
|
||
}
|
||
} catch (error) {
|
||
hideLoading()
|
||
console.error('获取视频播放URL失败:', error)
|
||
message.error('获取视频地址失败,请重试')
|
||
}
|
||
}
|
||
|
||
// 格式化时长
|
||
const formatDuration = (seconds) => {
|
||
if (!seconds) return ''
|
||
const mins = Math.floor(seconds / 60)
|
||
const secs = Math.floor(seconds % 60)
|
||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||
}
|
||
|
||
// 监听分类变化
|
||
watch(activeCategory, () => {
|
||
selectedFileIds.value = []
|
||
})
|
||
|
||
// 初始化
|
||
onMounted(async () => {
|
||
// 刷新用户档案获取最新存储配额
|
||
await userStore.fetchUserProfile()
|
||
await loadGroupList()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
// 使用设计系统变量
|
||
// 主色: var(--color-primary-500) = #3B82F6
|
||
|
||
// ========================================
|
||
// 主容器
|
||
// ========================================
|
||
.material-list-container {
|
||
display: flex;
|
||
height: 100%;
|
||
background: var(--color-gray-50);
|
||
}
|
||
|
||
// ========================================
|
||
// 侧边栏 - 分组面板
|
||
// ========================================
|
||
.material-sidebar {
|
||
width: 220px;
|
||
background: var(--color-bg-card);
|
||
border-right: 1px solid var(--color-gray-200);
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: var(--space-5) var(--space-3);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-slide-enter-active,
|
||
.sidebar-slide-leave-active {
|
||
transition: all var(--duration-slow) cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.sidebar-slide-enter-from,
|
||
.sidebar-slide-leave-to {
|
||
width: 0;
|
||
opacity: 0;
|
||
padding-left: 0;
|
||
padding-right: 0;
|
||
}
|
||
|
||
.sidebar-section {
|
||
&__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 var(--space-2);
|
||
margin-bottom: var(--space-3);
|
||
|
||
span {
|
||
font-size: var(--font-size-xs);
|
||
font-weight: 600;
|
||
color: var(--color-gray-500);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.8px;
|
||
}
|
||
|
||
.ant-btn {
|
||
padding: 2px 6px;
|
||
height: auto;
|
||
color: var(--color-gray-500);
|
||
|
||
&:hover {
|
||
color: var(--color-primary-500);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.sidebar-group-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
margin: 0 calc(var(--space-1) * -1);
|
||
padding: 0 var(--space-1);
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background: var(--color-gray-200);
|
||
border-radius: 2px;
|
||
}
|
||
}
|
||
|
||
.sidebar-group-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--space-2) var(--space-3);
|
||
cursor: pointer;
|
||
border-radius: var(--radius-md);
|
||
margin-bottom: 2px;
|
||
transition: all var(--duration-fast);
|
||
|
||
&:hover {
|
||
background: var(--color-gray-100);
|
||
|
||
.sidebar-group-item__action-btn {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
&--active {
|
||
background: var(--color-primary-50);
|
||
color: var(--color-primary-500);
|
||
|
||
.sidebar-group-item__icon {
|
||
color: var(--color-primary-500);
|
||
}
|
||
}
|
||
|
||
&__content {
|
||
display: flex;
|
||
align-items: center;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
&__icon {
|
||
margin-right: var(--space-2);
|
||
font-size: var(--font-size-base);
|
||
color: var(--color-gray-500);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
&__name {
|
||
font-size: var(--font-size-sm);
|
||
font-weight: 500;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
&__actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
&__count {
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-gray-500);
|
||
margin-right: var(--space-2);
|
||
}
|
||
|
||
&__action-btn {
|
||
opacity: 0;
|
||
padding: var(--space-1);
|
||
cursor: pointer;
|
||
color: var(--color-gray-500);
|
||
font-size: var(--font-size-xs);
|
||
transition: all var(--duration-fast);
|
||
|
||
&:hover {
|
||
color: var(--color-primary-500);
|
||
}
|
||
|
||
&--danger:hover {
|
||
color: var(--color-error-500);
|
||
}
|
||
}
|
||
|
||
&__edit-input {
|
||
:deep(.ant-input) {
|
||
height: 24px;
|
||
font-size: var(--font-size-sm);
|
||
border-color: var(--color-primary-500);
|
||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// 内容区域
|
||
// ========================================
|
||
.material-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
transition: all var(--duration-slow) cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
// ========================================
|
||
// 顶部工具栏
|
||
// ========================================
|
||
.material-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--space-4) var(--space-6);
|
||
background: var(--color-bg-card);
|
||
border-bottom: 1px solid var(--color-gray-200);
|
||
gap: var(--space-6);
|
||
}
|
||
|
||
// 分类切换器
|
||
.category-switcher {
|
||
display: flex;
|
||
background: var(--color-gray-50);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--space-1);
|
||
gap: var(--space-1);
|
||
|
||
&__item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
padding: var(--space-2) var(--space-5);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
font-size: var(--font-size-base);
|
||
font-weight: 500;
|
||
color: var(--color-gray-600);
|
||
transition: all var(--duration-base) ease;
|
||
|
||
&:hover {
|
||
color: var(--color-gray-900);
|
||
}
|
||
|
||
&--active {
|
||
background: var(--color-bg-card);
|
||
color: var(--color-primary-500);
|
||
box-shadow: var(--shadow-sm);
|
||
|
||
.category-switcher__icon {
|
||
color: var(--color-primary-500);
|
||
}
|
||
}
|
||
}
|
||
|
||
&__icon {
|
||
font-size: var(--font-size-md);
|
||
color: var(--color-gray-500);
|
||
transition: color var(--duration-base) ease;
|
||
}
|
||
}
|
||
|
||
// 存储配额显示
|
||
.storage-quota {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
padding: var(--space-2) var(--space-3);
|
||
background: var(--color-gray-50);
|
||
border-radius: var(--radius-md);
|
||
border: 1px solid var(--color-gray-200);
|
||
|
||
&__label {
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-gray-500);
|
||
}
|
||
|
||
&__value {
|
||
font-size: var(--font-size-xs);
|
||
font-weight: 500;
|
||
color: var(--color-gray-900);
|
||
}
|
||
|
||
&__bar {
|
||
width: 80px;
|
||
height: 4px;
|
||
background: var(--color-gray-200);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
&__used {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--color-primary-500), var(--color-primary-400));
|
||
border-radius: 2px;
|
||
transition: width var(--duration-slow) ease;
|
||
}
|
||
}
|
||
|
||
// 工具栏操作区
|
||
.toolbar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
|
||
.search-input {
|
||
width: 280px;
|
||
|
||
:deep(.ant-input) {
|
||
border-radius: var(--radius-md);
|
||
border-color: var(--color-gray-200);
|
||
background: var(--color-gray-50);
|
||
|
||
&:focus {
|
||
background: var(--color-bg-card);
|
||
border-color: var(--color-primary-500);
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
}
|
||
|
||
:deep(.ant-input-prefix) {
|
||
color: var(--color-gray-500);
|
||
}
|
||
}
|
||
|
||
.upload-btn {
|
||
border-radius: var(--radius-md);
|
||
font-weight: 500;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// 批量操作栏
|
||
// ========================================
|
||
.batch-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--space-3) var(--space-6);
|
||
background: var(--color-primary-50);
|
||
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
|
||
|
||
&__info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
font-size: var(--font-size-base);
|
||
color: var(--color-gray-900);
|
||
|
||
.anticon {
|
||
color: var(--color-primary-500);
|
||
font-size: var(--font-size-md);
|
||
}
|
||
|
||
strong {
|
||
color: var(--color-primary-500);
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
&__btns {
|
||
display: flex;
|
||
gap: var(--space-2);
|
||
}
|
||
}
|
||
|
||
.fade-slide-enter-active,
|
||
.fade-slide-leave-active {
|
||
transition: all var(--duration-slow) ease;
|
||
}
|
||
|
||
.fade-slide-enter-from,
|
||
.fade-slide-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
|
||
// ========================================
|
||
// 文件列表区域
|
||
// ========================================
|
||
.material-content__list {
|
||
flex: 1;
|
||
padding: var(--space-6);
|
||
overflow-y: auto;
|
||
|
||
.loading-spinner {
|
||
width: 100%;
|
||
min-height: 400px;
|
||
}
|
||
}
|
||
|
||
// 空状态
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 80px var(--space-6);
|
||
|
||
&__icon {
|
||
width: 80px;
|
||
height: 80px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--color-gray-100);
|
||
border-radius: 50%;
|
||
margin-bottom: var(--space-5);
|
||
|
||
.anticon {
|
||
font-size: 36px;
|
||
color: var(--color-gray-500);
|
||
}
|
||
}
|
||
|
||
&__text {
|
||
font-size: var(--font-size-md);
|
||
color: var(--color-gray-600);
|
||
margin-bottom: var(--space-5);
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// 素材网格
|
||
// ========================================
|
||
.material-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: var(--space-5);
|
||
}
|
||
|
||
.material-item {
|
||
position: relative;
|
||
background: var(--color-bg-card);
|
||
border: 1px solid var(--color-gray-200);
|
||
border-radius: var(--radius-lg);
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: all var(--duration-slow) cubic-bezier(0.4, 0, 0.2, 1);
|
||
|
||
&:hover {
|
||
border-color: var(--color-primary-500);
|
||
box-shadow: var(--shadow-md);
|
||
transform: translateY(-2px);
|
||
|
||
.material-item__check {
|
||
opacity: 1;
|
||
}
|
||
|
||
.material-item__overlay {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
&--selected {
|
||
border-color: var(--color-primary-500);
|
||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2), var(--shadow-md);
|
||
|
||
.material-item__check .check-circle {
|
||
background: var(--color-primary-500);
|
||
border-color: var(--color-primary-500);
|
||
opacity: 1;
|
||
|
||
.anticon {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 选择指示器
|
||
&__check {
|
||
position: absolute;
|
||
top: var(--space-2);
|
||
left: var(--space-2);
|
||
z-index: 10;
|
||
opacity: 0;
|
||
transition: opacity var(--duration-fast);
|
||
|
||
.check-circle {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
border: 2px solid var(--color-gray-200);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all var(--duration-fast);
|
||
box-shadow: var(--shadow-sm);
|
||
|
||
.anticon {
|
||
font-size: var(--font-size-xs);
|
||
color: white;
|
||
opacity: 0;
|
||
transition: opacity var(--duration-fast);
|
||
}
|
||
|
||
&--checked {
|
||
background: var(--color-primary-500);
|
||
border-color: var(--color-primary-500);
|
||
|
||
.anticon {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 预览区
|
||
&__preview {
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 16 / 10;
|
||
background: linear-gradient(135deg, var(--color-gray-50) 0%, var(--color-gray-100) 100%);
|
||
overflow: hidden;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
transition: transform var(--duration-slower) ease;
|
||
}
|
||
|
||
&:hover img {
|
||
transform: scale(1.05);
|
||
}
|
||
}
|
||
|
||
&__preview-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
.anticon {
|
||
font-size: 40px;
|
||
color: var(--color-gray-500);
|
||
}
|
||
}
|
||
|
||
&__overlay {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
padding: var(--space-2) var(--space-3);
|
||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
|
||
opacity: 0;
|
||
transition: opacity var(--duration-slow) ease;
|
||
}
|
||
|
||
&__play-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--space-2);
|
||
background: rgba(0, 0, 0, 0.4);
|
||
opacity: 0;
|
||
transition: opacity var(--duration-base);
|
||
cursor: pointer;
|
||
|
||
.anticon {
|
||
font-size: 48px;
|
||
color: white;
|
||
}
|
||
|
||
.duration-tag {
|
||
padding: 2px 8px;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
border-radius: 4px;
|
||
color: white;
|
||
font-size: var(--font-size-xs);
|
||
}
|
||
}
|
||
|
||
&__preview:hover &__play-overlay {
|
||
opacity: 1;
|
||
}
|
||
|
||
&__type-tag {
|
||
font-size: var(--font-size-xs);
|
||
font-weight: 600;
|
||
color: white;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
// 信息区
|
||
&__info {
|
||
padding: var(--space-3) var(--space-4);
|
||
}
|
||
|
||
&__name {
|
||
font-size: var(--font-size-base);
|
||
font-weight: 600;
|
||
color: var(--color-gray-900);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
line-height: 1.4;
|
||
margin-bottom: var(--space-2);
|
||
transition: color var(--duration-fast);
|
||
|
||
&:hover {
|
||
color: var(--color-primary-500);
|
||
}
|
||
|
||
:deep(.ant-input) {
|
||
font-size: var(--font-size-base);
|
||
font-weight: 600;
|
||
height: 22px;
|
||
border-radius: var(--radius-sm);
|
||
border-color: var(--color-primary-500);
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
}
|
||
|
||
&__meta {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-gray-500);
|
||
|
||
.meta-dot {
|
||
margin: 0 var(--space-2);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// 底部操作栏
|
||
// ========================================
|
||
.material-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--space-3) var(--space-6);
|
||
background: var(--color-bg-card);
|
||
border-top: 1px solid var(--color-gray-200);
|
||
|
||
.footer-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-4);
|
||
}
|
||
|
||
.footer-count {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-gray-500);
|
||
}
|
||
|
||
:deep(.ant-pagination) {
|
||
.ant-pagination-item {
|
||
border-radius: var(--radius-base);
|
||
border-color: var(--color-gray-200);
|
||
|
||
&:hover {
|
||
border-color: var(--color-primary-500);
|
||
|
||
a {
|
||
color: var(--color-primary-500);
|
||
}
|
||
}
|
||
}
|
||
|
||
.ant-pagination-item-active {
|
||
border-color: var(--color-primary-500);
|
||
background: var(--color-primary-500);
|
||
|
||
a {
|
||
color: white;
|
||
}
|
||
}
|
||
|
||
.ant-pagination-options {
|
||
.ant-select-selector {
|
||
border-radius: var(--radius-base);
|
||
border-color: var(--color-gray-200);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// 弹窗样式
|
||
// ========================================
|
||
:deep(.ant-modal-confirm) {
|
||
.ant-modal-content {
|
||
border-radius: var(--radius-lg);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.ant-modal-body {
|
||
padding: var(--space-6);
|
||
}
|
||
|
||
.ant-modal-footer {
|
||
padding: var(--space-4) var(--space-6);
|
||
border-top: 1px solid var(--color-gray-200);
|
||
}
|
||
}
|
||
</style>
|