You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1051 lines
27 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="resources-container">
<!-- 使用通用顶部导航栏组件 -->
<TopNavbar />
<!-- 主要内容区域 -->
<div class="resources-main">
<div class="resources-content">
<!-- 侧边栏 -->
<aside class="resources-sidebar">
<!-- 上传按钮 -->
<div class="upload-section">
<el-button
type="primary"
size="large"
class="upload-btn animate-glow"
@click="showUploadDialog = true"
>
<el-icon><Upload /></el-icon>
上传资源
</el-button>
</div>
<!-- 资源分类 -->
<div class="categories-section card-light">
<h3 class="section-title">资源分类</h3>
<div class="categories-list">
<div
class="category-item"
:class="{ active: selectedCategory === null }"
@click="selectCategory(null)"
>
<el-icon class="category-icon">
<Folder />
</el-icon>
<span class="category-name">全部资源</span>
<span class="resource-count">{{ allResourcesCount }}</span>
</div>
<div
v-for="category in categories"
:key="category.id"
class="category-item"
:class="{ active: selectedCategory === category.id }"
@click="selectCategory(category.id)"
>
<el-icon class="category-icon">
<Folder />
</el-icon>
<span class="category-name">{{ category.name }}</span>
<span class="resource-count">{{ category.resourceCount || 0 }}</span>
</div>
</div>
</div>
<!-- 文件类型筛选 -->
<div class="file-types-section card-light">
<h3 class="section-title">文件类型</h3>
<div class="file-types-list">
<el-tag
v-for="type in fileTypes"
:key="type.name"
class="file-type-tag"
:class="{ active: selectedFileType === type.name }"
@click="selectFileType(type.name)"
>
<el-icon>
<Document />
</el-icon>
{{ type.label }}
</el-tag>
</div>
</div>
</aside>
<!-- 资源列表区域 -->
<main class="resources-area">
<!-- 搜索和筛选栏 -->
<div class="search-section card-light">
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索资源标题或描述..."
size="large"
class="search-input"
@keyup.enter="loadResources"
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #append>
<el-button @click="loadResources">搜索</el-button>
</template>
</el-input>
</div>
<div class="filter-options">
<el-select v-model="sortBy" placeholder="排序方式" size="default" @change="loadResources">
<el-option label="最新上传" value="latest" />
<el-option label="下载最多" value="downloads" />
<el-option label="点赞最多" value="likes" />
<el-option label="文件大小" value="size" />
</el-select>
<el-button @click="refreshResources" circle>
<el-icon><Refresh /></el-icon>
</el-button>
</div>
</div>
<!-- 加载状态 -->
<div v-loading="loading" class="loading-container">
<!-- 资源列表 -->
<div class="resources-list" v-if="!loading">
<div
v-for="resource in resources"
:key="resource.id"
class="resource-card card-light animate-fade-in-up"
@click="goToResourceDetail(resource.id)"
>
<div class="resource-header">
<div class="file-icon">
<el-icon :size="32" :color="getFileTypeColor(resource.fileType)">
<Document />
</el-icon>
</div>
<div class="resource-info">
<h3 class="resource-title">{{ resource.title }}</h3>
<p class="resource-description">{{ resource.description }}</p>
<div class="resource-meta">
<span class="file-size">{{ formatFileSize(resource.fileSize) }}</span>
<span class="file-type">{{ getFileTypeLabel(resource.fileType) }}</span>
<span class="upload-time">{{ formatDate(resource.createdAt) }}</span>
</div>
</div>
<div class="resource-actions" @click.stop>
<el-button type="primary" @click="downloadResource(resource)" :loading="resource.downloading">
<el-icon><Download /></el-icon>
下载
</el-button>
<el-button
:text="!resource.isLiked"
:type="resource.isLiked ? 'danger' : 'default'"
@click="toggleLike(resource)"
:loading="resource.liking"
>
<el-icon><Star /></el-icon>
{{ resource.likeCount }}
</el-button>
</div>
</div>
<div class="resource-footer">
<div class="uploader-info">
<el-avatar :size="24" :src="resource.avatar">
{{ resource.nickname?.charAt(0) }}
</el-avatar>
<span class="uploader-name">{{ resource.nickname }}</span>
</div>
<div class="resource-stats">
<span class="stat-item">
<el-icon><Download /></el-icon>
{{ resource.downloadCount }}
</span>
<span class="stat-item">
<el-icon><Star /></el-icon>
{{ resource.likeCount }}
</span>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="resources.length === 0 && !loading" class="empty-state">
<el-empty description="暂无资源">
<el-button type="primary" @click="showUploadDialog = true">
上传第一个资源
</el-button>
</el-empty>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalResources"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadResources"
@current-change="loadResources"
/>
</div>
</div>
</main>
</div>
</div>
<!-- 上传资源对话框 -->
<el-dialog
v-model="showUploadDialog"
title="上传资源"
width="600px"
class="upload-dialog"
>
<el-form :model="uploadForm" :rules="uploadRules" ref="uploadFormRef" label-position="top">
<el-form-item label="资源标题" prop="title">
<el-input
v-model="uploadForm.title"
placeholder="请输入资源标题"
size="large"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="资源分类" prop="categoryId">
<el-select
v-model="uploadForm.categoryId"
placeholder="请选择分类"
size="large"
style="width: 100%"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</el-form-item>
<el-form-item label="资源描述" prop="description">
<el-input
v-model="uploadForm.description"
type="textarea"
:rows="3"
placeholder="请输入资源描述..."
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="上传文件" prop="file">
<el-upload
ref="uploadRef"
class="upload-area"
drag
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
accept=".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.zip,.rar"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将文件拖到此处,或<em>点击选择文件</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持 PDF、Word、PPT、Excel、压缩包等格式文件大小不超过 50MB
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUploadDialog = false">取消</el-button>
<el-button
type="primary"
@click="handleUploadResource"
:loading="uploading"
>
{{ uploading ? '上传中...' : '上传' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules, UploadInstance, UploadProps } from 'element-plus'
import {
Upload,
Search,
Refresh,
Download,
Star,
Setting,
Folder,
Document,
UploadFilled
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import {
getResources,
uploadResource,
downloadResource as downloadResourceAPI,
likeResource,
type Resource as BaseResource
} from '@/api/resources'
import { getCategories } from '@/api/forum'
import type { ApiResponse, Category } from '@/types'
import TopNavbar from '@/components/TopNavbar.vue'
// ResourceUI
interface ExtendedResource extends BaseResource {
downloading?: boolean
liking?: boolean
}
const router = useRouter()
const userStore = useUserStore()
//
const loading = ref(false)
const uploading = ref(false)
const showUploadDialog = ref(false)
const searchKeyword = ref('')
const selectedCategory = ref<number | null>(null)
const selectedFileType = ref<string | null>(null)
const sortBy = ref('latest')
const currentPage = ref(1)
const pageSize = ref(10)
const totalResources = ref(0)
const totalPages = ref(0)
const allResourcesCount = ref(0) // 总资源数量,用于"全部资源"显示
// 数据列表
const resources = ref<ExtendedResource[]>([])
const categories = ref<any[]>([])
// 上传表单
const uploadFormRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
const uploadForm = reactive({
title: '',
description: '',
categoryId: null as number | null,
file: null as File | null
})
// 表单验证规则
const uploadRules: FormRules = {
title: [
{ required: true, message: '请输入资源标题', trigger: 'blur' },
{ min: 2, max: 100, message: '标题长度在 2 到 100 个字符', trigger: 'blur' }
],
categoryId: [
{ required: true, message: '请选择资源分类', trigger: 'change' }
],
description: [
{ required: true, message: '请输入资源描述', trigger: 'blur' },
{ min: 10, max: 500, message: '描述长度在 10 到 500 个字符', trigger: 'blur' }
],
file: [
{ required: true, message: '请选择要上传的文件', trigger: 'change' }
]
}
// 文件类型
const fileTypes = ref([
{ name: 'pdf', label: 'PDF' },
{ name: 'doc', label: 'Word' },
{ name: 'ppt', label: 'PPT' },
{ name: 'excel', label: 'Excel' },
{ name: 'zip', label: '压缩包' }
])
// 方法
const selectCategory = (categoryId: number | null) => {
selectedCategory.value = selectedCategory.value === categoryId ? null : categoryId
currentPage.value = 1
loadResources()
}
const selectFileType = (fileType: string | null) => {
selectedFileType.value = selectedFileType.value === fileType ? null : fileType
currentPage.value = 1
loadResources()
}
const refreshResources = () => {
currentPage.value = 1
loadResources()
ElMessage.success('刷新成功')
}
// 跳转到资源详情页面
const goToResourceDetail = (resourceId: number) => {
router.push(`/resources/${resourceId}`)
}
// 加载资源列表
const loadResources = async () => {
try {
loading.value = true
const params = {
page: currentPage.value,
size: pageSize.value,
category: selectedCategory.value || undefined,
keyword: searchKeyword.value || undefined
}
console.log('正在请求资源列表,参数:', params)
console.log('请求URL: /resources')
const response = await getResources(params) as any as ApiResponse<{
total: number
list: BaseResource[]
pages: number
}>
console.log('资源列表API响应:', response)
if (response.code === 200) {
resources.value = response.data.list.map(item => ({
...item,
downloading: false,
liking: false
}))
totalResources.value = response.data.total
totalPages.value = response.data.pages
// 只有在未选择分类时才更新总数(即显示所有资源时)
if (!selectedCategory.value && !searchKeyword.value.trim()) {
allResourcesCount.value = response.data.total || 0
}
console.log('资源列表加载成功:', {
total: totalResources.value,
pages: totalPages.value,
list: resources.value
})
} else {
console.error('资源列表API返回错误:', response)
ElMessage.error(response.message || '获取资源列表失败')
}
} catch (error) {
console.error('获取资源列表失败:', error)
ElMessage.error('获取资源列表失败')
} finally {
loading.value = false
}
}
// 加载分类列表
const loadCategories = async () => {
try {
console.log('正在请求分类列表...')
const response = await getCategories() as any as ApiResponse<{
total: number
list: Category[]
}>
console.log('分类列表API响应:', response)
console.log(response.code)
if (response.code === 200) {
categories.value = response.data.list
console.log('分类列表加载成功:', categories.value)
// 加载每个分类的资源数量
await loadCategoryResourceCounts()
} else {
console.error('分类列表API返回错误:', response)
ElMessage.error(response.message || '获取分类列表失败')
}
} catch (error) {
console.error('获取分类列表失败:', error)
ElMessage.error('获取分类列表失败')
}
}
// 加载每个分类的资源数量
const loadCategoryResourceCounts = async () => {
try {
for (const category of categories.value) {
try {
const response = await getResources({
category: category.id,
page: 1,
size: 1
}) as any as ApiResponse<{
total: number
list: any[]
pages: number
}>
if (response.code === 200) {
// 直接修改分类对象的resourceCount属性
category.resourceCount = response.data.total || 0
}
} catch (error) {
console.error(`加载分类${category.name}的资源数量失败:`, error)
category.resourceCount = 0
}
}
console.log('分类资源数量加载完成:', categories.value.map(c => ({ name: c.name, count: c.resourceCount })))
} catch (error) {
console.error('加载分类资源数量失败:', error)
}
}
// 下载资源
const downloadResource = async (resource: ExtendedResource) => {
try {
resource.downloading = true
const response = await downloadResourceAPI(resource.id) as any as ApiResponse<{
fileUrl: string
fileName: string
fileType: string
}>
console.log('下载资源API响应:', response)
if (response.code === 200) {
const downloadUrl = response.data.fileUrl
// 创建下载链接
const link = document.createElement('a')
link.href = downloadUrl
link.download = response.data.fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 更新下载次数
resource.downloadCount++
ElMessage.success('下载开始')
} else {
ElMessage.error(response.message || '下载失败')
}
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败')
} finally {
resource.downloading = false
}
}
// 点赞/取消点赞
const toggleLike = async (resource: ExtendedResource) => {
try {
resource.liking = true
const response = await likeResource(resource.id) as any as ApiResponse<null>
console.log('点赞API响应:', response)
if (response.code === 200) {
resource.isLiked = !resource.isLiked
resource.likeCount += resource.isLiked ? 1 : -1
ElMessage.success(resource.isLiked ? '点赞成功' : '取消点赞')
} else {
ElMessage.error(response.message || '操作失败')
}
} catch (error) {
console.error('点赞操作失败:', error)
ElMessage.error('操作失败')
} finally {
resource.liking = false
}
}
// 文件选择处理
const handleFileChange: UploadProps['onChange'] = (uploadFile) => {
if (uploadFile.raw) {
// 检查文件大小50MB限制
if (uploadFile.raw.size > 50 * 1024 * 1024) {
ElMessage.error('文件大小不能超过 50MB')
uploadRef.value?.clearFiles()
return
}
uploadForm.file = uploadFile.raw
}
}
// 文件移除处理
const handleFileRemove = () => {
uploadForm.file = null
}
// 上传资源
const handleUploadResource = async () => {
if (!uploadFormRef.value) return
try {
await uploadFormRef.value.validate()
if (!uploadForm.file) {
ElMessage.error('请选择要上传的文件')
return
}
uploading.value = true
const response = await uploadResource({
file: uploadForm.file,
title: uploadForm.title,
description: uploadForm.description,
categoryId: uploadForm.categoryId!
}) as any as ApiResponse<{ resourceId: number }>
console.log('上传资源API响应:', response)
if (response.code === 200) {
ElMessage.success('资源上传成功!')
showUploadDialog.value = false
resetUploadForm()
loadResources() // 重新加载资源列表
} else {
ElMessage.error(response.message || '上传失败')
}
} catch (error) {
console.error('上传失败:', error)
ElMessage.error('上传失败')
} finally {
uploading.value = false
}
}
// 重置上传表单
const resetUploadForm = () => {
uploadForm.title = ''
uploadForm.description = ''
uploadForm.categoryId = null
uploadForm.file = null
uploadRef.value?.clearFiles()
uploadFormRef.value?.clearValidate()
}
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 格式化日期
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
// 获取文件类型标签
const getFileTypeLabel = (mimeType: string) => {
const typeMap: { [key: string]: string } = {
'application/pdf': 'PDF',
'application/msword': 'Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word',
'application/vnd.ms-powerpoint': 'PPT',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PPT',
'application/vnd.ms-excel': 'Excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel',
'application/zip': 'ZIP',
'application/x-rar-compressed': 'RAR'
}
return typeMap[mimeType] || '文件'
}
// 获取文件类型颜色
const getFileTypeColor = (mimeType: string) => {
const colorMap: { [key: string]: string } = {
'application/pdf': '#ff4757',
'application/msword': '#2e86de',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '#2e86de',
'application/vnd.ms-powerpoint': '#ff6348',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '#ff6348',
'application/vnd.ms-excel': '#2ed573',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '#2ed573',
'application/zip': '#a4b0be',
'application/x-rar-compressed': '#a4b0be'
}
return colorMap[mimeType] || '#6c5ce7'
}
// 用户操作处理
const handleCommand = (command: string) => {
if (command === 'logout') {
userStore.logout()
router.push('/login')
} else if (command === 'profile') {
router.push('/profile')
}
}
// 监听对话框关闭,重置表单
watch(showUploadDialog, (newVal) => {
if (!newVal) {
resetUploadForm()
}
})
// 页面加载时获取数据
onMounted(async () => {
console.log('资源页面加载完成')
await loadCategories()
await loadResources()
await loadTotalResourcesCount()
})
// 加载总资源数量
const loadTotalResourcesCount = async () => {
try {
const response = await getResources({ page: 1, size: 1 }) as any as ApiResponse<{
total: number
list: any[]
pages: number
}>
if (response.code === 200) {
allResourcesCount.value = response.data.total || 0
}
} catch (error) {
console.error('加载总资源数失败:', error)
}
}
</script>
<style scoped>
.resources-container {
min-height: 100vh;
background: var(--gradient-bg);
}
/* 主要内容区域 */
.resources-main {
padding: 32px 24px;
}
.resources-content {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 280px 1fr;
gap: 32px;
}
/* 侧边栏样式 */
.resources-sidebar {
display: flex;
flex-direction: column;
gap: 24px;
}
.upload-section {
position: sticky;
top: 100px;
}
.upload-btn {
width: 100%;
height: 52px;
border-radius: 16px;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.section-title {
color: var(--gray-800);
font-size: 16px;
font-weight: 700;
margin-bottom: 16px;
}
.categories-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.category-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 12px;
cursor: pointer;
transition: var(--transition-base);
}
.category-item:hover {
background: var(--primary-50);
}
.category-item.active {
background: var(--primary-100);
color: var(--primary-700);
}
.category-icon {
color: var(--primary-500);
}
.category-name {
flex: 1;
font-weight: 500;
}
.resource-count {
font-size: 12px;
color: var(--gray-500);
background: var(--gray-100);
padding: 2px 8px;
border-radius: 12px;
}
.file-types-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.file-type-tag {
cursor: pointer;
transition: var(--transition-base);
display: flex;
align-items: center;
gap: 4px;
}
.file-type-tag:hover {
transform: translateY(-1px);
}
.file-type-tag.active {
background: var(--primary-100);
color: var(--primary-700);
}
/* 资源区域样式 */
.resources-area {
display: flex;
flex-direction: column;
gap: 24px;
}
.search-section {
padding: 24px;
display: flex;
gap: 16px;
align-items: center;
}
.search-bar {
flex: 1;
}
.filter-options {
display: flex;
gap: 12px;
align-items: center;
}
.resources-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.resource-card {
padding: 24px;
transition: var(--transition-base);
cursor: pointer;
}
.resource-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-purple);
}
.resource-header {
display: flex;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
}
.file-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--gray-50);
border-radius: 12px;
}
.resource-info {
flex: 1;
}
.resource-title {
font-size: 18px;
font-weight: 700;
color: var(--gray-800);
margin: 0 0 8px 0;
line-height: 1.4;
}
.resource-description {
color: var(--gray-600);
line-height: 1.6;
margin: 0 0 12px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.resource-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--gray-500);
}
.resource-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.resource-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid var(--gray-200);
}
.uploader-info {
display: flex;
align-items: center;
gap: 8px;
}
.uploader-name {
font-size: 14px;
color: var(--gray-600);
font-weight: 500;
}
.resource-stats {
display: flex;
gap: 16px;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: var(--gray-500);
}
/* 分页 */
.pagination-container {
display: flex;
justify-content: center;
padding: 32px 0;
}
/* 表单行样式 */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
}
/* 上传对话框样式 */
.upload-dialog :deep(.el-upload-dragger) {
border: 2px dashed var(--primary-300);
border-radius: 12px;
background: var(--primary-50);
transition: var(--transition-base);
}
.upload-dialog :deep(.el-upload-dragger:hover) {
border-color: var(--primary-400);
background: var(--primary-100);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.resources-content {
grid-template-columns: 1fr;
}
.resources-sidebar {
order: 2;
}
.resources-area {
order: 1;
}
}
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.resources-main {
padding: 16px;
}
.resources-content {
gap: 16px;
}
.search-section {
flex-direction: column;
align-items: stretch;
}
.filter-options {
justify-content: center;
}
.resource-header {
flex-direction: column;
gap: 12px;
}
.resource-actions {
flex-direction: row;
align-items: center;
}
}
</style>