功能优化

This commit is contained in:
2025-11-16 19:35:55 +08:00
parent c2bd94cfad
commit bdbe74cebb
53 changed files with 8235 additions and 107 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
<template>
<div class="material-group">
<div class="material-group__header">
<h1 class="material-group__title">素材分组</h1>
<a-button type="primary" @click="handleCreateGroup">
<template #icon>
<PlusOutlined />
</template>
新建分组
</a-button>
</div>
<!-- 分组列表 -->
<div class="material-group__content">
<a-spin :spinning="loading" tip="加载中..." style="width: 100%; min-height: 400px;">
<template v-if="groupList.length > 0">
<a-list
:data="groupList"
:bordered="false"
>
<template #item="{ item }">
<a-list-item class="group-item">
<a-list-item-meta>
<template #title>
<div class="group-item__header">
<span class="group-item__name">{{ item.name }}</span>
<a-tag>{{ item.fileCount || 0 }} 个文件</a-tag>
</div>
</template>
<template #description>
<div class="group-item__description">
{{ item.description || '暂无描述' }}
</div>
<div class="group-item__meta">
<span>创建时间{{ formatDate(item.createTime) }}</span>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a-button type="text" @click="handleEditGroup(item)">
<template #icon>
<EditOutlined />
</template>
编辑
</a-button>
<a-button type="text" status="danger" @click="handleDeleteGroup(item)">
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</template>
<template v-else>
<a-empty description="暂无分组" />
</template>
</a-spin>
</div>
<!-- 创建/编辑分组对话框 -->
<a-modal
v-model:visible="groupModalVisible"
:title="groupModalTitle"
@ok="handleSaveGroup"
@cancel="handleCancelGroup"
>
<a-form :model="groupForm" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-form-item label="分组名称" field="name" :rules="[{ required: true, message: '请输入分组名称' }]">
<a-input v-model="groupForm.name" placeholder="请输入分组名称" />
</a-form-item>
<a-form-item label="分组描述" field="description">
<a-textarea
v-model="groupForm.description"
placeholder="请输入分组描述"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
<a-form-item label="排序" field="sort">
<a-input-number v-model="groupForm.sort" :min="0" placeholder="排序值" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import { MaterialGroupService } from '@/api/material'
// 数据
const loading = ref(false)
const groupList = ref([])
const groupModalVisible = ref(false)
const groupModalTitle = ref('新建分组')
const isEdit = ref(false)
// 表单
const groupForm = reactive({
id: undefined,
name: '',
description: '',
sort: 0
})
// 加载分组列表
const loadGroupList = async () => {
if (loading.value) return // 防止重复请求
loading.value = true
try {
const res = await MaterialGroupService.getGroupList()
if (res.code === 0) {
groupList.value = res.data || []
} else {
message.error(res.msg || '加载失败')
groupList.value = []
}
} catch (error) {
console.error('加载分组列表失败:', error)
message.error('加载失败,请重试')
groupList.value = []
} finally {
loading.value = false
}
}
// 创建分组
const handleCreateGroup = () => {
isEdit.value = false
groupModalTitle.value = '新建分组'
groupForm.id = undefined
groupForm.name = ''
groupForm.description = ''
groupForm.sort = 0
groupModalVisible.value = true
}
// 编辑分组
const handleEditGroup = (group) => {
isEdit.value = true
groupModalTitle.value = '编辑分组'
groupForm.id = group.id
groupForm.name = group.name
groupForm.description = group.description || ''
groupForm.sort = group.sort || 0
groupModalVisible.value = true
}
// 保存分组
const handleSaveGroup = async () => {
if (!groupForm.name.trim()) {
message.warning('请输入分组名称')
return
}
try {
if (isEdit.value) {
await MaterialGroupService.updateGroup(groupForm)
message.success('更新成功')
} else {
await MaterialGroupService.createGroup(groupForm)
message.success('创建成功')
}
groupModalVisible.value = false
loadGroupList()
} catch (error) {
console.error('保存分组失败:', error)
message.error(error.message || '保存失败,请重试')
}
}
// 取消
const handleCancelGroup = () => {
groupModalVisible.value = false
groupForm.id = undefined
groupForm.name = ''
groupForm.description = ''
groupForm.sort = 0
}
// 删除分组
const handleDeleteGroup = (group) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除分组"${group.name}"吗?删除后分组内的文件不会被删除。`,
onOk: async () => {
try {
await MaterialGroupService.deleteGroup(group.id)
message.success('删除成功')
loadGroupList()
} catch (error) {
console.error('删除分组失败:', error)
message.error(error.message || '删除失败,请重试')
}
}
})
}
// 工具函数
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 初始化
onMounted(() => {
loadGroupList()
})
</script>
<style scoped>
.material-group {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
}
.material-group__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.material-group__title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.material-group__content {
flex: 1;
overflow-y: auto;
position: relative;
}
.material-group__content :deep(.arco-spin) {
min-height: 400px;
}
.material-group__content :deep(.arco-spin-content) {
min-height: 400px;
}
.group-item {
padding: 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
margin-bottom: 12px;
transition: all 0.2s;
}
.group-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.group-item__header {
display: flex;
align-items: center;
gap: 12px;
}
.group-item__name {
font-size: 16px;
font-weight: 500;
}
.group-item__description {
margin-top: 8px;
color: var(--color-text-2);
}
.group-item__meta {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-3);
}
</style>

View File

@@ -0,0 +1,482 @@
<template>
<div class="material-list">
<div class="material-list__header">
<h1 class="material-list__title">素材列表</h1>
<div class="material-list__actions">
<a-button type="primary" @click="handleOpenUploadModal">
<template #icon>
<UploadOutlined />
</template>
上传素材
</a-button>
<a-button
v-if="selectedFileIds.length > 0"
type="primary"
status="danger"
@click="handleBatchDelete"
>
批量删除 ({{ selectedFileIds.length }})
</a-button>
</div>
</div>
<!-- 筛选条件 -->
<div class="material-list__filters">
<a-space>
<a-input
v-model="filters.fileName"
placeholder="搜索文件名"
style="width: 200px"
allow-clear
@press-enter="handleFilterChange"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-range-picker
v-model:value="filters.createTime"
style="width: 300px"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="handleFilterChange"
/>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
<a-button @click="handleResetFilters">重置</a-button>
</a-space>
</div>
<!-- 文件列表 -->
<div class="material-list__content">
<a-spin :spinning="loading" tip="加载中..." style="width: 100%; min-height: 400px;">
<template v-if="fileList.length > 0">
<div class="material-grid">
<div
v-for="file in fileList"
:key="file.id"
class="material-item"
:class="{ 'material-item--selected': selectedFileIds.includes(file.id) }"
@click="handleFileClick(file)"
>
<div class="material-item__content">
<!-- 预览图 -->
<div class="material-item__preview">
<img
v-if="file.previewUrl"
:src="file.previewUrl"
:alt="file.fileName"
@error="handleImageError"
/>
<div v-else class="material-item__placeholder">
<FileOutlined />
</div>
<!-- 文件类型标识 -->
<div class="material-item__badge">
<a-tag v-if="file.isVideo" color="red">视频</a-tag>
<a-tag v-else-if="file.isImage" color="blue">图片</a-tag>
<a-tag v-else color="gray">文件</a-tag>
</div>
<!-- 选中复选框 -->
<div class="material-item__checkbox">
<a-checkbox
:model-value="selectedFileIds.includes(file.id)"
@click.stop
@change="(checked) => handleSelectFile(file.id, checked)"
/>
</div>
</div>
<!-- 文件信息 -->
<div class="material-item__info">
<div class="material-item__name" :title="file.fileName">
{{ file.fileName }}
</div>
<div class="material-item__meta">
<span>{{ formatFileSize(file.fileSize) }}</span>
<span>{{ formatDate(file.createTime) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<a-empty description="暂无素材" />
</template>
</a-spin>
</div>
<!-- 分页 -->
<div class="material-list__pagination">
<a-pagination
v-model:current="pagination.pageNo"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:show-total="(total) => `${total}`"
:show-size-changer="true"
@change="handlePageChange"
/>
</div>
<!-- 上传对话框 -->
<MaterialUploadModal
v-model:visible="uploadModalVisible"
:uploading="uploading"
@confirm="handleConfirmUpload"
@cancel="handleUploadCancel"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
UploadOutlined,
SearchOutlined,
FileOutlined
} from '@ant-design/icons-vue'
import { MaterialService } from '@/api/material'
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue'
// 数据
const loading = ref(false)
const fileList = ref([])
const selectedFileIds = ref([])
const uploadModalVisible = ref(false)
const uploading = ref(false)
// 筛选条件
const filters = reactive({
fileCategory: undefined,
fileName: '',
createTime: undefined
})
// 分页
const pagination = reactive({
pageNo: 1,
pageSize: 20,
total: 0
})
// 加载文件列表
const loadFileList = async () => {
loading.value = true
try {
const params = {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
...filters
}
// 处理日期范围a-range-picker返回的是数组格式的字符串
// 日期区间:将开始日期设置为 00:00:00结束日期设置为 23:59:59
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
params.createTime = [
filters.createTime[0] + ' 00:00:00',
filters.createTime[1] + ' 23:59:59'
]
}
const res = await MaterialService.getFilePage(params)
if (res.code === 0) {
fileList.value = res.data.list || []
pagination.total = res.data.total || 0
} else {
message.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载文件列表失败:', error)
message.error('加载失败,请重试')
} finally {
loading.value = false
}
}
// 打开上传对话框
const handleOpenUploadModal = () => {
uploadModalVisible.value = true
}
// 确认上传
const handleConfirmUpload = async (files, fileCategory) => {
if (!files || files.length === 0) {
message.warning('请选择文件')
return
}
if (!fileCategory) {
message.warning('请选择文件分类')
return
}
uploading.value = true
let successCount = 0
let failCount = 0
try {
// 逐个上传文件,显示进度
for (const file of files) {
try {
await MaterialService.uploadFile(file, fileCategory)
successCount++
} catch (error) {
console.error('文件上传失败:', file.name, error)
failCount++
}
}
if (successCount > 0) {
message.success(`成功上传 ${successCount} 个文件${failCount > 0 ? `${failCount} 个失败` : ''}`)
uploadModalVisible.value = false
loadFileList()
} else {
message.error('所有文件上传失败,请重试')
}
} catch (error) {
console.error('上传失败:', error)
message.error(error.message || '上传失败,请重试')
} finally {
uploading.value = false
}
}
// 取消上传
const handleUploadCancel = () => {
uploadModalVisible.value = false
}
// 删除文件
const handleBatchDelete = async () => {
if (selectedFileIds.value.length === 0) {
return
}
try {
await MaterialService.deleteFiles(selectedFileIds.value)
message.success('删除成功')
selectedFileIds.value = []
loadFileList()
} catch (error) {
console.error('删除失败:', error)
message.error(error.message || '删除失败,请重试')
}
}
// 选择文件
const handleSelectFile = (fileId, checked) => {
if (checked) {
if (!selectedFileIds.value.includes(fileId)) {
selectedFileIds.value.push(fileId)
}
} else {
const index = selectedFileIds.value.indexOf(fileId)
if (index > -1) {
selectedFileIds.value.splice(index, 1)
}
}
}
// 文件点击
const handleFileClick = (file) => {
// TODO: 打开文件详情或预览
console.log('点击文件:', file)
}
// 筛选
const handleFilterChange = () => {
pagination.pageNo = 1
loadFileList()
}
const handleResetFilters = () => {
filters.fileCategory = undefined
filters.fileName = ''
filters.createTime = undefined
pagination.pageNo = 1
loadFileList()
}
// 分页
const handlePageChange = (page, pageSize) => {
pagination.pageNo = page
if (pageSize && pageSize !== pagination.pageSize) {
pagination.pageSize = pageSize
pagination.pageNo = 1
}
loadFileList()
}
// 工具函数
const formatFileSize = (bytes) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
const handleImageError = (e) => {
e.target.style.display = 'none'
}
// 初始化
onMounted(() => {
loadFileList()
})
</script>
<style scoped>
.material-list {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
}
.material-list__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.material-list__title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.material-list__actions {
display: flex;
gap: 12px;
}
.material-list__filters {
margin-bottom: 24px;
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
}
.material-list__content {
flex: 1;
overflow-y: auto;
margin-bottom: 24px;
position: relative;
}
.material-list__content :deep(.arco-spin) {
min-height: 400px;
}
.material-list__content :deep(.arco-spin-content) {
min-height: 400px;
}
.material-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.material-list__pagination {
display: flex;
justify-content: center;
}
.material-item {
cursor: pointer;
transition: all 0.2s;
}
.material-item:hover {
transform: translateY(-2px);
}
.material-item--selected {
border: 2px solid var(--color-primary);
}
.material-item__content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
overflow: hidden;
transition: all 0.2s;
}
.material-item:hover .material-item__content {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.material-item__preview {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 */
background: var(--color-bg-2);
overflow: hidden;
}
.material-item__preview img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.material-item__placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 48px;
color: var(--color-text-3);
}
.material-item__badge {
position: absolute;
top: 8px;
left: 8px;
}
.material-item__checkbox {
position: absolute;
top: 8px;
right: 8px;
}
.material-item__info {
padding: 12px;
}
.material-item__name {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.material-item__meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--color-text-3);
}
</style>