Files
sionrui/frontend/app/web-gold/src/views/material/MaterialListNew.vue
2026-03-04 19:00:06 +08:00

1438 lines
37 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>
<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>