Files
sionrui/frontend/app/web-gold/src/views/material/MaterialListNew.vue
2026-02-26 21:04:33 +08:00

1400 lines
36 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" @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>
</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"
:uploading="uploadLoading"
@confirm="handleFileUpload"
/>
<!-- 新建分组弹窗 -->
<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>
</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
} 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 { useUpload } from '@/composables/useUpload';
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue';
import { useUserStore } from '@/stores/user';
// 用户状态(获取存储配额)
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 uploadLoading = ref(false)
const createGroupModalVisible = ref(false)
// 表单数据
const createGroupForm = reactive({
name: '',
description: '',
category: 'MIX'
})
// 文件选择
const selectedFileIds = 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
})
// Hooks
const { upload } = useUpload()
// 方法
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 handleDeleteGroup = async (group, event) => {
event?.stopPropagation?.()
const confirmed = await new Promise((resolve) => {
Modal.confirm({
title: '删除分组',
content: `确定要删除分组「${group.name}」吗?删除后该分组下的所有文件将被移动到默认分组。此操作不可恢复。`,
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
centered: true,
class: 'delete-group-modal',
onOk() {
resolve(true)
},
onCancel() {
resolve(false)
},
})
})
if (!confirmed) return
try {
loading.value = true
await MaterialGroupService.deleteGroup(group.id)
// 移除已删除的分组
const index = groupList.value.findIndex(g => g.id === group.id)
if (index > -1) {
groupList.value.splice(index, 1)
}
// 重新选择分组
if (selectedGroupId.value === group.id) {
selectedGroupId.value = groupList.value.length > 0 ? 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 handleFileUpload = async (filesWithCover, category, groupId) => {
try {
uploadLoading.value = true
// 根据当前分类确定 fileCategory
// 混剪素材用 video数字人素材用 digital_human
const fileCategory = activeCategory.value === 'DIGITAL_HUMAN' ? 'digital_human' : 'video'
// 数字人素材不关联分组
const uploadGroupId = activeCategory.value === 'DIGITAL_HUMAN' ? null : groupId
for (const fileWithCover of filesWithCover) {
await upload(fileWithCover.file, {
fileCategory,
groupId: uploadGroupId,
coverBase64: fileWithCover.coverBase64,
onStart: () => {},
onProgress: () => {},
onSuccess: (id) => {
console.log('文件上传成功:', id)
},
onError: (error) => {
message.error(error.message || '上传失败')
}
})
}
message.success(`成功上传 ${filesWithCover.length} 个文件`)
uploadModalVisible.value = false
await loadFileList()
// 刷新存储配额
await userStore.fetchUserProfile()
// 混剪素材才刷新分组列表
if (activeCategory.value === 'MIX') {
await loadGroupList()
}
} catch (error) {
console.error("文件上传失败:", error)
message.error("文件上传失败: " + (error.message || "未知错误"))
} finally {
uploadLoading.value = false
}
}
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 new Promise((resolve) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除选中的 ${count} 个文件吗?此操作不可恢复。`,
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
centered: true,
class: 'batch-delete-modal',
onOk() {
resolve(true)
},
onCancel() {
resolve(false)
},
})
})
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 = pagination.current - 1
await loadFileList()
}
message.success(`成功删除 ${count} 个文件`)
} catch (error) {
console.error('批量删除失败:', error)
message.error('删除失败,请重试')
} finally {
loading.value = false
}
}
const formatFileSize = (size) => {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(2) + ' MB'
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
}
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'
}
// 监听分类变化
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;
}
&__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(.batch-delete-modal),
:deep(.delete-group-modal) {
.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>