2991692032 1 month ago
parent 4ab9a50eed
commit ddf06225fd

@ -1,5 +1,25 @@
# UniLife接口文档
## 更新日志
### v1.2.0 (2025-01-27)
- **修复资源点赞功能**: 实现了完整的资源点赞表 (`resource_likes`),防止重复点赞
- **优化响应数据结构**: 统一时间格式为ISO 8601格式 (`yyyy-MM-ddTHH:mm:ss`)
- **完善isLiked字段**: 资源列表和详情接口中的 `isLiked` 字段现在基于当前用户的真实点赞状态
- **增强文件存储**: 文件存储从本地上传改为阿里云OSS支持临时访问链接
- **添加数据库设计说明**: 详细说明了资源表和点赞表的设计
- **修正API参数说明**: 更新了资源列表接口中 `user` 参数的含义,改为 `uploaderUserId`
### v1.1.0 (2025-01-26)
- 完成论坛功能模块的前后端集成
- 实现帖子、评论、点赞等核心功能
- 添加用户认证和权限管理
### v1.0.0 (2025-01-25)
- 初始版本发布
- 实现基础的用户认证功能
- 定义核心数据结构和API接口
## 目录
- [1. 基础信息](#1-基础信息)
- [2. 用户认证模块](#2-用户认证模块)
@ -328,7 +348,7 @@
"title": "最新帖子标题",
"content": "帖子内容摘要...",
"categoryId": 1,
"createTime": "2024-01-15T10:30:00",
"createdAt": "2023-05-01T12:00:00",
"viewsCount": 50,
"likesCount": 10,
"commentsCount": 5
@ -373,7 +393,7 @@
"viewCount": 100,
"likeCount": 20,
"commentCount": 5,
"createdAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00"
}
],
"pages": 10
@ -404,8 +424,8 @@
"likeCount": 20,
"commentCount": 5,
"isLiked": true,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
}
```
@ -521,7 +541,7 @@
"viewCount": 100,
"likeCount": 20,
"commentCount": 5,
"createdAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00"
}
],
"pages": 3
@ -552,7 +572,7 @@
"avatar": "https://example.com/avatar.jpg",
"likeCount": 5,
"isLiked": false,
"createdAt": "2023-05-01 12:30:00",
"createdAt": "2023-05-01T12:30:00",
"replies": [
{
"id": 2,
@ -562,7 +582,7 @@
"avatar": "https://example.com/avatar2.jpg",
"likeCount": 2,
"isLiked": true,
"createdAt": "2023-05-01 12:35:00"
"createdAt": "2023-05-01T12:35:00"
}
]
}
@ -652,8 +672,8 @@
"icon": "icon-study",
"sort": 1,
"status": 1,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
]
}
@ -677,8 +697,8 @@
"icon": "icon-study",
"sort": 1,
"status": 1,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
}
```
@ -792,7 +812,7 @@
"id": 1,
"title": "资源标题",
"description": "资源描述",
"fileUrl": "uploads/resources/file.pdf",
"fileUrl": "https://oss-example.aliyuncs.com/resources/file.pdf",
"fileSize": 1024000,
"fileType": "application/pdf",
"userId": 12345,
@ -803,12 +823,16 @@
"downloadCount": 10,
"likeCount": 5,
"isLiked": false,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
}
```
**说明**:
- `isLiked` 字段表示当前登录用户是否已点赞该资源未登录用户该字段为false
- `fileUrl` 为阿里云OSS存储的文件访问URL
### 5.3 获取资源列表
- **URL**: `/resources`
- **方法**: GET
@ -816,7 +840,7 @@
请求参数:
- **category** (query, 可选): 分类ID
- **user** (query, 可选): 用户ID
- **user** (query, 可选): 上传者用户ID(用于筛选特定用户上传的资源)
- **keyword** (query, 可选): 搜索关键词
- **page** (query, 可选): 页码默认为1
- **size** (query, 可选): 每页大小默认为10
@ -833,7 +857,7 @@
"id": 1,
"title": "资源标题",
"description": "资源描述",
"fileUrl": "uploads/resources/file.pdf",
"fileUrl": "https://oss-example.aliyuncs.com/resources/file.pdf",
"fileSize": 1024000,
"fileType": "application/pdf",
"userId": 12345,
@ -844,8 +868,8 @@
"downloadCount": 10,
"likeCount": 5,
"isLiked": false,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
],
"pages": 10
@ -853,6 +877,10 @@
}
```
**说明**:
- `isLiked` 字段基于当前登录用户的点赞状态,通过查询`resource_likes`表获得
- 未登录用户获取列表时,所有资源的`isLiked`字段均为false
### 5.4 更新资源
- **URL**: `/resources/{id}`
- **方法**: PUT
@ -892,6 +920,8 @@
}
```
**说明**: 删除资源时会同时删除阿里云OSS中的文件和数据库中的相关记录包括点赞记录
### 5.6 下载资源
- **URL**: `/resources/{id}/download`
- **方法**: GET
@ -903,13 +933,17 @@
"code": 200,
"message": "获取下载链接成功",
"data": {
"fileUrl": "uploads/resources/file.pdf",
"fileName": "资源标题",
"fileUrl": "https://oss-example.aliyuncs.com/resources/file.pdf?Expires=1684737600&OSSAccessKeyId=xxx&Signature=xxx",
"fileName": "资源标题.pdf",
"fileType": "application/pdf"
}
}
```
**说明**:
- `fileUrl` 为生成的临时访问URL有效期为1小时
- 每次下载会增加资源的下载计数
### 5.7 点赞/取消点赞资源
- **URL**: `/resources/{id}/like`
- **方法**: POST
@ -925,6 +959,21 @@
}
```
```json
{
"code": 200,
"message": "取消点赞成功",
"data": null
}
```
**实现说明**:
- 使用`resource_likes`表记录用户点赞状态,确保一个用户对同一资源只能点赞一次
- 点赞时会增加资源的`like_count`字段,取消点赞时会减少
- 通过唯一键约束防止重复点赞:`UNIQUE KEY uk_user_resource (user_id, resource_id)`
### 5.8 获取用户上传的资源列表
- **URL**: `/resources/user/{userId}`
- **方法**: GET
@ -946,7 +995,7 @@
"id": 1,
"title": "资源标题",
"description": "资源描述",
"fileUrl": "uploads/resources/file.pdf",
"fileUrl": "https://oss-example.aliyuncs.com/resources/file.pdf",
"fileSize": 1024000,
"fileType": "application/pdf",
"userId": 12345,
@ -957,8 +1006,8 @@
"downloadCount": 10,
"likeCount": 5,
"isLiked": false,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
],
"pages": 2
@ -988,7 +1037,7 @@
"id": 1,
"title": "资源标题",
"description": "资源描述",
"fileUrl": "uploads/resources/file.pdf",
"fileUrl": "https://oss-example.aliyuncs.com/resources/file.pdf",
"fileSize": 1024000,
"fileType": "application/pdf",
"userId": 12345,
@ -999,8 +1048,8 @@
"downloadCount": 10,
"likeCount": 5,
"isLiked": false,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
],
"pages": 2
@ -1008,6 +1057,44 @@
}
```
### 5.10 数据库设计说明
#### 资源表 (resources)
```sql
CREATE TABLE `resources` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源ID',
`user_id` BIGINT NOT NULL COMMENT '上传用户ID',
`title` VARCHAR(100) NOT NULL COMMENT '资源标题',
`description` TEXT DEFAULT NULL COMMENT '资源描述',
`file_url` VARCHAR(255) NOT NULL COMMENT '文件URL(OSS)',
`file_size` BIGINT NOT NULL COMMENT '文件大小(字节)',
`file_type` VARCHAR(50) NOT NULL COMMENT '文件类型',
`category_id` BIGINT NOT NULL COMMENT '分类ID',
`download_count` INT DEFAULT 0 COMMENT '下载次数',
`like_count` INT DEFAULT 0 COMMENT '点赞次数',
`status` TINYINT DEFAULT 1 COMMENT '状态0-删除, 1-正常)',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
);
```
#### 资源点赞表 (resource_likes)
```sql
CREATE TABLE `resource_likes` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '点赞ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`resource_id` BIGINT NOT NULL COMMENT '资源ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY `uk_user_resource` (`user_id`, `resource_id`),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`resource_id`) REFERENCES `resources` (`id`) ON DELETE CASCADE
);
```
**特性**:
- 通过唯一键约束确保用户不能重复点赞同一资源
- 级联删除保证数据一致性
## 6. 课程表与日程管理模块
### 6.1 课程管理
@ -1067,10 +1154,11 @@
"endTime": "09:40:00",
"startWeek": 1,
"endWeek": 16,
"semester": "2023-1",
"color": "#4CAF50",
"status": 1,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
}
```
@ -1100,10 +1188,11 @@
"endTime": "09:40:00",
"startWeek": 1,
"endWeek": 16,
"semester": "2023-1",
"color": "#4CAF50",
"status": 1,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
]
}
@ -1135,10 +1224,11 @@
"endTime": "09:40:00",
"startWeek": 1,
"endWeek": 16,
"semester": "2023-1",
"color": "#4CAF50",
"status": 1,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
]
}
@ -1173,8 +1263,8 @@
"semester": "2023-1",
"color": "#4CAF50",
"status": 1,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
]
}
@ -1198,6 +1288,7 @@
"endTime": "11:40:00",
"startWeek": 1,
"endWeek": 16,
"semester": "2023-1",
"color": "#2196F3"
}
```
@ -1306,8 +1397,8 @@
"reminder": 30,
"color": "#FF5722",
"status": 1,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
}
```
@ -1338,8 +1429,8 @@
"reminder": 30,
"color": "#FF5722",
"status": 1,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
]
}
@ -1376,8 +1467,8 @@
"reminder": 30,
"color": "#FF5722",
"status": 1,
"createdAt": "2023-05-01 12:00:00",
"updatedAt": "2023-05-01 12:00:00"
"createdAt": "2023-05-01T12:00:00",
"updatedAt": "2023-05-01T12:00:00"
}
]
}
@ -1468,6 +1559,54 @@
}
```
### 6.3 数据库设计说明
#### 课程表 (courses)
```sql
CREATE TABLE `courses` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '课程ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`name` VARCHAR(100) NOT NULL COMMENT '课程名称',
`teacher` VARCHAR(50) DEFAULT NULL COMMENT '教师姓名',
`location` VARCHAR(100) DEFAULT NULL COMMENT '上课地点',
`day_of_week` TINYINT NOT NULL COMMENT '星期几1-7',
`start_time` TIME NOT NULL COMMENT '开始时间',
`end_time` TIME NOT NULL COMMENT '结束时间',
`start_week` TINYINT NOT NULL COMMENT '开始周次',
`end_week` TINYINT NOT NULL COMMENT '结束周次',
`semester` VARCHAR(20) DEFAULT NULL COMMENT '学期2023-1',
`color` VARCHAR(20) DEFAULT NULL COMMENT '显示颜色',
`status` TINYINT DEFAULT 1 COMMENT '状态0-删除, 1-正常)',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
);
```
#### 日程表 (schedules)
```sql
CREATE TABLE `schedules` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '日程ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`title` VARCHAR(100) NOT NULL COMMENT '日程标题',
`description` TEXT DEFAULT NULL COMMENT '日程描述',
`start_time` DATETIME NOT NULL COMMENT '开始时间',
`end_time` DATETIME NOT NULL COMMENT '结束时间',
`location` VARCHAR(100) DEFAULT NULL COMMENT '地点',
`is_all_day` TINYINT DEFAULT 0 COMMENT '是否全天0-否, 1-是)',
`reminder` TINYINT DEFAULT NULL COMMENT '提醒时间(分钟)',
`color` VARCHAR(20) DEFAULT NULL COMMENT '显示颜色',
`status` TINYINT DEFAULT 1 COMMENT '状态0-删除, 1-正常)',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
);
```
**特性**:
- 支持学期管理和多学期课程数据
- 支持时间冲突检测
- 支持课程和日程的颜色标记
- 支持日程提醒功能
## 7. 待实现模块
以下模块尚未实现,将在后续开发中完成:

@ -37,6 +37,12 @@ const router = createRouter({
component: () => import('@/views/resources/ResourcesView.vue'),
meta: { requiresAuth: true }
},
{
path: '/resources/:id',
name: 'resource-detail',
component: () => import('@/views/resources/ResourceDetailView.vue'),
meta: { requiresAuth: true }
},
{
path: '/schedule',
name: 'schedule',

@ -0,0 +1,659 @@
<template>
<div class="resource-detail-container">
<!-- 顶部导航栏 -->
<nav class="navbar glass-light">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name gradient-text">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item active">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname }}</span>
</div>
<el-dropdown @command="handleCommand">
<el-button circle>
<el-icon><Setting /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</nav>
<!-- 主要内容区域 -->
<div class="resource-detail-main">
<div class="resource-detail-content" v-loading="loading">
<!-- 返回按钮 -->
<div class="back-section">
<el-button @click="goBack" type="primary" text>
<el-icon><ArrowLeft /></el-icon>
返回资源列表
</el-button>
</div>
<!-- 资源详情卡片 -->
<div v-if="resource" class="resource-detail-card card-light">
<!-- 资源头部信息 -->
<div class="resource-header">
<div class="file-icon-large">
<el-icon :size="64" :color="getFileTypeColor(resource.fileType)">
<Document />
</el-icon>
</div>
<div class="resource-main-info">
<h1 class="resource-title">{{ resource.title }}</h1>
<div class="resource-meta">
<span class="meta-item">
<el-icon><User /></el-icon>
上传者{{ resource.nickname }}
</span>
<span class="meta-item">
<el-icon><Calendar /></el-icon>
上传时间{{ formatDate(resource.createdAt) }}
</span>
<span class="meta-item">
<el-icon><Folder /></el-icon>
分类{{ resource.categoryName }}
</span>
<span class="meta-item">
<el-icon><Document /></el-icon>
文件类型{{ getFileTypeLabel(resource.fileType) }}
</span>
<span class="meta-item">
<el-icon><Download /></el-icon>
文件大小{{ formatFileSize(resource.fileSize) }}
</span>
</div>
</div>
<div class="resource-actions">
<el-button type="primary" size="large" @click="downloadResource" :loading="downloading">
<el-icon><Download /></el-icon>
下载资源
</el-button>
<el-button
:type="resource.isLiked ? 'danger' : 'default'"
size="large"
@click="toggleLike"
:loading="liking"
>
<el-icon><Star /></el-icon>
{{ resource.isLiked ? '已点赞' : '点赞' }}
({{ resource.likeCount }})
</el-button>
</div>
</div>
<!-- 资源描述 -->
<div class="resource-description">
<h3>资源描述</h3>
<p class="description-text">{{ resource.description }}</p>
</div>
<!-- 统计信息 -->
<div class="resource-stats">
<div class="stat-card">
<div class="stat-icon">
<el-icon><Download /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">{{ resource.downloadCount }}</div>
<div class="stat-label">下载次数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<el-icon><Star /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">{{ resource.likeCount }}</div>
<div class="stat-label">点赞数量</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<el-icon><View /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">{{ resource.downloadCount * 2 + resource.likeCount }}</div>
<div class="stat-label">浏览次数</div>
</div>
</div>
</div>
<!-- 上传者信息 -->
<div class="uploader-info-card">
<h3>上传者信息</h3>
<div class="uploader-profile">
<el-avatar :size="48" :src="resource.avatar">
{{ resource.nickname?.charAt(0) }}
</el-avatar>
<div class="uploader-details">
<div class="uploader-name">{{ resource.nickname }}</div>
<div class="uploader-id">用户ID: {{ resource.userId }}</div>
<div class="uploader-upload-time">上传于 {{ formatDate(resource.createdAt) }}</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!loading" class="empty-state">
<el-empty description="资源不存在或已被删除">
<el-button type="primary" @click="goBack">
返回资源列表
</el-button>
</el-empty>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft,
Document,
Download,
Star,
Setting,
User,
Calendar,
Folder,
View
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import {
getResourceDetail,
downloadResource as downloadResourceAPI,
likeResource,
type Resource as BaseResource
} from '@/api/resources'
import type { ApiResponse } from '@/types'
// ResourceUI
interface ExtendedResource extends BaseResource {
downloading?: boolean
liking?: boolean
}
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
//
const loading = ref(false)
const downloading = ref(false)
const liking = ref(false)
const resource = ref<ExtendedResource | null>(null)
//
const goBack = () => {
router.push('/resources')
}
const loadResourceDetail = async () => {
try {
loading.value = true
const resourceId = Number(route.params.id)
if (!resourceId) {
ElMessage.error('资源ID无效')
goBack()
return
}
const response = await getResourceDetail(resourceId) as any as ApiResponse<BaseResource>
if (response.code === 200) {
resource.value = {
...response.data,
downloading: false,
liking: false
}
} else {
ElMessage.error(response.message || '获取资源详情失败')
goBack()
}
} catch (error) {
console.error('获取资源详情失败:', error)
ElMessage.error('获取资源详情失败')
goBack()
} finally {
loading.value = false
}
}
const downloadResource = async () => {
if (!resource.value) return
try {
downloading.value = true
const response = await downloadResourceAPI(resource.value.id) as any as ApiResponse<{
fileUrl: string
fileName: string
fileType: string
}>
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.value.downloadCount++
ElMessage.success('下载开始')
} else {
ElMessage.error(response.message || '下载失败')
}
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败')
} finally {
downloading.value = false
}
}
const toggleLike = async () => {
if (!resource.value) return
try {
liking.value = true
const response = await likeResource(resource.value.id) as any as ApiResponse<null>
if (response.code === 200) {
resource.value.isLiked = !resource.value.isLiked
resource.value.likeCount += resource.value.isLiked ? 1 : -1
ElMessage.success(resource.value.isLiked ? '点赞成功' : '取消点赞')
} else {
ElMessage.error(response.message || '操作失败')
}
} catch (error) {
console.error('点赞操作失败:', error)
ElMessage.error('操作失败')
} finally {
liking.value = false
}
}
//
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')
}
}
//
onMounted(() => {
loadResourceDetail()
})
</script>
<style scoped>
.resource-detail-container {
min-height: 100vh;
background: var(--gradient-bg);
}
/* 导航栏样式 - 复用之前的样式 */
.navbar {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--gray-200);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-light);
}
.brand-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--gray-600);
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: var(--transition-base);
}
.nav-item:hover,
.nav-item.active {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: var(--gray-700);
}
/* 主要内容区域 */
.resource-detail-main {
padding: 32px 24px;
}
.resource-detail-content {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.back-section {
display: flex;
align-items: center;
}
.resource-detail-card {
padding: 32px;
display: flex;
flex-direction: column;
gap: 32px;
}
.resource-header {
display: flex;
gap: 24px;
align-items: flex-start;
padding-bottom: 24px;
border-bottom: 1px solid var(--gray-200);
}
.file-icon-large {
flex-shrink: 0;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background: var(--gray-50);
border-radius: 16px;
}
.resource-main-info {
flex: 1;
}
.resource-title {
font-size: 28px;
font-weight: 700;
color: var(--gray-800);
margin: 0 0 16px 0;
line-height: 1.3;
}
.resource-meta {
display: flex;
flex-direction: column;
gap: 8px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--gray-600);
}
.resource-actions {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-end;
}
.resource-description {
padding-bottom: 24px;
border-bottom: 1px solid var(--gray-200);
}
.resource-description h3 {
font-size: 18px;
font-weight: 600;
color: var(--gray-800);
margin: 0 0 16px 0;
}
.description-text {
font-size: 16px;
line-height: 1.6;
color: var(--gray-700);
margin: 0;
}
.resource-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
padding-bottom: 24px;
border-bottom: 1px solid var(--gray-200);
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--gray-50);
border-radius: 12px;
}
.stat-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-100);
color: var(--primary-600);
border-radius: 10px;
}
.stat-number {
font-size: 20px;
font-weight: 700;
color: var(--gray-800);
}
.stat-label {
font-size: 12px;
color: var(--gray-500);
}
.uploader-info-card h3 {
font-size: 18px;
font-weight: 600;
color: var(--gray-800);
margin: 0 0 16px 0;
}
.uploader-profile {
display: flex;
align-items: center;
gap: 16px;
}
.uploader-details {
flex: 1;
}
.uploader-name {
font-size: 16px;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 4px;
}
.uploader-id,
.uploader-upload-time {
font-size: 14px;
color: var(--gray-500);
}
.empty-state {
text-align: center;
padding: 60px 20px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.resource-detail-main {
padding: 16px;
}
.resource-header {
flex-direction: column;
gap: 16px;
}
.resource-actions {
flex-direction: row;
align-items: center;
width: 100%;
}
.resource-actions button {
flex: 1;
}
.resource-stats {
grid-template-columns: 1fr;
}
}
</style>

@ -62,6 +62,17 @@
<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">{{ totalResources }}</span>
</div>
<div
v-for="category in categories"
:key="category.id"
@ -73,7 +84,7 @@
<Folder />
</el-icon>
<span class="category-name">{{ category.name }}</span>
<span class="resource-count">{{ category.count }}</span>
<span class="resource-count">{{ category.count || 0 }}</span>
</div>
</div>
</div>
@ -108,19 +119,20 @@
placeholder="搜索资源标题或描述..."
size="large"
class="search-input"
@keyup.enter="searchResources()"
@keyup.enter="loadResources"
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #append>
<el-button @click="searchResources()"></el-button>
<el-button @click="loadResources"></el-button>
</template>
</el-input>
</div>
<div class="filter-options">
<el-select v-model="sortBy" placeholder="排序方式" size="default">
<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" />
@ -133,71 +145,93 @@
</div>
</div>
<!-- 资源列表 -->
<div class="resources-list">
<div
v-for="resource in mockResources"
:key="resource.id"
class="resource-card card-light animate-fade-in-up"
>
<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 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-meta">
<span class="file-size">{{ formatFileSize(resource.fileSize) }}</span>
<span class="file-type">{{ getFileTypeLabel(resource.fileType) }}</span>
<span class="upload-time">{{ resource.createdAt }}</span>
<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-actions">
<el-button type="primary" @click="downloadResource(resource)">
<el-icon><Download /></el-icon>
下载
</el-button>
<el-button text @click="toggleLike(resource)">
<el-icon><Star /></el-icon>
{{ resource.likeCount }}
</el-button>
<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 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 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="mockResources.length === 0" class="empty-state">
<el-empty description="暂无资源">
<el-button type="primary" @click="showUploadDialog = true">
上传第一个资源
</el-button>
</el-empty>
<!-- 分页 -->
<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>
@ -211,35 +245,58 @@
width="600px"
class="upload-dialog"
>
<el-form label-position="top">
<el-form-item label="资源标题">
<el-input placeholder="请输入资源标题" size="large" />
<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="资源分类">
<el-select placeholder="请选择分类" size="large" style="width: 100%">
<el-option label="学习资料" value="1" />
<el-option label="课件PPT" value="2" />
<el-option label="实验报告" value="3" />
<el-option label="其他资源" value="4" />
<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="资源描述">
<el-input type="textarea" :rows="3" placeholder="请输入资源描述..." />
<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="上传文件">
<el-form-item label="上传文件" prop="file">
<el-upload
ref="uploadRef"
class="upload-area"
drag
action="#"
: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>
将文件拖到此处<em>点击选择文件</em>
</div>
<template #tip>
<div class="el-upload__tip">
@ -252,16 +309,23 @@
<template #footer>
<el-button @click="showUploadDialog = false">取消</el-button>
<el-button type="primary" @click="handleUploadResource"></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 } from 'vue'
import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules, UploadInstance, UploadProps } from 'element-plus'
import {
Upload,
Search,
@ -274,25 +338,69 @@ import {
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'
// 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 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 categories = ref([
{ id: 1, name: '学习资料', count: 156 },
{ id: 2, name: '课件PPT', count: 89 },
{ id: 3, name: '实验报告', count: 67 },
{ id: 4, name: '考试资料', count: 134 },
{ id: 5, name: '其他资源', count: 45 }
])
//
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([
@ -303,81 +411,229 @@ const fileTypes = ref([
{ name: 'zip', label: '压缩包' }
])
//
const mockResources = ref([
{
id: 1,
title: '高等数学期末复习资料',
description: '包含重点知识点总结、历年真题和详细解答,适合期末复习使用',
fileType: 'application/pdf',
fileSize: 2048000,
nickname: '数学小王子',
avatar: '',
downloadCount: 234,
likeCount: 89,
isLiked: false,
createdAt: '2024-01-15 14:30'
},
{
id: 2,
title: '数据结构课件完整版',
description: '完整的数据结构课程PPT包含所有章节内容和代码示例',
fileType: 'application/vnd.ms-powerpoint',
fileSize: 15360000,
nickname: '编程达人',
avatar: '',
downloadCount: 156,
likeCount: 67,
isLiked: false,
createdAt: '2024-01-14 10:20'
},
{
id: 3,
title: '计算机网络实验报告模板',
description: '标准的实验报告格式,包含实验目的、步骤、结果分析等部分',
fileType: 'application/msword',
fileSize: 512000,
nickname: '实验小助手',
avatar: '',
downloadCount: 98,
likeCount: 34,
isLiked: false,
createdAt: '2024-01-13 16:45'
}
])
//
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
}
const searchResources = () => {
ElMessage.info('搜索功能开发中...')
currentPage.value = 1
loadResources()
}
const refreshResources = () => {
currentPage.value = 1
loadResources()
ElMessage.success('刷新成功')
}
const downloadResource = (resource: any) => {
ElMessage.success(`开始下载:${resource.title}`)
//
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
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)
} else {
console.error('分类列表API返回错误:', response)
ElMessage.error(response.message || '获取分类列表失败')
}
} catch (error) {
console.error('获取分类列表失败:', error)
ElMessage.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 toggleLike = (resource: any) => {
resource.isLiked = !resource.isLiked
resource.likeCount += resource.isLiked ? 1 : -1
ElMessage.success(resource.isLiked ? '点赞成功' : '取消点赞')
//
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 handleUploadResource = () => {
showUploadDialog.value = false
ElMessage.success('资源上传成功!')
//
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
@ -386,28 +642,45 @@ const formatFileSize = (bytes: number) => {
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/zip': 'ZIP'
'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/zip': '#a4b0be'
'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()
@ -417,8 +690,18 @@ const handleCommand = (command: string) => {
}
}
onMounted(() => {
//
watch(showUploadDialog, (newVal) => {
if (!newVal) {
resetUploadForm()
}
})
//
onMounted(async () => {
console.log('资源页面加载完成')
await loadCategories()
await loadResources()
})
</script>
@ -651,6 +934,7 @@ onMounted(() => {
.resource-card {
padding: 24px;
transition: var(--transition-base);
cursor: pointer;
}
.resource-card:hover {
@ -745,6 +1029,20 @@ onMounted(() => {
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;

@ -65,8 +65,9 @@
<!-- 学期选择 -->
<div class="semester-selector">
<el-select v-model="currentSemester" placeholder="选择学期" size="default">
<el-select v-model="currentSemester" placeholder="选择学期" size="default" @change="loadCourses">
<el-option label="2024春季学期" value="2024-1" />
<el-option label="2024秋季学期" value="2024-2" />
<el-option label="2023秋季学期" value="2023-2" />
<el-option label="2023春季学期" value="2023-1" />
</el-select>
@ -84,7 +85,7 @@
</div>
<!-- 课程表 -->
<div class="schedule-table card-light">
<div v-loading="loading" class="schedule-table card-light">
<div class="table-header">
<div class="time-column">时间</div>
<div
@ -105,12 +106,12 @@
class="time-row"
>
<div class="time-cell">
<div class="time-period">{{ timeSlot.period }}</div>
<div class="time-range">{{ timeSlot.time }}</div>
<div class="time-period">{{ timeIndex + 1 }}</div>
<div class="time-range">{{ timeSlot }}</div>
</div>
<div
v-for="(day, dayIndex) in 7"
v-for="dayIndex in 7"
:key="dayIndex"
class="course-cell"
@click="addCourseToSlot(timeIndex, dayIndex)"
@ -119,7 +120,7 @@
v-if="getCourseForSlot(timeIndex, dayIndex)"
class="course-item"
:style="{ backgroundColor: getCourseForSlot(timeIndex, dayIndex)?.color }"
@click.stop="editCourse(getCourseForSlot(timeIndex, dayIndex))"
@click.stop="editCourse(getCourseForSlot(timeIndex, dayIndex)!)"
>
<div class="course-name">{{ getCourseForSlot(timeIndex, dayIndex)?.name }}</div>
<div class="course-location">{{ getCourseForSlot(timeIndex, dayIndex)?.location }}</div>
@ -149,6 +150,10 @@
<div class="course-name">{{ course.name }}</div>
<div class="course-details">{{ course.location }} · {{ course.teacher }}</div>
</div>
<div class="course-actions">
<el-button size="small" @click="editCourse(course)"></el-button>
<el-button size="small" type="danger" @click="deleteCourseConfirm(course)"></el-button>
</div>
</div>
</div>
<div v-else class="empty-message">
@ -164,11 +169,15 @@
:key="schedule.id"
class="schedule-card"
>
<div class="schedule-time">{{ schedule.startTime }}</div>
<div class="schedule-time">{{ formatTime(schedule.startTime) }}</div>
<div class="schedule-info">
<div class="schedule-title">{{ schedule.title }}</div>
<div class="schedule-location">{{ schedule.location }}</div>
</div>
<div class="schedule-actions">
<el-button size="small" @click="editSchedule(schedule)"></el-button>
<el-button size="small" type="danger" @click="deleteScheduleConfirm(schedule)"></el-button>
</div>
</div>
</div>
<div v-else class="empty-message">
@ -182,229 +191,358 @@
<!-- 添加课程对话框 -->
<el-dialog
v-model="showAddCourse"
title="添加课程"
:title="editingCourse ? '编辑课程' : '添加课程'"
width="500px"
>
<el-form label-position="top">
<el-form-item label="课程名称">
<el-input placeholder="请输入课程名称" size="large" />
<el-form :model="courseForm" :rules="courseRules" ref="courseFormRef" label-position="top">
<el-form-item label="课程名称" prop="name">
<el-input v-model="courseForm.name" placeholder="请输入课程名称" size="large" />
</el-form-item>
<el-form-item label="任课教师">
<el-input placeholder="请输入教师姓名" size="large" />
<el-form-item label="任课教师" prop="teacher">
<el-input v-model="courseForm.teacher" placeholder="请输入教师姓名" size="large" />
</el-form-item>
<el-form-item label="上课地点">
<el-input placeholder="请输入上课地点" size="large" />
<el-form-item label="上课地点" prop="location">
<el-input v-model="courseForm.location" placeholder="请输入上课地点" size="large" />
</el-form-item>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
<el-form-item label="星期">
<el-select placeholder="选择星期" size="large">
<el-option label="星期一" value="1" />
<el-option label="星期二" value="2" />
<el-option label="星期三" value="3" />
<el-option label="星期四" value="4" />
<el-option label="星期五" value="5" />
<el-option label="星期六" value="6" />
<el-option label="星期日" value="0" />
<div class="form-row">
<el-form-item label="星期" prop="dayOfWeek">
<el-select v-model="courseForm.dayOfWeek" placeholder="选择星期" style="width: 100%">
<el-option v-for="(day, index) in weekDays" :key="index" :label="day" :value="index + 1" />
</el-select>
</el-form-item>
<el-form-item label="开始时间">
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="courseForm.color" />
</el-form-item>
</div>
<div class="form-row">
<el-form-item label="开始时间" prop="startTime">
<el-time-picker
placeholder="选择时间"
v-model="courseForm.startTime"
format="HH:mm"
value-format="HH:mm"
size="large"
placeholder="选择开始时间"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束时间">
<el-form-item label="结束时间" prop="endTime">
<el-time-picker
placeholder="选择时间"
v-model="courseForm.endTime"
format="HH:mm"
value-format="HH:mm"
size="large"
placeholder="选择结束时间"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="form-row">
<el-form-item label="开始周" prop="startWeek">
<el-input-number
v-model="courseForm.startWeek"
:min="1"
:max="20"
placeholder="开始周"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束周" prop="endWeek">
<el-input-number
v-model="courseForm.endWeek"
:min="1"
:max="20"
placeholder="结束周"
style="width: 100%"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="showAddCourse = false">取消</el-button>
<el-button type="primary" @click="handleAddCourse"></el-button>
<el-button @click="cancelEditCourse"></el-button>
<el-button
type="primary"
@click="handleSaveCourse"
:loading="savingCourse"
>
{{ editingCourse ? '更新' : '保存' }}
</el-button>
</template>
</el-dialog>
<!-- 添加日程对话框 -->
<el-dialog
v-model="showAddSchedule"
title="添加日程"
:title="editingSchedule ? '编辑日程' : '添加日程'"
width="500px"
>
<el-form label-position="top">
<el-form-item label="日程标题">
<el-input placeholder="请输入日程标题" size="large" />
<el-form :model="scheduleForm" :rules="scheduleRules" ref="scheduleFormRef" label-position="top">
<el-form-item label="日程标题" prop="title">
<el-input v-model="scheduleForm.title" placeholder="请输入日程标题" size="large" />
</el-form-item>
<el-form-item label="日期时间">
<el-date-picker
type="datetime"
placeholder="选择日期时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm"
size="large"
style="width: 100%"
<el-form-item label="日程描述" prop="description">
<el-input
v-model="scheduleForm.description"
type="textarea"
:rows="3"
placeholder="请输入日程描述..."
/>
</el-form-item>
<el-form-item label="地点">
<el-input placeholder="请输入地点(可选)" size="large" />
<el-form-item label="地点" prop="location">
<el-input v-model="scheduleForm.location" placeholder="请输入地点" size="large" />
</el-form-item>
<el-form-item label="备注">
<el-input type="textarea" :rows="3" placeholder="请输入备注信息..." />
<el-form-item label="是否全天">
<el-switch v-model="scheduleForm.isAllDay" @change="handleAllDayChange" />
</el-form-item>
<div v-if="!scheduleForm.isAllDay" class="form-row">
<el-form-item label="开始时间" prop="startTime">
<el-date-picker
v-model="scheduleForm.startTime"
type="datetime"
placeholder="选择开始时间"
format="YYYY-MM-DD HH:mm"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker
v-model="scheduleForm.endTime"
type="datetime"
placeholder="选择结束时间"
format="YYYY-MM-DD HH:mm"
style="width: 100%"
/>
</el-form-item>
</div>
<div v-else class="form-row">
<el-form-item label="日期" prop="date">
<el-date-picker
v-model="scheduleForm.date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="form-row">
<el-form-item label="提醒时间" prop="reminder">
<el-select v-model="scheduleForm.reminder" placeholder="选择提醒时间" style="width: 100%">
<el-option label="不提醒" :value="0" />
<el-option label="5分钟前" :value="5" />
<el-option label="15分钟前" :value="15" />
<el-option label="30分钟前" :value="30" />
<el-option label="1小时前" :value="60" />
<el-option label="1天前" :value="1440" />
</el-select>
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="scheduleForm.color" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="showAddSchedule = false">取消</el-button>
<el-button type="primary" @click="handleAddSchedule"></el-button>
<el-button @click="cancelEditSchedule"></el-button>
<el-button
type="primary"
@click="handleSaveSchedule"
:loading="savingSchedule"
>
{{ editingSchedule ? '更新' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import {
Plus,
Calendar,
Plus,
Calendar,
Setting,
ArrowLeft,
ArrowRight
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import {
getCourses,
getCoursesBySemester,
getCoursesByDay,
createCourse,
updateCourse,
deleteCourse,
type Course
} from '@/api/schedule'
import {
getSchedules,
getSchedulesByRange,
createSchedule,
updateSchedule,
deleteSchedule,
type Schedule
} from '@/api/schedule'
import type { ApiResponse } from '@/types'
const router = useRouter()
const userStore = useUserStore()
//
const loading = ref(false)
const savingCourse = ref(false)
const savingSchedule = ref(false)
const showAddCourse = ref(false)
const showAddSchedule = ref(false)
const currentSemester = ref('2024-1')
const currentWeek = ref(1)
//
const editingCourse = ref<Course | null>(null)
const editingSchedule = ref<Schedule | null>(null)
//
const courses = ref<Course[]>([])
const schedules = ref<Schedule[]>([])
//
const courseFormRef = ref<FormInstance>()
const scheduleFormRef = ref<FormInstance>()
//
const courseForm = reactive({
name: '',
teacher: '',
location: '',
dayOfWeek: null as number | null,
startTime: '',
endTime: '',
startWeek: 1,
endWeek: 16,
color: '#409EFF'
})
//
const scheduleForm = reactive({
title: '',
description: '',
location: '',
startTime: '',
endTime: '',
date: '',
isAllDay: false,
reminder: 30,
color: '#67C23A'
})
//
const courseRules: FormRules = {
name: [
{ required: true, message: '请输入课程名称', trigger: 'blur' },
{ min: 2, max: 50, message: '课程名称长度在 2 到 50 个字符', trigger: 'blur' }
],
teacher: [
{ required: true, message: '请输入教师姓名', trigger: 'blur' }
],
location: [
{ required: true, message: '请输入上课地点', trigger: 'blur' }
],
dayOfWeek: [
{ required: true, message: '请选择星期', trigger: 'change' }
],
startTime: [
{ required: true, message: '请选择开始时间', trigger: 'change' }
],
endTime: [
{ required: true, message: '请选择结束时间', trigger: 'change' }
]
}
const scheduleRules: FormRules = {
title: [
{ required: true, message: '请输入日程标题', trigger: 'blur' },
{ min: 2, max: 100, message: '标题长度在 2 到 100 个字符', trigger: 'blur' }
],
description: [
{ required: true, message: '请输入日程描述', trigger: 'blur' }
],
location: [
{ required: true, message: '请输入地点', trigger: 'blur' }
]
}
//
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const timeSlots = [
'08:00-09:40',
'10:00-11:40',
'14:00-15:40',
'16:00-17:40',
'19:00-20:40'
]
//
const todayCourses = computed(() => {
const today = new Date().getDay()
const dayOfWeek = today === 0 ? 7 : today // 7
return courses.value.filter(course => course.dayOfWeek === dayOfWeek)
})
const timeSlots = ref([
{ period: '第1节', time: '08:00-08:45' },
{ period: '第2节', time: '08:55-09:40' },
{ period: '第3节', time: '10:00-10:45' },
{ period: '第4节', time: '10:55-11:40' },
{ period: '第5节', time: '14:00-14:45' },
{ period: '第6节', time: '14:55-15:40' },
{ period: '第7节', time: '16:00-16:45' },
{ period: '第8节', time: '16:55-17:40' },
{ period: '第9节', time: '19:00-19:45' },
{ period: '第10节', time: '19:55-20:40' }
])
//
const courses = ref([
{
id: 1,
name: '高等数学',
teacher: '张教授',
location: 'A101',
dayOfWeek: 1,
startTime: '08:00',
endTime: '09:40',
timeSlots: [0, 1],
color: '#e3f2fd'
},
{
id: 2,
name: '数据结构',
teacher: '李教授',
location: 'B203',
dayOfWeek: 2,
startTime: '10:00',
endTime: '11:40',
timeSlots: [2, 3],
color: '#f3e5f5'
},
{
id: 3,
name: '计算机网络',
teacher: '王教授',
location: 'C305',
dayOfWeek: 3,
startTime: '14:00',
endTime: '15:40',
timeSlots: [4, 5],
color: '#e8f5e8'
}
])
//
const todayCourses = ref([
{
id: 1,
name: '高等数学',
location: 'A101',
teacher: '张教授',
startTime: '08:00',
endTime: '09:40'
},
{
id: 2,
name: '英语听力',
location: 'B205',
teacher: 'Smith',
startTime: '14:00',
endTime: '15:40'
}
])
//
const todaySchedules = ref([
{
id: 1,
title: '小组讨论',
location: '图书馆201',
startTime: '16:00'
},
{
id: 2,
title: '社团活动',
location: '学生活动中心',
startTime: '19:00'
}
])
const todaySchedules = computed(() => {
const today = new Date()
const todayStr = today.toISOString().split('T')[0]
return schedules.value.filter(schedule => {
const scheduleDate = new Date(schedule.startTime).toISOString().split('T')[0]
return scheduleDate === todayStr
})
})
//
const isToday = (dayIndex: number) => {
const today = new Date().getDay()
return dayIndex === (today === 0 ? 6 : today - 1)
const targetDay = dayIndex + 1
return today === 0 ? targetDay === 7 : today === targetDay
}
const getDayDate = (dayIndex: number) => {
const today = new Date()
const currentDay = today.getDay() === 0 ? 6 : today.getDay() - 1
const targetDate = new Date(today)
targetDate.setDate(today.getDate() + (dayIndex - currentDay))
const currentDayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (currentDayOfWeek === 0 ? 6 : currentDayOfWeek - 1))
const targetDate = new Date(monday)
targetDate.setDate(monday.getDate() + dayIndex)
return targetDate.getDate()
}
const getCourseForSlot = (timeIndex: number, dayIndex: number) => {
return courses.value.find(course =>
course.dayOfWeek === dayIndex + 1 &&
course.timeSlots.includes(timeIndex)
)
return courses.value.find(course => {
if (course.dayOfWeek !== dayIndex + 1) return false
//
const courseStart = course.startTime
const courseEnd = course.endTime
const slotTime = timeSlots[timeIndex]
//
return slotTime.includes(courseStart.substring(0, 5))
})
}
const previousWeek = () => {
@ -420,21 +558,294 @@ const nextWeek = () => {
}
const addCourseToSlot = (timeIndex: number, dayIndex: number) => {
//
courseForm.dayOfWeek = dayIndex + 1
const timeSlot = timeSlots[timeIndex].split('-')
courseForm.startTime = timeSlot[0]
courseForm.endTime = timeSlot[1]
showAddCourse.value = true
}
//
const loadCourses = async () => {
try {
loading.value = true
const response = await getCoursesBySemester(currentSemester.value) as any as ApiResponse<{
total: number
list: Course[]
pages: number
}>
console.log('课程列表API响应:', response)
if (response.code === 200) {
courses.value = response.data.list
console.log('课程列表加载成功:', courses.value)
} else {
console.error('课程列表API返回错误:', response)
ElMessage.error(response.message || '获取课程列表失败')
}
} catch (error) {
console.error('获取课程列表失败:', error)
ElMessage.error('获取课程列表失败')
} finally {
loading.value = false
}
}
const loadSchedules = async () => {
try {
const response = await getSchedules() as any as ApiResponse<{
total: number
list: Schedule[]
pages: number
}>
console.log('日程列表API响应:', response)
if (response.code === 200) {
schedules.value = response.data.list
console.log('日程列表加载成功:', schedules.value)
} else {
console.error('日程列表API返回错误:', response)
ElMessage.error(response.message || '获取日程列表失败')
}
} catch (error) {
console.error('获取日程列表失败:', error)
ElMessage.error('获取日程列表失败')
}
}
const handleSaveCourse = async () => {
if (!courseFormRef.value) return
try {
await courseFormRef.value.validate()
savingCourse.value = true
const courseData = {
name: courseForm.name,
teacher: courseForm.teacher,
location: courseForm.location,
dayOfWeek: courseForm.dayOfWeek!,
startTime: courseForm.startTime,
endTime: courseForm.endTime,
startWeek: courseForm.startWeek,
endWeek: courseForm.endWeek,
semester: currentSemester.value,
color: courseForm.color
}
let response
if (editingCourse.value) {
response = await updateCourse(editingCourse.value.id, courseData) as any as ApiResponse<null>
} else {
response = await createCourse(courseData) as any as ApiResponse<{ courseId: number }>
}
if (response.code === 200) {
ElMessage.success(editingCourse.value ? '课程更新成功!' : '课程添加成功!')
showAddCourse.value = false
resetCourseForm()
loadCourses() //
} else {
ElMessage.error(response.message || '保存失败')
}
} catch (error) {
console.error('保存课程失败:', error)
ElMessage.error('保存失败')
} finally {
savingCourse.value = false
}
}
const handleSaveSchedule = async () => {
if (!scheduleFormRef.value) return
try {
await scheduleFormRef.value.validate()
savingSchedule.value = true
let startTime, endTime
if (scheduleForm.isAllDay) {
startTime = `${scheduleForm.date}T00:00:00`
endTime = `${scheduleForm.date}T23:59:59`
} else {
startTime = scheduleForm.startTime
endTime = scheduleForm.endTime
}
const scheduleData = {
title: scheduleForm.title,
description: scheduleForm.description,
location: scheduleForm.location,
startTime,
endTime,
isAllDay: scheduleForm.isAllDay ? 1 : 0,
reminder: scheduleForm.reminder,
color: scheduleForm.color
}
let response
if (editingSchedule.value) {
response = await updateSchedule(editingSchedule.value.id, scheduleData) as any as ApiResponse<null>
} else {
response = await createSchedule(scheduleData) as any as ApiResponse<{ scheduleId: number }>
}
if (response.code === 200) {
ElMessage.success(editingSchedule.value ? '日程更新成功!' : '日程添加成功!')
showAddSchedule.value = false
resetScheduleForm()
loadSchedules() //
} else {
ElMessage.error(response.message || '保存失败')
}
} catch (error) {
console.error('保存日程失败:', error)
ElMessage.error('保存失败')
} finally {
savingSchedule.value = false
}
}
const editCourse = (course: Course) => {
editingCourse.value = course
courseForm.name = course.name
courseForm.teacher = course.teacher
courseForm.location = course.location
courseForm.dayOfWeek = course.dayOfWeek
courseForm.startTime = course.startTime
courseForm.endTime = course.endTime
courseForm.startWeek = course.startWeek
courseForm.endWeek = course.endWeek
courseForm.color = course.color
showAddCourse.value = true
}
const editCourse = (course: any) => {
ElMessage.info('编辑课程功能开发中...')
const editSchedule = (schedule: Schedule) => {
editingSchedule.value = schedule
scheduleForm.title = schedule.title
scheduleForm.description = schedule.description
scheduleForm.location = schedule.location
scheduleForm.isAllDay = schedule.isAllDay === 1
scheduleForm.reminder = schedule.reminder
scheduleForm.color = schedule.color
if (schedule.isAllDay === 1) {
scheduleForm.date = new Date(schedule.startTime).toISOString().split('T')[0]
} else {
scheduleForm.startTime = schedule.startTime
scheduleForm.endTime = schedule.endTime
}
showAddSchedule.value = true
}
const deleteCourseConfirm = async (course: Course) => {
try {
await ElMessageBox.confirm(
`确定要删除课程"${course.name}"吗?`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
}
)
const response = await deleteCourse(course.id) as any as ApiResponse<null>
if (response.code === 200) {
ElMessage.success('课程删除成功')
loadCourses()
} else {
ElMessage.error(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除课程失败:', error)
ElMessage.error('删除失败')
}
}
}
const deleteScheduleConfirm = async (schedule: Schedule) => {
try {
await ElMessageBox.confirm(
`确定要删除日程"${schedule.title}"吗?`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
}
)
const response = await deleteSchedule(schedule.id) as any as ApiResponse<null>
if (response.code === 200) {
ElMessage.success('日程删除成功')
loadSchedules()
} else {
ElMessage.error(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除日程失败:', error)
ElMessage.error('删除失败')
}
}
}
const handleAddCourse = () => {
const cancelEditCourse = () => {
showAddCourse.value = false
ElMessage.success('课程添加成功!')
resetCourseForm()
}
const handleAddSchedule = () => {
const cancelEditSchedule = () => {
showAddSchedule.value = false
ElMessage.success('日程添加成功!')
resetScheduleForm()
}
const resetCourseForm = () => {
editingCourse.value = null
courseForm.name = ''
courseForm.teacher = ''
courseForm.location = ''
courseForm.dayOfWeek = null
courseForm.startTime = ''
courseForm.endTime = ''
courseForm.startWeek = 1
courseForm.endWeek = 16
courseForm.color = '#409EFF'
courseFormRef.value?.clearValidate()
}
const resetScheduleForm = () => {
editingSchedule.value = null
scheduleForm.title = ''
scheduleForm.description = ''
scheduleForm.location = ''
scheduleForm.startTime = ''
scheduleForm.endTime = ''
scheduleForm.date = ''
scheduleForm.isAllDay = false
scheduleForm.reminder = 30
scheduleForm.color = '#67C23A'
scheduleFormRef.value?.clearValidate()
}
const handleAllDayChange = (value: boolean) => {
if (value) {
scheduleForm.startTime = ''
scheduleForm.endTime = ''
} else {
scheduleForm.date = ''
}
}
const formatTime = (dateTimeString: string) => {
const date = new Date(dateTimeString)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
const handleCommand = (command: string) => {
@ -446,8 +857,30 @@ const handleCommand = (command: string) => {
}
}
onMounted(() => {
//
watch(showAddCourse, (newVal) => {
if (!newVal) {
resetCourseForm()
}
})
watch(showAddSchedule, (newVal) => {
if (!newVal) {
resetScheduleForm()
}
})
//
onMounted(async () => {
console.log('课程表页面加载完成')
await loadCourses()
await loadSchedules()
//
const now = new Date()
const startOfYear = new Date(now.getFullYear(), 0, 1)
const weekNumber = Math.ceil((now.getTime() - startOfYear.getTime()) / (7 * 24 * 60 * 60 * 1000))
currentWeek.value = Math.min(weekNumber, 20)
})
</script>
@ -853,4 +1286,11 @@ onMounted(() => {
font-size: 9px;
}
}
/* 表单行样式 */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
</style>

@ -53,11 +53,13 @@ public class ResourceController {
@GetMapping
public Result<?> getResourceList(
@RequestParam(value = "category", required = false) Long categoryId,
@RequestParam(value = "user", required = false) Long userId,
@RequestParam(value = "user", required = false) Long uploaderUserId,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size) {
return resourceService.getResourceList(categoryId, userId, keyword, page, size);
// 从当前上下文获取用户ID可能为null未登录用户
Long currentUserId = BaseContext.getId();
return resourceService.getResourceList(categoryId, uploaderUserId, keyword, page, size, currentUserId);
}
@Operation(summary = "更新资源")

@ -0,0 +1,39 @@
package com.unilife.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 访
*/
@Mapper
public interface ResourceLikeMapper {
/**
*
* @param resourceId ID
* @param userId ID
* @return
*/
boolean isLiked(@Param("resourceId") Long resourceId, @Param("userId") Long userId);
/**
*
* @param resourceId ID
* @param userId ID
*/
void insert(@Param("resourceId") Long resourceId, @Param("userId") Long userId);
/**
*
* @param resourceId ID
* @param userId ID
*/
void delete(@Param("resourceId") Long resourceId, @Param("userId") Long userId);
/**
*
* @param resourceId ID
* @return
*/
int getLikeCount(@Param("resourceId") Long resourceId);
}

@ -28,13 +28,14 @@ public interface ResourceService {
/**
*
* @param categoryId IDnull
* @param userId IDnull
* @param uploaderUserId IDnull
* @param keyword null
* @param page
* @param size
* @param currentUserId IDnull
* @return
*/
Result getResourceList(Long categoryId, Long userId, String keyword, Integer page, Integer size);
Result getResourceList(Long categoryId, Long uploaderUserId, String keyword, Integer page, Integer size, Long currentUserId);
/**
*

@ -5,6 +5,7 @@ import com.github.pagehelper.PageInfo;
import com.unilife.common.result.Result;
import com.unilife.mapper.CategoryMapper;
import com.unilife.mapper.ResourceMapper;
import com.unilife.mapper.ResourceLikeMapper;
import com.unilife.mapper.UserMapper;
import com.unilife.model.dto.CreateResourceDTO;
import com.unilife.model.entity.Category;
@ -47,6 +48,9 @@ public class ResourceServiceImpl implements ResourceService {
@Autowired
private OssService ossService;
@Autowired
private ResourceLikeMapper resourceLikeMapper;
// 文件存储路径实际项目中应该配置在application.yml中
private static final String UPLOAD_DIR = "uploads/resources/";
// OSS存储目录
@ -131,7 +135,7 @@ public class ResourceServiceImpl implements ResourceService {
.categoryName(category != null ? category.getName() : "未知分类")
.downloadCount(resource.getDownloadCount())
.likeCount(resource.getLikeCount())
.isLiked(false) // 实际项目中应该查询用户是否点赞
.isLiked(userId != null ? resourceLikeMapper.isLiked(resourceId, userId) : false) // 查询用户是否点赞
.createdAt(resource.getCreatedAt())
.updatedAt(resource.getUpdatedAt())
.build();
@ -140,14 +144,14 @@ public class ResourceServiceImpl implements ResourceService {
}
@Override
public Result getResourceList(Long categoryId, Long userId, String keyword, Integer page, Integer size) {
public Result getResourceList(Long categoryId, Long uploaderUserId, String keyword, Integer page, Integer size, Long currentUserId) {
// 参数校验
if (page == null || page < 1) page = 1;
if (size == null || size < 1 || size > 50) size = 10;
// 分页查询
PageHelper.startPage(page, size);
List<Resource> resources = resourceMapper.getList(categoryId, userId, keyword);
List<Resource> resources = resourceMapper.getList(categoryId, uploaderUserId, keyword);
PageInfo<Resource> pageInfo = new PageInfo<>(resources);
// 转换为VO
@ -170,7 +174,7 @@ public class ResourceServiceImpl implements ResourceService {
.categoryName(category != null ? category.getName() : "未知分类")
.downloadCount(resource.getDownloadCount())
.likeCount(resource.getLikeCount())
.isLiked(false) // 实际项目中应该查询用户是否点赞
.isLiked(currentUserId != null ? resourceLikeMapper.isLiked(resource.getId(), currentUserId) : false) // 查询当前用户是否点赞
.createdAt(resource.getCreatedAt())
.updatedAt(resource.getUpdatedAt())
.build();
@ -298,17 +302,16 @@ public class ResourceServiceImpl implements ResourceService {
}
// 检查用户是否已点赞
// 注意这里需要创建一个资源点赞表和相应的Mapper实际开发中需要先创建
boolean isLiked = false; // resourceLikeMapper.isLiked(resourceId, userId);
boolean isLiked = resourceLikeMapper.isLiked(resourceId, userId);
if (isLiked) {
// 取消点赞
// resourceLikeMapper.delete(resourceId, userId);
resourceLikeMapper.delete(resourceId, userId);
resourceMapper.decrementLikeCount(resourceId);
return Result.success(null, "取消点赞成功");
} else {
// 添加点赞
// resourceLikeMapper.insert(resourceId, userId);
resourceLikeMapper.insert(resourceId, userId);
resourceMapper.incrementLikeCount(resourceId);
return Result.success(null, "点赞成功");
}

@ -112,6 +112,17 @@ CREATE TABLE IF NOT EXISTS `comment_likes` (
FOREIGN KEY (`comment_id`) REFERENCES `comments` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论点赞表';
-- 点赞表(用户-资源)
CREATE TABLE IF NOT EXISTS `resource_likes` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '点赞ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`resource_id` BIGINT NOT NULL COMMENT '资源ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY `uk_user_resource` (`user_id`, `resource_id`),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`resource_id`) REFERENCES `resources` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源点赞表';
-- 资源表
CREATE TABLE IF NOT EXISTS `resources` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源ID',
@ -188,6 +199,3 @@ INSERT INTO `categories` (`name`, `description`, `icon`, `sort`, `status`) VALUE
INSERT INTO `users` (`username`, `email`, `password`, `nickname`, `role`, `status`, `is_verified`) VALUES
('admin', 'admin@unilife.com', '123456', '系统管理员', 2, 1, 1);
-- 数据库迁移为现有courses表添加semester字段
ALTER TABLE `courses` ADD COLUMN `semester` VARCHAR(20) DEFAULT NULL COMMENT '学期2023-1' AFTER `end_week`;
ALTER TABLE `courses` ADD INDEX `idx_semester` (`semester`);

@ -0,0 +1,36 @@
-- 测试数据插入脚本
-- 使用数据库
USE UniLife;
-- 插入测试用户数据
INSERT INTO `users` (`username`, `email`, `password`, `nickname`, `role`, `status`, `is_verified`, `student_id`, `department`, `major`, `grade`) VALUES
('testuser1', 'test1@student.edu.cn', '123456', '张小明', 0, 1, 1, '2021001001', '计算机科学与技术学院', '计算机科学与技术', '2021'),
('testuser2', 'test2@student.edu.cn', '123456', '李小红', 0, 1, 1, '2021001002', '数学与统计学院', '数学与应用数学', '2021'),
('testuser3', 'test3@student.edu.cn', '123456', '王小刚', 0, 1, 1, '2021001003', '物理学院', '物理学', '2021');
-- 插入测试资源数据
INSERT INTO `resources` (`user_id`, `title`, `description`, `file_url`, `file_size`, `file_type`, `category_id`, `download_count`, `like_count`, `status`) VALUES
(2, '数据结构课程设计报告', '包含完整的数据结构课程设计实验报告,涵盖栈、队列、树、图等数据结构的实现和应用。', 'https://example.com/files/data-structure-report.pdf', 2048576, 'application/pdf', 1, 15, 8, 1),
(2, '算法导论学习笔记', '详细的算法导论学习笔记,包含排序算法、图算法、动态规划等重要算法的分析和实现。', 'https://example.com/files/algorithm-notes.docx', 1572864, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 1, 25, 12, 1),
(3, '高等数学期末复习资料', '高等数学期末考试复习资料合集,包含重要公式、定理证明和典型习题解答。', 'https://example.com/files/calculus-review.pdf', 3145728, 'application/pdf', 1, 32, 18, 1),
(3, '线性代数PPT课件', '线性代数完整PPT课件包含矩阵运算、向量空间、特征值等核心内容。', 'https://example.com/files/linear-algebra.pptx', 5242880, 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 1, 20, 15, 1),
(4, '校园生活指南', '新生校园生活指南,包含宿舍管理、食堂介绍、图书馆使用等实用信息。', 'https://example.com/files/campus-guide.pdf', 1048576, 'application/pdf', 2, 45, 28, 1),
(2, '计算机网络实验代码', '计算机网络课程实验代码合集包含Socket编程、HTTP协议实现等。', 'https://example.com/files/network-lab-code.zip', 4194304, 'application/zip', 5, 18, 10, 1);
-- 插入测试课程数据
INSERT INTO `courses` (`user_id`, `name`, `teacher`, `location`, `day_of_week`, `start_time`, `end_time`, `start_week`, `end_week`, `semester`, `color`, `status`) VALUES
(2, '数据结构', '张教授', '教学楼A201', 1, '08:00:00', '09:40:00', 1, 16, '2024-1', '#409EFF', 1),
(2, '算法设计与分析', '李老师', '教学楼B301', 3, '10:00:00', '11:40:00', 1, 16, '2024-1', '#67C23A', 1),
(2, '计算机网络', '王教授', '实验楼C102', 5, '14:00:00', '15:40:00', 1, 16, '2024-1', '#E6A23C', 1),
(3, '高等数学', '赵老师', '教学楼A101', 2, '08:00:00', '09:40:00', 1, 16, '2024-1', '#F56C6C', 1),
(3, '线性代数', '钱教授', '教学楼A203', 4, '10:00:00', '11:40:00', 1, 16, '2024-1', '#909399', 1),
(4, '大学物理', '孙老师', '物理楼101', 1, '14:00:00', '15:40:00', 1, 16, '2024-1', '#9B59B6', 1),
(4, '物理实验', '周教授', '物理实验室', 3, '16:00:00', '17:40:00', 1, 16, '2024-1', '#3498DB', 1);
-- 插入测试日程数据
INSERT INTO `schedules` (`user_id`, `title`, `description`, `start_time`, `end_time`, `location`, `is_all_day`, `reminder`, `color`, `status`) VALUES
(2, '期末考试复习', '准备数据结构期末考试', '2024-06-15 19:00:00', '2024-06-15 22:00:00', '图书馆', 0, 30, '#409EFF', 1),
(2, '项目讨论会', '讨论课程设计项目进展', '2024-06-20 14:00:00', '2024-06-20 16:00:00', '实验室', 0, 15, '#67C23A', 1),
(3, '社团活动', '参加数学建模社团活动', '2024-06-18 00:00:00', '2024-06-18 23:59:59', '学生活动中心', 1, 60, '#E6A23C', 1),
(3, '导师面谈', '与导师讨论学习进度', '2024-06-22 10:00:00', '2024-06-22 11:00:00', '办公楼A305', 0, 30, '#F56C6C', 1),
(4, '实验报告提交', '提交物理实验报告', '2024-06-25 23:59:00', '2024-06-25 23:59:59', '在线提交', 0, 1440, '#9B59B6', 1);

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.unilife.mapper.ResourceLikeMapper">
<select id="isLiked" resultType="boolean">
SELECT COUNT(*) > 0
FROM resource_likes
WHERE resource_id = #{resourceId} AND user_id = #{userId}
</select>
<insert id="insert">
INSERT INTO resource_likes (resource_id, user_id, created_at)
VALUES (#{resourceId}, #{userId}, NOW())
</insert>
<delete id="delete">
DELETE FROM resource_likes
WHERE resource_id = #{resourceId} AND user_id = #{userId}
</delete>
<select id="getLikeCount" resultType="int">
SELECT COUNT(*)
FROM resource_likes
WHERE resource_id = #{resourceId}
</select>
</mapper>
Loading…
Cancel
Save