2991692032 4 weeks ago
parent 945bbe25bd
commit 5762c3123b

@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Front.iml" filepath="$PROJECT_DIR$/.idea/Front.iml" />
</modules>
</component>
</project>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}

@ -1,120 +0,0 @@
# 论坛功能完善记录
## 已完成的功能
### 1. 帖子点赞功能
- ✅ 在帖子详情页面添加了点赞/取消点赞按钮
- ✅ 在帖子列表页面为每个帖子添加了点赞按钮
- ✅ 实时更新点赞数量和状态
- ✅ 未登录用户点击点赞会提示登录
- ✅ 在postStore中实现了likePost方法
### 2. 评论系统
- ✅ 创建了评论API接口 (`/src/api/comment.ts`)
- ✅ 创建了完整的评论组件 (`/src/components/CommentSection.vue`)
- ✅ 支持发表评论和回复评论
- ✅ 支持评论的点赞和取消点赞
- ✅ 支持删除自己的评论
- ✅ 评论时间的友好显示(几分钟前、几小时前等)
- ✅ 未登录用户会提示登录
### 3. 个人中心系统
- ✅ 完善了个人主页 (`/src/views/Home.vue`)
- ✅ 真实的用户统计数据API接口
- ✅ 最近发布帖子的展示
- ✅ 加载状态和错误处理
- ✅ 响应式设计
- ✅ 创建了消息中心页面 (`/src/views/MessagesView.vue`)
- ✅ 系统通知、评论回复、点赞通知分类
- ✅ 消息已读/未读状态管理
- ✅ 批量操作功能
- ✅ 创建了设置页面 (`/src/views/SettingsView.vue`)
- ✅ 通知设置
- ✅ 隐私设置
- ✅ 界面设置
- ✅ 账户安全设置
- ✅ 数据管理功能
- ✅ 完善了个人帖子管理页面 (`/src/views/forum/MyPostsView.vue`)
- ✅ 完善了账号管理页面 (`/src/views/AccountManager.vue`)
### 4. 用户体验优化
- ✅ 添加了加载状态和错误处理
- ✅ 优化了UI布局和样式
- ✅ 支持未登录用户浏览但限制互动功能
- ✅ 完善了错误提示和成功提示
- ✅ 响应式设计适配移动端
## 技术实现
### API接口
- `POST /posts/{id}/like` - 点赞/取消点赞帖子
- `GET /comments/post/{postId}` - 获取帖子评论列表
- `POST /comments` - 发表评论
- `DELETE /comments/{id}` - 删除评论
- `POST /comments/{id}/like` - 点赞/取消点赞评论
- `GET /users/stats` - 获取用户统计数据
- `GET /users/recent-posts` - 获取用户最近帖子
### 组件结构
```
个人中心布局 (PersonalLayout.vue)
├── 个人主页 (Home.vue)
│ ├── 用户统计卡片
│ └── 最近帖子列表
├── 账号管理 (AccountManager.vue)
│ ├── 个人资料编辑
│ └── 密码修改
├── 我的帖子 (MyPostsView.vue)
│ ├── 帖子列表
│ └── 编辑/删除操作
├── 消息中心 (MessagesView.vue)
│ ├── 系统通知
│ ├── 评论回复
│ └── 点赞通知
└── 设置 (SettingsView.vue)
├── 通知设置
├── 隐私设置
├── 界面设置
└── 账户安全
帖子列表页面 (PostListView.vue)
├── 点赞按钮
└── 帖子卡片交互
帖子详情页面 (PostDetailView.vue)
├── 点赞按钮
└── 评论区组件 (CommentSection.vue)
├── 评论表单
├── 评论列表
├── 回复功能
└── 评论点赞功能
```
### 状态管理
- 在PostStore中添加了`likePost`方法
- 在UserStore中完善了用户信息管理
- 评论数据通过CommentSection组件本地管理
## 数据流设计
1. **用户统计数据**: API → UserStore → 个人主页显示
2. **帖子点赞**: 用户点击 → PostStore处理 → API调用 → 状态更新
3. **评论系统**: 用户操作 → CommentSection → API调用 → 界面更新
4. **个人设置**: 用户修改 → 本地状态 → API保存 → 成功提示
## 已解决的问题
- ✅ 个人主页假数据问题
- ✅ 消息中心页面缺失
- ✅ 设置页面缺失
- ✅ 用户统计数据获取
- ✅ 帖子交互功能不完整
- ✅ 响应式布局适配
## 待开发功能(建议)
- 🔲 消息系统的后端API实现
- 🔲 设置数据的持久化存储
- 🔲 两步验证功能
- 🔲 会话管理功能
- 🔲 数据导出功能
- 🔲 实时通知推送
- 🔲 好友系统
- 🔲 私信功能

@ -1,5 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

@ -1,15 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/images/默认头像.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="UniLife学生论坛 - 专属于大学生的交流平台" />
<meta name="keywords" content="学生论坛,大学生,交流,UniLife" />
<title>UniLife学生论坛</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -1,30 +0,0 @@
{
"name": "vue-unilife",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vue/shared": "^3.5.13",
"axios": "^1.8.3",
"element-plus": "^2.9.7",
"pinia": "^3.0.2",
"vee-validate": "^4.15.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"yup": "^1.6.1"
},
"devDependencies": {
"@types/node": "^22.15.21",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.7.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.4"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@ -1,34 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useUserStore, useUIStore } from './stores';
import GlobalLoading from './components/GlobalLoading.vue';
const userStore = useUserStore();
const uiStore = useUIStore();
onMounted(() => {
// UI
uiStore.initialize();
//
if (userStore.isLoggedIn) {
userStore.fetchUserInfo();
}
});
</script>
<template>
<div id="app">
<router-view />
<GlobalLoading />
</div>
</template>
<style>
/* 全局样式已移至styles/global.css */
#app {
width: 100%;
height: 100vh;
overflow-x: hidden;
}
</style>

@ -1,61 +0,0 @@
import { get, post as httpPost, del } from './request';
// 评论类型定义
export interface CommentItem {
id: number;
postId: number;
userId: number;
nickname: string;
avatar: string;
content: string;
parentId?: number;
likeCount: number;
isLiked: boolean;
createdAt: string;
replies: CommentItem[];
}
export interface CreateCommentParams {
postId: number;
content: string;
parentId?: number;
}
// 评论API方法
export default {
// 获取帖子评论列表
getCommentsByPostId(postId: number) {
return get<{
code: number;
data: {
total: number;
list: CommentItem[]
}
}>(`/comments/post/${postId}`);
},
// 创建评论
createComment(data: CreateCommentParams) {
return httpPost<{
code: number;
message: string;
data: { commentId: number }
}>('/comments', data);
},
// 删除评论
deleteComment(id: number) {
return del<{
code: number;
message: string
}>(`/comments/${id}`);
},
// 点赞/取消点赞评论
likeComment(id: number) {
return httpPost<{
code: number;
message: string
}>(`/comments/${id}/like`);
}
};

@ -1,112 +0,0 @@
import request from './request';
/**
*
* @param page
* @param size
* @param categoryId ID
* @param keyword
* @param sort latest, hot, likes, commentslatest
* @param userId ID
*/
export function getPosts(page = 1, size = 10, categoryId?: number | null, keyword?: string | null, sort = 'latest', userId?: number | null) {
if (userId) {
// 获取指定用户的帖子
return request({
url: `/posts/user/${userId}`,
method: 'get',
params: { page, size, sort }
});
} else {
// 获取所有帖子或者按分类筛选
return request({
url: '/posts',
method: 'get',
params: { page, size, categoryId, keyword, sort }
});
}
}
/**
*
* @param keyword
* @param page
* @param size
* @param categoryId ID
* @param sort
*/
export function searchPosts(keyword: string, page = 1, size = 10, categoryId?: number | null, sort = 'latest') {
return request({
url: '/posts',
method: 'get',
params: { keyword, page, size, categoryId, sort }
});
}
/**
*
* @param id ID
*/
export function getPostDetail(id: number) {
return request({
url: `/posts/${id}`,
method: 'get'
});
}
/**
*
* @param data
*/
export function createPost(data: any) {
return request({
url: '/posts',
method: 'post',
data
});
}
/**
*
* @param id ID
* @param data
*/
export function updatePost(id: number, data: any) {
return request({
url: `/posts/${id}`,
method: 'put',
data
});
}
/**
*
* @param id ID
*/
export function deletePost(id: number) {
return request({
url: `/posts/${id}`,
method: 'delete'
});
}
/**
*
* @param id ID
*/
export function likePost(id: number) {
return request({
url: `/posts/${id}/like`,
method: 'post'
});
}
/**
*
*/
export function getCategories() {
return request({
url: '/categories',
method: 'get'
});
}

@ -1,20 +0,0 @@
import userApi from './user';
import postApi from './post';
import resourceApi from './resource';
import scheduleApi from './schedule';
import commentApi from './comment';
export {
userApi,
postApi,
resourceApi,
scheduleApi,
commentApi
};
export * from './user';
export * from './post';
export * from './comment';
export * from './resource';
export * from './schedule';
export * from './search';

@ -1,106 +0,0 @@
import { get, post as httpPost, put, del } from './request';
// 类型定义
export interface PostItem {
id: number;
title: string;
summary?: string; // Added: for post list item summary
content: string; // Existing: for post detail content
userId: number;
nickname: string;
avatar: string;
categoryId: number;
categoryName: string;
viewCount: number;
likeCount: number;
commentCount: number;
createdAt: string;
updatedAt: string;
isLiked?: boolean; // Added: for post detail, if current user liked it
}
export interface CategoryItem {
id: number;
name: string;
description?: string;
}
export interface CreatePostParams {
title: string;
content: string;
categoryId: number;
}
// API方法
export default {
// 获取帖子列表
getPosts(params: { pageNum?: number; pageSize?: number; categoryId?: number; sort?: string }) {
// 将前端参数名转换为后端参数名
const serverParams: any = {
page: params.pageNum,
size: params.pageSize,
sort: params.sort
};
// 保留categoryId参数
if (params.categoryId !== undefined) {
serverParams.categoryId = params.categoryId;
}
return get<{ code: number; data: { total: number; list: PostItem[]; pages: number; pageNum: number; pageSize: number } }>('/posts', serverParams);
},
// 获取帖子详情
getPostDetail(id: number) {
return get<{ code: number; data: PostItem }>(`/posts/${id}`);
},
// 创建帖子
createPost(data: CreatePostParams) {
return httpPost<{ code: number; data: { postId: number } }>('/posts', data);
},
// 更新帖子
updatePost(id: number, data: CreatePostParams) {
return put<{ code: number; message: string }>(`/posts/${id}`, data);
},
// 删除帖子
deletePost(id: number) {
return del<{ code: number; message: string }>(`/posts/${id}`);
},
// 点赞/取消点赞帖子
likePost(id: number) {
return httpPost<{ code: number; message: string }>(`/posts/${id}/like`);
},
// 获取用户的帖子列表
getUserPosts(userId?: number) {
const params = userId ? { userId } : {};
return get<{ code: number; data: { total: number; list: PostItem[]; pages: number } }>('/posts', params);
},
// 获取所有帖子分类
getCategories() {
return get<{ code: number; message: string; data: { list: CategoryItem[], total: number } }>('/categories');
},
// 搜索帖子
searchPosts(params: { keyword: string; categoryId?: number | null; pageNum?: number; pageSize?: number; sort?: string }) {
// 将前端参数名转换为后端参数名
const serverParams: any = {
keyword: params.keyword,
page: params.pageNum,
size: params.pageSize,
sort: params.sort || 'latest'
};
// 保留categoryId参数
if (params.categoryId !== undefined && params.categoryId !== null) {
serverParams.categoryId = params.categoryId;
}
return get<{ code: number; data: { total: number; list: PostItem[]; pages: number } }>('/posts', serverParams);
}
};

@ -1,108 +0,0 @@
import axios from 'axios';
import type { AxiosResponse } from 'axios';
import { ElMessage } from 'element-plus';
// 创建axios实例
const service = axios.create({
baseURL: 'http://localhost:8087',
timeout: 10000
});
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 从localStorage获取token
const token = localStorage.getItem('token');
// 如果存在token则添加到请求头
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
console.log("请求已附加token");
}
return config;
},
(error) => {
console.error('请求错误:', error);
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data;
// 如果接口返回的状态码不是200则判断为错误
if (res.code !== 200) {
ElMessage({
message: res.message || '请求失败',
type: 'error',
duration: 5 * 1000
});
// 处理特定错误码
if (res.code === 401) {
// 未授权清除token并重定向到登录页
localStorage.removeItem('token');
const currentPath = window.location.pathname;
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
return Promise.reject(new Error('未登录或登录已过期'));
}
return Promise.reject(new Error(res.message || '请求失败'));
} else {
return res;
}
},
(error) => {
console.error('响应错误:', error);
// 处理HTTP 401错误
if (error.response && error.response.status === 401) {
ElMessage({
message: '未登录或登录已过期,请重新登录',
type: 'warning',
duration: 3000
});
// 清除token
localStorage.removeItem('token');
// 获取当前页面路径,并重定向到登录页面
const currentPath = window.location.pathname;
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
return Promise.reject(error);
}
ElMessage({
message: error.message || '请求失败',
type: 'error',
duration: 5 * 1000
});
return Promise.reject(error);
}
);
// 封装GET请求
export function get<T>(url: string, params?: any): Promise<T> {
return service.get(url, { params });
}
// 封装POST请求
export function post<T>(url: string, data?: any): Promise<T> {
return service.post(url, data);
}
// 封装PUT请求
export function put<T>(url: string, data?: any): Promise<T> {
return service.put(url, data);
}
// 封装DELETE请求
export function del<T>(url: string, params?: any): Promise<T> {
return service.delete(url, { params });
}
export default service;

@ -1,102 +0,0 @@
import { get, post, put, del } from './request';
// 资源类型定义
export interface ResourceItem {
id: number;
title: string;
description?: string;
fileUrl: string;
fileSize: number;
fileType: string;
userId: number;
nickname: string;
avatar?: string;
categoryId: number;
categoryName: string;
downloadCount: number;
likeCount: number;
isLiked?: boolean;
createdAt: string;
updatedAt: string;
}
export interface ResourceCategory {
id: number;
name: string;
description?: string;
icon?: string;
}
export interface ResourceParams {
page?: number;
size?: number;
categoryId?: number;
keyword?: string;
userId?: number;
}
export interface UploadResourceParams {
title: string;
description?: string;
categoryId: number;
file: File;
}
export interface UpdateResourceParams {
title?: string;
description?: string;
categoryId?: number;
}
// 资源API
export default {
// 获取资源列表
getResources(params: ResourceParams = {}) {
return get<{ code: number; data: { total: number; list: ResourceItem[]; pages: number } }>('/resources', params);
},
// 获取资源详情
getResourceDetail(id: number) {
return get<{ code: number; data: ResourceItem }>(`/resources/${id}`);
},
// 上传资源
uploadResource(data: FormData) {
return post<{ code: number; data: { resourceId: number } }>('/resources', data);
},
// 更新资源信息
updateResource(id: number, data: UpdateResourceParams) {
return put<{ code: number; message: string }>(`/resources/${id}`, data);
},
// 删除资源
deleteResource(id: number) {
return del<{ code: number; message: string }>(`/resources/${id}`);
},
// 下载资源
downloadResource(id: number) {
return get<{ code: number; data: { fileUrl: string; fileName: string; fileType: string } }>(`/resources/${id}/download`);
},
// 点赞/取消点赞资源
likeResource(id: number) {
return post<{ code: number; message: string }>(`/resources/${id}/like`);
},
// 获取用户上传的资源列表
getUserResources(userId: number) {
return get<{ code: number; data: { total: number; list: ResourceItem[]; pages: number } }>(`/resources/user/${userId}`);
},
// 获取当前用户上传的资源列表
getMyResources(params: { page?: number; size?: number } = {}) {
return get<{ code: number; data: { total: number; list: ResourceItem[]; pages: number } }>('/resources/my', params);
},
// 获取资源分类
getResourceCategories() {
return get<{ code: number; data: { list: ResourceCategory[]; total: number } }>('/categories');
}
};

@ -1,151 +0,0 @@
import { get, post, put, del } from './request';
// 课程类型定义
export interface CourseItem {
id: number;
userId: number;
name: string;
teacher?: string;
location?: string;
dayOfWeek: number; // 1-7 表示周一到周日
startTime: string; // 格式: "08:00:00"
endTime: string; // 格式: "09:40:00"
startWeek: number; // 开始周次
endWeek: number; // 结束周次
color?: string; // 颜色,如 "#4CAF50"
status: number; // 状态0-删除, 1-正常)
createdAt: string;
updatedAt: string;
}
// 日程类型定义
export interface ScheduleItem {
id: number;
userId: number;
title: string;
description?: string;
startTime: string; // 格式: "2023-05-10T14:00:00"
endTime: string; // 格式: "2023-05-10T16:00:00"
location?: string;
isAllDay: number; // 是否全天0-否, 1-是)
reminder?: number; // 提醒时间(分钟)
color?: string; // 颜色,如 "#FF5722"
status: number; // 状态0-删除, 1-正常)
createdAt: string;
updatedAt: string;
}
export interface CreateCourseParams {
name: string;
teacher?: string;
location?: string;
dayOfWeek: number;
startTime: string;
endTime: string;
startWeek: number;
endWeek: number;
semester?: string;
color?: string;
}
export interface UpdateCourseParams extends Partial<CreateCourseParams> {}
export interface CreateScheduleParams {
title: string;
description?: string;
startTime: string;
endTime: string;
location?: string;
isAllDay?: number;
reminder?: number;
color?: string;
}
export interface UpdateScheduleParams extends Partial<CreateScheduleParams> {}
export interface ConflictCheckResult {
hasConflict: boolean;
conflictCount: number;
}
// 课程表与日程管理API
export default {
// 课程相关API
// 创建课程
createCourse(data: CreateCourseParams) {
return post<{ code: number; data: { courseId: number } }>('/courses', data);
},
// 获取课程详情
getCourseDetail(id: number) {
return get<{ code: number; data: CourseItem }>(`/courses/${id}`);
},
// 获取用户的所有课程
getAllCourses() {
return get<{ code: number; data: { total: number; list: CourseItem[] } }>('/courses');
},
// 获取用户在指定星期几的课程
getCoursesByDay(dayOfWeek: number) {
return get<{ code: number; data: { total: number; list: CourseItem[] } }>(`/courses/day/${dayOfWeek}`);
},
// 获取用户在指定学期的课程
getCoursesBySemester(semester: string) {
return get<{ code: number; data: { total: number; list: CourseItem[] } }>(`/courses/semester/${semester}`);
},
// 更新课程
updateCourse(id: number, data: UpdateCourseParams) {
return put<{ code: number; message: string }>(`/courses/${id}`, data);
},
// 删除课程
deleteCourse(id: number) {
return del<{ code: number; message: string }>(`/courses/${id}`);
},
// 检查课程时间冲突
checkCourseConflict(params: { dayOfWeek: number; startTime: string; endTime: string; excludeCourseId?: number }) {
return get<{ code: number; data: ConflictCheckResult }>('/courses/check-conflict', params);
},
// 日程相关API
// 创建日程
createSchedule(data: CreateScheduleParams) {
return post<{ code: number; data: { scheduleId: number } }>('/schedules', data);
},
// 获取日程详情
getScheduleDetail(id: number) {
return get<{ code: number; data: ScheduleItem }>(`/schedules/${id}`);
},
// 获取用户的所有日程
getAllSchedules() {
return get<{ code: number; data: { total: number; list: ScheduleItem[] } }>('/schedules');
},
// 获取用户在指定时间范围内的日程
getSchedulesByRange(params: { startTime: string; endTime: string }) {
return get<{ code: number; data: { total: number; list: ScheduleItem[] } }>('/schedules/range', params);
},
// 更新日程
updateSchedule(id: number, data: UpdateScheduleParams) {
return put<{ code: number; message: string }>(`/schedules/${id}`, data);
},
// 删除日程
deleteSchedule(id: number) {
return del<{ code: number; message: string }>(`/schedules/${id}`);
},
// 检查日程时间冲突
checkScheduleConflict(params: { startTime: string; endTime: string; excludeScheduleId?: number }) {
return get<{ code: number; data: ConflictCheckResult }>('/schedules/check-conflict', params);
}
};

@ -1,64 +0,0 @@
import { get } from './request'
// 搜索相关接口类型定义
export interface SearchParams {
keyword: string
type?: 'all' | 'post' | 'resource' | 'user'
categoryId?: number
sortBy?: 'time' | 'relevance' | 'popularity'
page?: number
size?: number
}
export interface SearchItem {
id: number
title: string
summary: string
type: 'post' | 'resource' | 'user'
author: string
avatar: string
categoryName: string
createdAt: string
likeCount: number
viewCount: number
highlights?: string[]
}
export interface SearchResult {
items: SearchItem[]
total: number
page: number
size: number
keyword: string
searchTime: number
}
// 综合搜索
export const search = (params: SearchParams) => {
return get<SearchResult>('/search', params)
}
// 搜索帖子
export const searchPosts = (params: SearchParams) => {
return get<SearchResult>('/search/posts', params)
}
// 搜索资源
export const searchResources = (params: SearchParams) => {
return get<SearchResult>('/search/resources', params)
}
// 搜索用户
export const searchUsers = (params: SearchParams) => {
return get<SearchResult>('/search/users', params)
}
// 获取搜索建议
export const getSuggestions = (keyword: string) => {
return get<string[]>('/search/suggestions', { keyword })
}
// 获取热门搜索词
export const getHotKeywords = () => {
return get<string[]>('/search/hot-keywords')
}

@ -1,146 +0,0 @@
import { get, post, put } from './request';
// 用户接口类型定义
export interface UserInfo {
id: number;
username: string;
email: string;
nickname?: string;
avatar?: string;
bio?: string;
gender?: number;
birthday?: string;
studentId?: string;
department?: string;
major?: string;
grade?: string;
points?: number;
role?: number;
isVerified?: number;
}
export interface LoginParams {
email: string;
password: string;
}
export interface RegisterParams {
email: string;
password: string;
username?: string;
studentId?: string;
department?: string;
major?: string;
grade?: string;
code: string;
}
export interface EmailCodeParams {
email: string;
}
export interface VerifyCodeParams {
email: string;
code: string;
}
export interface UpdateProfileParams {
username?: string;
bio?: string;
gender?: number;
department?: string;
major?: string;
grade?: string;
}
export interface UpdatePasswordParams {
code: string;
newPassword: string;
}
export interface UserStats {
totalPosts: number;
totalLikes: number;
totalComments: number;
totalViews: number;
}
// 用户API
export default {
// 登录
login(data: LoginParams) {
return post<{code: number; data: {token: string}}>(
'/users/login',
data
);
},
// 注册
register(data: RegisterParams) {
return post<{code: number; data: {token: string}}>(
'/users/register',
data
);
},
// 获取邮箱验证码
getEmailCode(data: EmailCodeParams) {
return post<{code: number; message: string}>(
'/users/code',
data
);
},
// 验证邮箱验证码
verifyEmailCode(data: VerifyCodeParams) {
return post<{code: number; data: {token: string}}>(
'/users/login/code',
data
);
},
// 获取用户信息
getUserInfo() {
return get<{code: number; data: UserInfo}>(
'/users/info'
);
},
// 更新用户资料
updateProfile(data: UpdateProfileParams) {
return put<{code: number; message: string}>(
'/users/profile',
data
);
},
// 更新用户密码
updatePassword(data: UpdatePasswordParams) {
return put<{code: number; message: string}>(
'/users/password',
data
);
},
// 获取用户统计数据
getUserStats() {
return get<{code: number; data: UserStats}>(
'/users/stats'
);
},
// 获取用户最近帖子
getUserRecentPosts(limit: number = 5) {
return get<{code: number; data: {list: any[]}}>(
`/users/recent-posts?limit=${limit}`
);
},
// 上传头像
uploadAvatar(formData: FormData) {
return post<{code: number; data: {avatarUrl: string}}>(
'/users/avatar',
formData
);
}
};

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

@ -1,521 +0,0 @@
<template>
<div class="comment-section">
<div class="comment-section-header">
<h3>评论区 ({{ commentTotal }})</h3>
</div>
<!-- 评论输入框 -->
<div class="comment-form" v-if="userStore.isLoggedIn">
<el-input
v-model="commentContent"
type="textarea"
:rows="3"
placeholder="写下你的评论..."
maxlength="500"
show-word-limit
/>
<div class="comment-form-actions">
<el-button
type="primary"
@click="submitComment"
:loading="submittingComment"
:disabled="!commentContent.trim()"
>
发表评论
</el-button>
</div>
</div>
<div class="login-prompt" v-else>
<el-alert
title="请登录后发表评论"
type="info"
show-icon
:closable="false"
>
<template #default>
<el-button type="primary" size="small" @click="goLogin"></el-button>
</template>
</el-alert>
</div>
<!-- 评论列表 -->
<div class="comment-list">
<el-skeleton :rows="3" animated v-if="loading && comments.length === 0" />
<el-alert
v-if="error && comments.length === 0"
:title="`加载评论失败: ${error}`"
type="error"
show-icon
:closable="false"
/>
<div v-if="!loading && comments.length === 0 && !error" class="empty-comments">
<el-empty description="暂无评论,来发表第一条评论吧!" :image-size="100"></el-empty>
</div>
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-main">
<div class="comment-avatar">
<el-avatar :src="comment.avatar" :size="40">
{{ comment.nickname.charAt(0) }}
</el-avatar>
</div>
<div class="comment-content">
<div class="comment-header">
<span class="comment-nickname">{{ comment.nickname }}</span>
<span class="comment-time">{{ formatDate(comment.createdAt) }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-actions">
<el-button
text
:type="comment.isLiked ? 'primary' : ''"
@click="toggleCommentLike(comment)"
:loading="comment.id === likingCommentId"
>
<el-icon><Pointer /></el-icon>
{{ comment.likeCount || 0 }}
</el-button>
<el-button text @click="showReplyForm(comment)">
<el-icon><ChatDotRound /></el-icon>
回复
</el-button>
<el-button
v-if="userStore.isLoggedIn && userStore.userInfo && comment.userId === userStore.userInfo.id"
text
type="danger"
@click="deleteComment(comment)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
</div>
<!-- 回复表单 -->
<div v-if="replyingTo === comment.id" class="reply-form">
<el-input
v-model="replyContent"
type="textarea"
:rows="2"
:placeholder="`回复 ${comment.nickname}...`"
maxlength="500"
show-word-limit
/>
<div class="reply-form-actions">
<el-button size="small" @click="cancelReply"></el-button>
<el-button
type="primary"
size="small"
@click="submitReply(comment)"
:loading="submittingReply"
:disabled="!replyContent.trim()"
>
回复
</el-button>
</div>
</div>
<!-- 回复列表 -->
<div v-if="comment.replies && comment.replies.length > 0" class="replies">
<div v-for="reply in comment.replies" :key="reply.id" class="reply-item">
<div class="comment-avatar">
<el-avatar :src="reply.avatar" :size="32">
{{ reply.nickname.charAt(0) }}
</el-avatar>
</div>
<div class="comment-content">
<div class="comment-header">
<span class="comment-nickname">{{ reply.nickname }}</span>
<span class="comment-time">{{ formatDate(reply.createdAt) }}</span>
</div>
<div class="comment-text">{{ reply.content }}</div>
<div class="comment-actions">
<el-button
text
:type="reply.isLiked ? 'primary' : ''"
@click="toggleCommentLike(reply)"
:loading="reply.id === likingCommentId"
>
<el-icon><Pointer /></el-icon>
{{ reply.likeCount || 0 }}
</el-button>
<el-button
v-if="userStore.isLoggedIn && userStore.userInfo && reply.userId === userStore.userInfo.id"
text
type="danger"
@click="deleteComment(reply)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores';
import commentApi from '@/api/comment';
import type { CommentItem } from '@/api/comment';
import { ElMessage, ElMessageBox, ElIcon, ElButton, ElInput, ElAlert, ElEmpty, ElSkeleton, ElAvatar } from 'element-plus';
import { Pointer, ChatDotRound, Delete } from '@element-plus/icons-vue';
const props = defineProps<{
postId: number;
}>();
const router = useRouter();
const userStore = useUserStore();
//
const comments = ref<CommentItem[]>([]);
const commentTotal = ref(0);
const loading = ref(false);
const error = ref<string | null>(null);
//
const commentContent = ref('');
const submittingComment = ref(false);
//
const replyingTo = ref<number | null>(null);
const replyContent = ref('');
const submittingReply = ref(false);
//
const likingCommentId = ref<number | null>(null);
//
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`;
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
//
const fetchComments = async () => {
loading.value = true;
error.value = null;
try {
const response = await commentApi.getCommentsByPostId(props.postId);
if (response && response.code === 200 && response.data) {
comments.value = response.data.list;
commentTotal.value = response.data.total;
} else {
throw new Error('获取评论失败');
}
} catch (err: any) {
const errorMsg = err.message || '获取评论失败';
error.value = errorMsg;
ElMessage.error(errorMsg);
} finally {
loading.value = false;
}
};
//
const submitComment = async () => {
if (!commentContent.value.trim()) {
ElMessage.warning('请输入评论内容');
return;
}
submittingComment.value = true;
try {
const response = await commentApi.createComment({
postId: props.postId,
content: commentContent.value.trim()
});
if (response && response.code === 200) {
ElMessage.success('评论发表成功');
commentContent.value = '';
await fetchComments(); //
} else {
throw new Error('发表评论失败');
}
} catch (err: any) {
if (err.response && err.response.status === 401) {
ElMessage.warning('请先登录');
goLogin();
} else {
ElMessage.error(err.message || '发表评论失败');
}
} finally {
submittingComment.value = false;
}
};
//
const showReplyForm = (comment: CommentItem) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录');
goLogin();
return;
}
replyingTo.value = comment.id;
replyContent.value = '';
};
//
const cancelReply = () => {
replyingTo.value = null;
replyContent.value = '';
};
//
const submitReply = async (parentComment: CommentItem) => {
if (!replyContent.value.trim()) {
ElMessage.warning('请输入回复内容');
return;
}
submittingReply.value = true;
try {
const response = await commentApi.createComment({
postId: props.postId,
content: replyContent.value.trim(),
parentId: parentComment.id
});
if (response && response.code === 200) {
ElMessage.success('回复发表成功');
cancelReply();
await fetchComments(); //
} else {
throw new Error('发表回复失败');
}
} catch (err: any) {
if (err.response && err.response.status === 401) {
ElMessage.warning('请先登录');
goLogin();
} else {
ElMessage.error(err.message || '发表回复失败');
}
} finally {
submittingReply.value = false;
}
};
// /
const toggleCommentLike = async (comment: CommentItem) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录');
goLogin();
return;
}
likingCommentId.value = comment.id;
try {
const response = await commentApi.likeComment(comment.id);
if (response && response.code === 200) {
//
if (comment.isLiked) {
comment.likeCount -= 1;
comment.isLiked = false;
ElMessage.success('取消点赞成功');
} else {
comment.likeCount += 1;
comment.isLiked = true;
ElMessage.success('点赞成功');
}
} else {
throw new Error('操作失败');
}
} catch (err: any) {
if (err.response && err.response.status === 401) {
ElMessage.warning('请先登录');
goLogin();
} else {
ElMessage.error(err.message || '操作失败');
}
} finally {
likingCommentId.value = null;
}
};
//
const deleteComment = async (comment: CommentItem) => {
try {
await ElMessageBox.confirm('确定要删除这条评论吗?', '删除确认', {
type: 'warning'
});
const response = await commentApi.deleteComment(comment.id);
if (response && response.code === 200) {
ElMessage.success('删除成功');
await fetchComments(); //
} else {
throw new Error('删除失败');
}
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.message || '删除失败');
}
}
};
//
const goLogin = () => {
router.push({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
});
};
// postId
watch(() => props.postId, (newPostId) => {
if (newPostId) {
fetchComments();
}
});
onMounted(() => {
if (props.postId) {
fetchComments();
}
});
</script>
<style scoped>
.comment-section {
margin-top: 20px;
}
.comment-section-header {
margin-bottom: 20px;
}
.comment-section-header h3 {
margin: 0;
color: var(--el-text-color-primary);
}
.comment-form {
margin-bottom: 30px;
padding: 20px;
background: var(--el-bg-color-page);
border-radius: 8px;
}
.comment-form-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.login-prompt {
margin-bottom: 30px;
}
.comment-list {
min-height: 200px;
}
.empty-comments {
text-align: center;
padding: 40px 0;
}
.comment-item {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.comment-item:last-child {
border-bottom: none;
}
.comment-main {
display: flex;
gap: 12px;
}
.comment-avatar {
flex-shrink: 0;
}
.comment-content {
flex: 1;
min-width: 0;
}
.comment-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.comment-nickname {
font-weight: 600;
color: var(--el-text-color-primary);
}
.comment-time {
font-size: 0.85em;
color: var(--el-text-color-secondary);
}
.comment-text {
line-height: 1.6;
color: var(--el-text-color-regular);
margin-bottom: 10px;
word-wrap: break-word;
}
.comment-actions {
display: flex;
gap: 8px;
}
.reply-form {
margin-top: 16px;
margin-left: 52px;
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 6px;
}
.reply-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.replies {
margin-top: 16px;
margin-left: 52px;
padding-left: 20px;
border-left: 2px solid var(--el-border-color-lighter);
}
.reply-item {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.reply-item:last-child {
margin-bottom: 0;
}
</style>

@ -1,76 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useUIStore } from '../stores';
const uiStore = useUIStore();
//
const isVisible = computed(() => uiStore.isLoading);
const loadingText = computed(() => uiStore.loadingText);
</script>
<template>
<transition name="fade">
<div v-if="isVisible" class="loading-overlay">
<div class="loading-container">
<div class="loading-spinner"></div>
<div class="loading-text">{{ loadingText }}</div>
</div>
</div>
</transition>
</template>
<style scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-container {
background-color: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--secondary-color);
border-top: 4px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
.loading-text {
color: var(--text-primary);
font-size: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

@ -1,79 +0,0 @@
import { ref } from 'vue';
import { userApi } from '../api';
import { ElMessage } from 'element-plus';
export function useEmailCode() {
const isSending = ref(false);
const countdown = ref(0);
let timer: number | null = null;
// 发送邮箱验证码
const sendEmailCode = async (email: string) => {
if (isSending.value) return;
if (!email) {
ElMessage.warning('请输入邮箱地址');
return;
}
try {
isSending.value = true;
const res = await userApi.getEmailCode({ email });
if (res.code === 200) {
ElMessage.success('验证码已发送,请查收邮件');
startCountdown();
}
return res;
} catch (error) {
console.error('发送验证码失败:', error);
ElMessage.error('发送验证码失败,请稍后重试');
} finally {
isSending.value = false;
}
};
// 验证邮箱验证码
const verifyEmailCode = async (email: string, code: string) => {
if (!email || !code) {
ElMessage.warning('请输入邮箱和验证码');
return;
}
try {
const res = await userApi.verifyEmailCode({ email, code });
return res;
} catch (error) {
console.error('验证码验证失败:', error);
throw error;
}
};
// 开始倒计时
const startCountdown = () => {
countdown.value = 60;
if (timer) {
clearInterval(timer);
}
timer = window.setInterval(() => {
if (countdown.value > 0) {
countdown.value--;
} else {
if (timer) {
clearInterval(timer);
timer = null;
}
}
}, 1000);
};
return {
isSending,
countdown,
sendEmailCode,
verifyEmailCode
};
}

@ -1,27 +0,0 @@
<script setup lang="ts">
import { useUserStore } from '../stores';
import { onMounted } from 'vue';
const userStore = useUserStore();
onMounted(async () => {
//
if (userStore.isLoggedIn) {
await userStore.fetchUserInfo();
}
});
</script>
<template>
<div class="base-layout">
<router-view />
</div>
</template>
<style scoped>
.base-layout {
width: 100%;
height: 100vh;
overflow-x: hidden;
}
</style>

@ -1,693 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore, useUIStore } from '@/stores'
import { ElMessage, ElDropdown, ElDropdownMenu, ElDropdownItem, ElButton, ElIcon, ElBadge, ElAvatar } from 'element-plus'
import {
HomeFilled as Home,
ChatDotRound,
Document,
Calendar,
Clock,
User,
Setting,
Search,
Bell,
Moon,
Sunny,
Menu,
Close,
ArrowDown
} from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const uiStore = useUIStore()
//
const isMobileMenuOpen = ref(false)
const isScrolled = ref(false)
const unreadNotifications = ref(3) //
//
const navigationItems = [
{ name: '首页', path: '/home', icon: Home },
{ name: '论坛', path: '/forum', icon: ChatDotRound },
{ name: '课程表', path: '/course-table', icon: Document },
{ name: '日程', path: '/schedule', icon: Clock },
{ name: '资源', path: '/resource', icon: Calendar },
]
//
const currentPath = computed(() => route.path)
const isDarkMode = computed(() => uiStore.isDarkMode)
//
const handleScroll = () => {
isScrolled.value = window.scrollY > 20
}
//
const toggleMobileMenu = () => {
isMobileMenuOpen.value = !isMobileMenuOpen.value
}
//
const closeMobileMenu = () => {
isMobileMenuOpen.value = false
}
//
const navigateTo = (path: string) => {
router.push(path)
closeMobileMenu()
}
//
const handleProfileClick = () => {
router.push('/profile')
closeMobileMenu()
}
const handleSettingsClick = () => {
router.push('/settings')
closeMobileMenu()
}
const handleLogout = async () => {
try {
await userStore.logout()
ElMessage.success('退出成功')
router.push('/login')
} catch (error) {
ElMessage.error('退出失败')
}
closeMobileMenu()
}
//
const toggleTheme = () => {
uiStore.toggleDarkMode()
}
//
onMounted(() => {
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<template>
<div class="main-layout">
<!-- 导航栏 -->
<header
class="navbar"
:class="{ 'navbar--scrolled': isScrolled }"
>
<div class="navbar__container">
<!-- Logo -->
<div class="navbar__brand" @click="navigateTo('/home')">
<div class="brand-logo">
<div class="logo-icon">
<span class="logo-text">UL</span>
</div>
<span class="brand-name">UniLife</span>
</div>
</div>
<!-- 桌面端导航 -->
<nav class="navbar__nav">
<div class="nav-items">
<a
v-for="item in navigationItems"
:key="item.path"
class="nav-item"
:class="{ 'nav-item--active': currentPath.startsWith(item.path) }"
@click="navigateTo(item.path)"
>
<el-icon class="nav-item__icon">
<component :is="item.icon" />
</el-icon>
<span class="nav-item__text">{{ item.name }}</span>
</a>
</div>
</nav>
<!-- 右侧操作区 -->
<div class="navbar__actions">
<!-- 搜索按钮 -->
<el-button
class="action-btn"
circle
@click="navigateTo('/search')"
>
<el-icon><Search /></el-icon>
</el-button>
<!-- 通知按钮 -->
<el-badge :value="unreadNotifications" :hidden="unreadNotifications === 0">
<el-button
class="action-btn"
circle
@click="navigateTo('/notifications')"
>
<el-icon><Bell /></el-icon>
</el-button>
</el-badge>
<!-- 主题切换 -->
<el-button
class="action-btn"
circle
@click="toggleTheme"
>
<el-icon>
<Sunny v-if="isDarkMode" />
<Moon v-else />
</el-icon>
</el-button>
<!-- 用户菜单 -->
<el-dropdown v-if="userStore.isLoggedIn" trigger="click" placement="bottom-end">
<div class="user-menu">
<el-avatar
:size="36"
:src="userStore.userInfo?.avatar"
class="user-avatar"
>
{{ userStore.userInfo?.nickname?.charAt(0) || 'U' }}
</el-avatar>
<span class="user-name">{{ userStore.userInfo?.nickname || '用户' }}</span>
<el-icon class="dropdown-arrow"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleProfileClick">
<el-icon><User /></el-icon>
个人资料
</el-dropdown-item>
<el-dropdown-item @click="handleSettingsClick">
<el-icon><Setting /></el-icon>
系统设置
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 登录按钮 -->
<el-button
v-else
type="primary"
@click="navigateTo('/login')"
>
登录
</el-button>
<!-- 移动端菜单按钮 -->
<el-button
class="mobile-menu-btn"
circle
@click="toggleMobileMenu"
>
<el-icon>
<Close v-if="isMobileMenuOpen" />
<Menu v-else />
</el-icon>
</el-button>
</div>
</div>
<!-- 移动端菜单 -->
<transition name="mobile-menu">
<div v-if="isMobileMenuOpen" class="mobile-menu">
<div class="mobile-menu__content">
<!-- 导航项 -->
<div class="mobile-nav">
<a
v-for="item in navigationItems"
:key="item.path"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': currentPath.startsWith(item.path) }"
@click="navigateTo(item.path)"
>
<el-icon class="mobile-nav-item__icon">
<component :is="item.icon" />
</el-icon>
<span class="mobile-nav-item__text">{{ item.name }}</span>
</a>
</div>
<!-- 用户操作 -->
<div v-if="userStore.isLoggedIn" class="mobile-user-actions">
<div class="mobile-user-info">
<el-avatar
:size="48"
:src="userStore.userInfo?.avatar"
>
{{ userStore.userInfo?.nickname?.charAt(0) || 'U' }}
</el-avatar>
<div class="user-details">
<span class="user-name">{{ userStore.userInfo?.nickname || '用户' }}</span>
<span class="user-email">{{ userStore.userInfo?.email || '' }}</span>
</div>
</div>
<div class="mobile-action-buttons">
<el-button @click="handleProfileClick" style="width: 100%;">
<el-icon><User /></el-icon>
个人资料
</el-button>
<el-button @click="handleSettingsClick" style="width: 100%;">
<el-icon><Setting /></el-icon>
系统设置
</el-button>
<el-button type="danger" @click="handleLogout" style="width: 100%;">
退出登录
</el-button>
</div>
</div>
<div v-else class="mobile-auth-actions">
<el-button type="primary" @click="navigateTo('/login')" style="width: 100%;">
登录
</el-button>
<el-button @click="navigateTo('/register')" style="width: 100%;">
注册
</el-button>
</div>
</div>
</div>
</transition>
</header>
<!-- 主内容区 -->
<main class="main-content">
<router-view />
</main>
<!-- 底部导航移动端 -->
<nav class="bottom-nav">
<a
v-for="item in navigationItems"
:key="item.path"
class="bottom-nav-item"
:class="{ 'bottom-nav-item--active': currentPath.startsWith(item.path) }"
@click="navigateTo(item.path)"
>
<el-icon class="bottom-nav-item__icon">
<component :is="item.icon" />
</el-icon>
<span class="bottom-nav-item__text">{{ item.name }}</span>
</a>
</nav>
</div>
</template>
<style scoped>
.main-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 导航栏样式 */
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--z-fixed);
background: var(--bg-overlay);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-light);
transition: all var(--duration-200) var(--ease-out);
}
.navbar--scrolled {
box-shadow: var(--shadow-lg);
background: var(--bg-elevated);
}
.navbar__container {
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 var(--space-6);
height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
}
/* Logo和品牌 */
.navbar__brand {
cursor: pointer;
user-select: none;
}
.brand-logo {
display: flex;
align-items: center;
gap: var(--space-3);
}
.logo-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--primary-600), var(--primary-500));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: var(--font-weight-bold);
box-shadow: var(--shadow-primary);
}
.logo-text {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
}
.brand-name {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
background: linear-gradient(135deg, var(--primary-600), var(--primary-400));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 桌面端导航 */
.navbar__nav {
flex: 1;
display: flex;
justify-content: center;
}
.nav-items {
display: flex;
gap: var(--space-2);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
color: var(--text-secondary);
text-decoration: none;
font-weight: var(--font-weight-medium);
transition: all var(--duration-150) var(--ease-out);
cursor: pointer;
position: relative;
}
.nav-item::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 2px;
background: var(--primary-500);
border-radius: var(--radius-full);
transition: all var(--duration-200) var(--ease-out);
transform: translateX(-50%);
}
.nav-item:hover {
color: var(--text-primary);
background: var(--neutral-100);
}
.nav-item--active {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-item--active::before {
width: 24px;
}
.nav-item__icon {
font-size: var(--font-size-lg);
}
/* 右侧操作区 */
.navbar__actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.action-btn {
width: 40px;
height: 40px;
border: 1px solid var(--border-light);
background: var(--bg-elevated);
color: var(--text-secondary);
transition: all var(--duration-150) var(--ease-out);
}
.action-btn:hover {
background: var(--neutral-100);
color: var(--text-primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* 用户菜单 */
.user-menu {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--duration-150) var(--ease-out);
}
.user-menu:hover {
background: var(--neutral-100);
}
.user-avatar {
box-shadow: var(--shadow-sm);
}
.user-name {
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.dropdown-arrow {
color: var(--text-light);
transition: transform var(--duration-150) var(--ease-out);
}
/* 移动端菜单按钮 */
.mobile-menu-btn {
display: none;
width: 40px;
height: 40px;
}
/* 移动端菜单 */
.mobile-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-light);
box-shadow: var(--shadow-lg);
}
.mobile-menu__content {
padding: var(--space-6);
}
.mobile-nav {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-6);
}
.mobile-nav-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
border-radius: var(--radius-lg);
color: var(--text-secondary);
text-decoration: none;
font-weight: var(--font-weight-medium);
transition: all var(--duration-150) var(--ease-out);
cursor: pointer;
}
.mobile-nav-item:hover {
background: var(--neutral-100);
color: var(--text-primary);
}
.mobile-nav-item--active {
background: var(--primary-50);
color: var(--primary-600);
}
.mobile-nav-item__icon {
font-size: var(--font-size-xl);
}
/* 移动端用户操作 */
.mobile-user-info {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--neutral-50);
margin-bottom: var(--space-4);
}
.user-details {
flex: 1;
display: flex;
flex-direction: column;
}
.user-email {
font-size: var(--font-size-sm);
color: var(--text-light);
}
.mobile-action-buttons {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mobile-auth-actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* 主内容区 */
.main-content {
flex: 1;
margin-top: 72px;
margin-bottom: 70px;
min-height: calc(100vh - 142px);
}
/* 底部导航 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--z-fixed);
background: var(--bg-elevated);
border-top: 1px solid var(--border-light);
display: none;
padding: var(--space-2) var(--space-4);
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-2);
border-radius: var(--radius-md);
color: var(--text-light);
text-decoration: none;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
transition: all var(--duration-150) var(--ease-out);
cursor: pointer;
}
.bottom-nav-item:hover {
color: var(--text-secondary);
}
.bottom-nav-item--active {
color: var(--primary-600);
background: var(--primary-50);
}
.bottom-nav-item__icon {
font-size: var(--font-size-lg);
}
/* 动画 */
.mobile-menu-enter-active,
.mobile-menu-leave-active {
transition: all var(--duration-200) var(--ease-out);
}
.mobile-menu-enter-from,
.mobile-menu-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.navbar__container {
padding: 0 var(--space-4);
}
}
@media (max-width: 768px) {
.navbar__nav {
display: none;
}
.mobile-menu-btn {
display: flex;
}
.bottom-nav {
display: flex;
}
.main-content {
margin-bottom: 70px;
}
.user-name {
display: none;
}
}
@media (max-width: 480px) {
.navbar__container {
padding: 0 var(--space-3);
}
.brand-name {
display: none;
}
.navbar__actions {
gap: var(--space-2);
}
}
</style>

@ -1,287 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '../stores';
import { HomeFilled, User, Document, Message, Setting, ArrowLeft } from '@element-plus/icons-vue';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
// -
const menuItems = [
{ name: 'PersonalHome', title: '个人主页', icon: HomeFilled, path: '/personal/home' },
{ name: 'AccountManager', title: '账号管理', icon: User, path: '/personal/account' },
{ name: 'MyPosts', title: '我的帖子', icon: Document, path: '/personal/posts' },
{ name: 'Messages', title: '消息中心', icon: Message, path: '/personal/messages' },
{ name: 'Settings', title: '设置', icon: Setting, path: '/personal/settings' }
];
//
const activeIndex = ref(0);
//
const setActive = (index: number) => {
activeIndex.value = index;
router.push(menuItems[index].path);
};
//
const backToForum = () => {
router.push('/');
};
//
onMounted(() => {
const currentPath = route.path;
const index = menuItems.findIndex(item => currentPath.includes(item.path));
if (index !== -1) {
activeIndex.value = index;
}
//
if (userStore.isLoggedIn) {
userStore.fetchUserInfo();
} else {
//
router.push({ path: '/login', query: { redirect: route.fullPath } });
}
});
</script>
<template>
<div class="personal-layout">
<!-- 返回论坛按钮 -->
<div class="back-bar">
<button class="back-btn" @click="backToForum">
<el-icon><ArrowLeft /></el-icon>
<span>返回论坛</span>
</button>
<h1 class="page-title">个人中心</h1>
</div>
<div class="layout-container">
<!-- 侧边栏 -->
<div class="sidebar">
<div class="sidebar-header">
<div class="avatar">
<img :src="userStore.userInfo?.avatar || '/images/默认头像.jpg'" alt="用户头像">
</div>
<div class="user-info">
<div class="nickname">{{ userStore.userInfo?.nickname || userStore.userInfo?.username || '用户' }}</div>
<div class="username">{{ userStore.userInfo?.username }}</div>
</div>
</div>
<ul class="menu">
<li
v-for="(item, index) in menuItems"
:key="index"
:class="{ active: activeIndex === index }"
@click="setActive(index)"
>
<div class="menu-item">
<div class="icon">
<el-icon>
<component :is="item.icon"></component>
</el-icon>
</div>
<div class="title">{{ item.title }}</div>
</div>
</li>
</ul>
</div>
<!-- 主内容区 -->
<div class="main-content">
<router-view />
</div>
</div>
</div>
</template>
<style scoped>
.personal-layout {
display: flex;
flex-direction: column;
width: 100%;
min-height: 100vh;
background-color: var(--bg-color);
}
.back-bar {
display: flex;
align-items: center;
padding: 12px 24px;
background-color: var(--secondary-color);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.back-btn {
display: flex;
align-items: center;
padding: 8px 16px;
background-color: transparent;
border: none;
border-radius: var(--border-radius-sm);
color: var(--primary-color);
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition-normal);
}
.back-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.back-btn span {
margin-left: 8px;
}
.page-title {
margin-left: 16px;
font-size: 1.2rem;
color: var(--text-primary);
}
.layout-container {
display: flex;
flex: 1;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 24px;
gap: 24px;
}
.sidebar {
width: 260px;
background-color: white;
border-radius: var(--border-radius-md);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.avatar {
width: 80px;
height: 80px;
border-radius: var(--border-radius-full);
overflow: hidden;
margin-bottom: 12px;
border: 3px solid var(--primary-light);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-info {
text-align: center;
}
.nickname {
color: var(--text-primary);
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 4px;
}
.username {
color: var(--text-secondary);
font-size: 0.9rem;
}
.menu {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px;
}
.menu li {
position: relative;
margin-bottom: 8px;
list-style: none;
cursor: pointer;
}
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: var(--border-radius-md);
transition: all var(--transition-normal);
}
.menu-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.menu li.active .menu-item {
background-color: var(--primary-color);
color: white;
}
.icon {
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
margin-right: 12px;
}
.title {
white-space: nowrap;
font-weight: 500;
}
.main-content {
flex: 1;
background-color: white;
border-radius: var(--border-radius-md);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
overflow-y: auto;
}
@media screen and (max-width: 768px) {
.layout-container {
flex-direction: column;
padding: 16px;
}
.sidebar {
width: 100%;
}
.sidebar-header {
flex-direction: row;
align-items: center;
text-align: left;
}
.avatar {
width: 60px;
height: 60px;
margin-bottom: 0;
margin-right: 16px;
}
.user-info {
text-align: left;
}
}
</style>

@ -1,218 +0,0 @@
<template>
<div class="public-layout">
<!-- 顶部导航栏 -->
<header class="header">
<div class="header-container">
<div class="logo">
<router-link to="/">
<img src="/images/默认头像.jpg" alt="UniLife Logo" class="logo-image" />
<span class="logo-text">UniLife</span>
</router-link>
</div>
<nav class="main-nav">
<router-link to="/" class="nav-item">论坛广场</router-link>
<router-link to="/resources" class="nav-item">学习资源</router-link>
<router-link to="/courses" class="nav-item">课程表</router-link>
</nav>
<div class="user-area">
<template v-if="userStore.isLoggedIn">
<el-dropdown trigger="click">
<div class="user-dropdown-link">
<el-avatar :size="32" :src="userStore.userInfo?.avatar || '/images/默认头像.jpg'" />
<span class="username">{{ userStore.userInfo?.nickname || userStore.userInfo?.username }}</span>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/personal/home')">
<el-icon><User /></el-icon>
</el-dropdown-item>
<el-dropdown-item @click="router.push('/personal/account')">
<el-icon><Setting /></el-icon>
</el-dropdown-item>
<el-dropdown-item @click="router.push('/personal/posts')">
<el-icon><Document /></el-icon>
</el-dropdown-item>
<el-dropdown-item @click="router.push('/personal/messages')">
<el-icon><Message /></el-icon>
</el-dropdown-item>
<el-dropdown-item divided @click="logout">
<el-icon><SwitchButton /></el-icon>退
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<router-link to="/login" class="login-btn">
<el-button type="primary" size="small">登录 / 注册</el-button>
</router-link>
</template>
</div>
</div>
</header>
<!-- 主内容区 -->
<main class="main-content">
<router-view />
</main>
<!-- 页脚 -->
<footer class="footer">
<div class="footer-container">
<p>&copy; {{ new Date().getFullYear() }} UniLife - 有你生活优你生活</p>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '../stores';
import { User, Setting, Document, Message, SwitchButton, ArrowDown } from '@element-plus/icons-vue';
const router = useRouter();
const userStore = useUserStore();
//
onMounted(() => {
if (userStore.isLoggedIn) {
userStore.fetchUserInfo();
}
});
// 退
const logout = () => {
userStore.logout();
router.push('/');
};
</script>
<style scoped>
.public-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.header {
background-color: var(--secondary-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 1000;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
}
.logo {
display: flex;
align-items: center;
}
.logo a {
display: flex;
align-items: center;
text-decoration: none;
color: var(--primary-color);
}
.logo-image {
height: 32px;
margin-right: 8px;
}
.logo-text {
font-size: 1.5rem;
font-weight: bold;
}
.main-nav {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--text-primary);
font-weight: 500;
padding: 8px 0;
position: relative;
}
.nav-item:hover {
color: var(--primary-color);
}
.nav-item.router-link-active {
color: var(--primary-color);
}
.nav-item.router-link-active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: var(--primary-color);
}
.user-area {
display: flex;
align-items: center;
}
.user-dropdown-link {
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.user-dropdown-link:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.username {
margin: 0 8px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-btn {
text-decoration: none;
}
.main-content {
flex: 1;
background-color: var(--bg-color);
padding: 24px 0;
}
.footer {
background-color: var(--secondary-color);
padding: 16px 0;
text-align: center;
color: var(--text-secondary);
}
.footer-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
</style>

@ -1,26 +0,0 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import App from './App.vue';
import router from './router';
// 样式
import 'element-plus/dist/index.css';
import './styles/global.css';
// 创建应用实例
const app = createApp(App);
// 使用插件
app.use(createPinia());
app.use(ElementPlus);
app.use(router);
// 注册所有Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
// 挂载应用
app.mount('#app');

@ -1,189 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import { useUserStore } from '../stores';
// 布局
import MainLayout from '../layouts/MainLayout.vue';
import BaseLayout from '../layouts/BaseLayout.vue';
// 页面
import Login from '../views/Login.vue';
import Home from '../views/Home.vue';
import NotFound from '../views/NotFound.vue';
// 路由配置
const routes: Array<RouteRecordRaw> = [
// 主应用布局 - 使用MainLayout
{
path: '/',
component: MainLayout,
children: [
// 个人主页 - 需要登录
{
path: 'home', // URL: /home
name: 'Home',
component: Home,
meta: { title: '个人主页 - UniLife', requiresAuth: true }
},
// 论坛首页 - 无需登录
{
path: 'forum', // URL: /forum
name: 'Forum',
component: () => import('../views/forum/PostListView.vue'),
meta: { title: '论坛广场 - UniLife', requiresAuth: false }
},
// 帖子详情 - 无需登录
{
path: 'post/:id', // URL: /post/123
name: 'PostDetail',
component: () => import('../views/forum/PostDetailView.vue'),
props: true,
meta: { title: '帖子详情 - UniLife', requiresAuth: false }
},
// 发布帖子 - 需要登录
{
path: 'create-post', // URL: /create-post
name: 'CreatePost',
component: () => import('../views/forum/CreatePostView.vue'),
meta: { title: '发布帖子 - UniLife', requiresAuth: true }
},
// 编辑帖子 - 需要登录
{
path: 'edit-post/:id', // URL: /edit-post/123
name: 'EditPost',
component: () => import('../views/forum/CreatePostView.vue'),
props: true,
meta: { title: '编辑帖子 - UniLife', requiresAuth: true }
},
// 我的帖子 - 需要登录
{
path: 'my-posts', // URL: /my-posts
name: 'MyPosts',
component: () => import('../views/forum/MyPostsView.vue'),
meta: { title: '我的帖子 - UniLife', requiresAuth: true }
},
// 学习资源 - 无需登录
{
path: 'resource', // URL: /resource
name: 'Resources',
component: () => import('../views/resource/ResourceListView.vue'),
meta: { title: '学习资源 - UniLife', requiresAuth: false }
},
// 资源详情 - 无需登录
{
path: 'resource/:id', // URL: /resource/123
name: 'ResourceDetail',
component: () => import('../views/resource/ResourceDetailView.vue'),
props: true,
meta: { title: '资源详情 - UniLife', requiresAuth: false }
},
// 课程表管理 - 需要登录
{
path: 'course-table', // URL: /course-table
name: 'CourseTable',
component: () => import('../views/schedule/CourseTableView.vue'),
meta: { title: '课程表 - UniLife', requiresAuth: true }
},
// 日程管理 - 需要登录
{
path: 'schedule', // URL: /schedule
name: 'Schedule',
component: () => import('../views/schedule/ScheduleView.vue'),
meta: { title: '日程管理 - UniLife', requiresAuth: true }
},
// 搜索页面 - 无需登录
{
path: 'search', // URL: /search
name: 'Search',
component: () => import('../views/SearchView.vue'),
meta: { title: '搜索 - UniLife', requiresAuth: false }
},
// 个人资料 - 需要登录
{
path: 'profile', // URL: /profile
name: 'Profile',
component: () => import('../views/AccountManager.vue'),
meta: { title: '个人资料 - UniLife', requiresAuth: true }
},
// 消息中心 - 需要登录
{
path: 'messages', // URL: /messages
name: 'Messages',
component: () => import('../views/MessagesView.vue'),
meta: { title: '消息中心 - UniLife', requiresAuth: true }
},
// 设置页面 - 需要登录
{
path: 'settings', // URL: /settings
name: 'Settings',
component: () => import('../views/SettingsView.vue'),
meta: { title: '设置 - UniLife', requiresAuth: true }
},
// 通知页面 - 需要登录
{
path: 'notifications', // URL: /notifications
name: 'Notifications',
component: () => import('../views/NotFound.vue'), // 临时使用NotFound页面
meta: { title: '通知 - UniLife', requiresAuth: true }
},
// 根路径重定向
{
path: '', // 网站根路径 /
redirect: (to) => {
const userStore = useUserStore();
return userStore.isLoggedIn ? '/home' : '/forum';
}
}
]
},
// 认证相关页面 - 使用BaseLayout布局
{
path: '/',
component: BaseLayout,
children: [
{
path: 'login', // URL: /login
name: 'Login',
component: Login,
meta: { title: '登录/注册 - UniLife', requiresAuth: false }
}
]
},
// Catch-all 404
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
meta: { title: '页面不存在 - UniLife' }
}
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
});
// 全局前置守卫
router.beforeEach((to, from, next) => {
document.title = to.meta.title as string || 'UniLife学生论坛';
const userStore = useUserStore();
const isLoggedIn = userStore.isLoggedIn;
// 如果路由需要认证但用户未登录
if (to.matched.some(record => record.meta.requiresAuth) && !isLoggedIn) {
next({
name: 'Login',
query: { redirect: to.fullPath } // 保存原始路径用于登录后重定向
});
} else if ((to.name === 'Login') && isLoggedIn) {
// 如果用户已登录但尝试访问登录页面,重定向到首页
next({ path: '/home' });
} else {
// 正常导航
next();
}
});
export default router;

@ -1,5 +0,0 @@
declare module '*.vue' {
import { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

@ -1,7 +0,0 @@
import { useUserStore } from './user';
import { useUIStore } from './ui';
export {
useUserStore,
useUIStore
};

@ -1,243 +0,0 @@
import { defineStore } from 'pinia';
import postApi from '@/api/post';
import type { PostItem, CategoryItem } from '@/api/post';
import { ElMessage } from 'element-plus';
export interface PostState {
posts: PostItem[];
currentPost: PostItem | null;
loading: boolean;
error: string | null;
currentPage: number;
pageSize: number;
totalPosts: number;
totalPages: number;
categories: CategoryItem[];
loadingCategories: boolean;
errorCategories: string | null;
selectedCategoryId: number | null;
// 搜索相关状态
searchKeyword: string | null;
isSearching: boolean;
}
export const usePostStore = defineStore('post', {
state: (): PostState => ({
posts: [],
currentPost: null,
loading: false,
error: null,
currentPage: 1,
pageSize: 10,
totalPosts: 0,
totalPages: 0,
categories: [],
loadingCategories: false,
errorCategories: null,
selectedCategoryId: null,
// 搜索相关状态
searchKeyword: null,
isSearching: false,
}),
actions: {
async fetchPosts(params: { pageNum?: number; pageSize?: number } = {}) {
this.loading = true;
this.error = null;
try {
const pageNum = params.pageNum || this.currentPage;
const pageSize = params.pageSize || this.pageSize;
const categoryId = this.selectedCategoryId; // Use the stored selectedCategoryId
const apiParams: { pageNum: number; pageSize: number; categoryId?: number } = { pageNum, pageSize };
if (categoryId !== null) {
apiParams.categoryId = categoryId;
}
const response = await postApi.getPosts(apiParams);
if (response && response.data && Array.isArray(response.data.list)) {
this.posts = response.data.list;
this.totalPosts = response.data.total;
this.totalPages = response.data.pages;
this.currentPage = response.data.pageNum; // Ensure backend returns this
this.pageSize = response.data.pageSize; // Ensure backend returns this
} else {
console.error('Unexpected response structure for posts:', response);
this.posts = [];
this.totalPosts = 0;
this.totalPages = 0;
// Do not reset currentPage and pageSize here unless intended,
// as they might be needed for subsequent fetches if only list is malformed.
throw new Error('帖子数据格式不正确');
}
} catch (error: any) {
this.error = error.message || '获取帖子列表失败';
// Potentially keep existing posts if fetch fails, or clear them:
// this.posts = [];
// this.totalPosts = 0;
// this.totalPages = 0;
} finally {
this.loading = false;
}
},
async fetchPostDetail(id: number) {
this.loading = true;
this.error = null;
this.currentPost = null;
try {
const response = await postApi.getPostDetail(id);
// The API returns code 200 for success, and post data is directly in response.data
if (response && response.code === 200 && response.data) {
this.currentPost = response.data;
} else {
// Construct a more informative error or use a default
const errorMessage = response?.data?.toString() ? `错误: ${response.data.toString()}` : '获取帖子详情失败';
console.error('Failed to fetch post detail:', response);
throw new Error(errorMessage);
}
} catch (error: any) {
// 检查是否是未登录错误
if (error.response && error.response.status === 401) {
this.error = '您需要登录后才能查看帖子详情';
ElMessage.warning('您需要登录后才能查看帖子详情');
// 将用户重定向到登录页面,并记录需要返回的帖子详情页面
const currentPath = `/post/${id}`;
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
return;
} else {
// 处理其他错误
const errorMsg = error.message || '加载帖子详情时发生未知错误';
this.error = errorMsg;
ElMessage.error(errorMsg);
}
} finally {
this.loading = false;
}
},
async fetchCategories() {
this.loadingCategories = true;
this.errorCategories = null;
try {
const response = await postApi.getCategories();
// response.data is an object like { total: number, list: CategoryItem[] }
// We need to access the 'list' property for the actual categories array.
if (response && response.data && Array.isArray(response.data.list)) {
this.categories = response.data.list;
} else {
// Handle cases where the structure is not as expected, though API seems to return it correctly.
console.error('Unexpected response structure for categories:', response);
this.categories = []; // Default to empty array to prevent further errors
throw new Error('分类数据格式不正确');
}
this.loadingCategories = false;
} catch (error: any) {
this.errorCategories = error.message || '获取分类失败';
} finally {
this.loadingCategories = false;
}
},
async selectCategory(categoryId: number | null) {
if (this.selectedCategoryId !== categoryId) {
this.selectedCategoryId = categoryId;
this.currentPage = 1;
await this.fetchPosts();
}
},
async likePost(postId: number) {
try {
const response = await postApi.likePost(postId);
if (response && response.code === 200) {
// 更新当前帖子的点赞状态
if (this.currentPost && this.currentPost.id === postId) {
if (this.currentPost.isLiked) {
this.currentPost.likeCount -= 1;
this.currentPost.isLiked = false;
ElMessage.success('取消点赞成功');
} else {
this.currentPost.likeCount += 1;
this.currentPost.isLiked = true;
ElMessage.success('点赞成功');
}
}
// 更新帖子列表中的对应帖子
const postInList = this.posts.find(post => post.id === postId);
if (postInList) {
if (postInList.isLiked) {
postInList.likeCount -= 1;
postInList.isLiked = false;
} else {
postInList.likeCount += 1;
postInList.isLiked = true;
}
}
} else {
throw new Error('操作失败');
}
} catch (error: any) {
if (error.response && error.response.status === 401) {
ElMessage.warning('请先登录');
// 可以在这里处理登录跳转
} else {
ElMessage.error(error.message || '操作失败,请稍后重试');
}
}
},
// 搜索帖子
async searchPosts(params: { keyword: string; categoryId?: number | null; pageNum?: number; pageSize?: number }) {
this.loading = true;
this.error = null;
this.searchKeyword = params.keyword;
this.isSearching = true;
try {
const pageNum = params.pageNum || 1;
const pageSize = params.pageSize || this.pageSize;
// 调用搜索API
const response = await postApi.searchPosts({
keyword: params.keyword,
categoryId: params.categoryId,
pageNum,
pageSize
});
if (response && response.data && Array.isArray(response.data.list)) {
this.posts = response.data.list;
this.totalPosts = response.data.total;
this.totalPages = response.data.pages;
this.currentPage = pageNum;
this.pageSize = pageSize;
} else {
console.error('Unexpected response structure for search:', response);
this.posts = [];
this.totalPosts = 0;
this.totalPages = 0;
throw new Error('搜索结果数据格式不正确');
}
} catch (error: any) {
this.error = error.message || '搜索失败';
this.posts = [];
this.totalPosts = 0;
this.totalPages = 0;
} finally {
this.loading = false;
}
},
// 清除搜索状态
clearSearch() {
this.searchKeyword = null;
this.isSearching = false;
}
}
});

@ -1,66 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useUIStore = defineStore('ui', () => {
// 状态
const isMobileView = ref(false);
const isSidebarCollapsed = ref(false);
const isDarkMode = ref(false);
const isLoading = ref(false);
const loadingText = ref('加载中...');
// 检测是否为移动视图
const checkMobileView = () => {
isMobileView.value = window.innerWidth < 768;
};
// 切换侧边栏状态
const toggleSidebar = () => {
isSidebarCollapsed.value = !isSidebarCollapsed.value;
};
// 切换暗黑模式
const toggleDarkMode = () => {
isDarkMode.value = !isDarkMode.value;
// 应用暗黑模式到文档
if (isDarkMode.value) {
document.documentElement.classList.add('dark-mode');
} else {
document.documentElement.classList.remove('dark-mode');
}
};
// 设置加载状态
const setLoading = (loading: boolean, text?: string) => {
isLoading.value = loading;
if (text) {
loadingText.value = text;
}
};
// 初始化
const initialize = () => {
// 检测移动视图
checkMobileView();
window.addEventListener('resize', checkMobileView);
// 检测系统暗黑模式偏好
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDarkMode) {
toggleDarkMode();
}
};
return {
isMobileView,
isSidebarCollapsed,
isDarkMode,
isLoading,
loadingText,
toggleSidebar,
toggleDarkMode,
setLoading,
initialize
};
});

@ -1,206 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import userApi from '../api/user';
import type { UserInfo, UpdatePasswordParams } from '../api/user';
import { ElMessage } from 'element-plus';
export const useUserStore = defineStore('user', () => {
// 状态
const token = ref<string>(localStorage.getItem('token') || '');
const userInfo = ref<UserInfo | null>(null);
const isLoggedIn = ref<boolean>(!!token.value);
const loading = ref<boolean>(false);
// 设置token
const setToken = (newToken: string) => {
token.value = newToken;
localStorage.setItem('token', newToken);
isLoggedIn.value = true;
};
// 清除token
const clearToken = () => {
token.value = '';
localStorage.removeItem('token');
isLoggedIn.value = false;
};
// 登录
const login = async (email: string, password: string) => {
try {
loading.value = true;
const res = await userApi.login({ email, password });
if (res.code === 200 && res.data.token) {
setToken(res.data.token);
ElMessage.success('登录成功');
return true;
}
return false;
} catch (error) {
console.error('登录失败:', error);
return false;
} finally {
loading.value = false;
}
};
// 通过验证码登录
const loginWithCode = async (email: string, code: string) => {
try {
loading.value = true;
const res = await userApi.verifyEmailCode({ email, code });
if (res.code === 200 && res.data.token) {
setToken(res.data.token);
ElMessage.success('登录成功');
return true;
}
return false;
} catch (error) {
console.error('登录失败:', error);
return false;
} finally {
loading.value = false;
}
};
// 注册
const register = async (email: string, password: string, code: string) => {
try {
loading.value = true;
const res = await userApi.register({ email, password, code });
if (res.code === 200 && res.data.token) {
setToken(res.data.token);
ElMessage.success('注册成功');
return true;
}
return false;
} catch (error) {
console.error('注册失败:', error);
return false;
} finally {
loading.value = false;
}
};
// 登出
const logout = () => {
clearToken();
userInfo.value = null;
ElMessage.success('已退出登录');
};
// 获取用户信息
const fetchUserInfo = async () => {
if (!token.value) return null;
try {
loading.value = true;
const res = await userApi.getUserInfo();
if (res.code === 200) {
userInfo.value = res.data;
return res.data;
}
return null;
} catch (error) {
console.error('获取用户信息失败:', error);
return null;
} finally {
loading.value = false;
}
};
// 更新用户资料
const updateProfile = async (data: {
username?: string;
bio?: string;
gender?: number;
department?: string;
major?: string;
grade?: string;
}) => {
try {
loading.value = true;
const params: any = {};
if (data.username !== undefined) params.username = data.username;
if (data.bio !== undefined) params.bio = data.bio;
if (data.gender !== undefined) params.gender = data.gender;
if (data.department !== undefined) params.department = data.department;
if (data.major !== undefined) params.major = data.major;
if (data.grade !== undefined) params.grade = data.grade;
const res = await userApi.updateProfile(params);
if (res.code === 200) {
// 更新本地用户信息
if (userInfo.value) {
if (data.username !== undefined) userInfo.value.username = data.username;
if (data.bio !== undefined) userInfo.value.bio = data.bio;
if (data.gender !== undefined) userInfo.value.gender = data.gender;
if (data.department !== undefined) userInfo.value.department = data.department;
if (data.major !== undefined) userInfo.value.major = data.major;
if (data.grade !== undefined) userInfo.value.grade = data.grade;
}
ElMessage.success('个人资料更新成功');
return true;
}
return false;
} catch (error) {
console.error('更新个人资料失败:', error);
return false;
} finally {
loading.value = false;
}
};
// 更新用户密码
const updatePassword = async (data: {
newPassword?: string;
code?: string;
}) => {
try {
loading.value = true;
const params: Partial<UpdatePasswordParams> = {};
if (data.newPassword) params.newPassword = data.newPassword;
if (data.code) params.code = data.code;
const res = await userApi.updatePassword(params as UpdatePasswordParams);
if (res.code === 200) {
ElMessage.success('密码修改成功');
return true;
}
return false;
} catch (error) {
console.error('修改密码失败:', error);
return false;
} finally {
loading.value = false;
}
};
return {
token,
userInfo,
isLoggedIn,
loading,
login,
loginWithCode,
register,
logout,
fetchUserInfo,
updateProfile,
updatePassword
};
});

@ -1,469 +0,0 @@
@import './variables.css';
@import './reset.css';
/* 现代化全局样式 - 2025 UI趋势 */
body {
font-family: var(--font-family-base);
line-height: var(--line-height-normal);
font-weight: var(--font-weight-normal);
color: var(--text-primary);
background: var(--bg-secondary);
min-height: 100vh;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
#app {
width: 100%;
min-height: 100vh;
margin: 0;
padding: 0;
position: relative;
}
/* 现代容器设计 */
.container {
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding: var(--space-6);
}
.container-sm {
max-width: var(--content-width-sm);
}
.container-md {
max-width: var(--content-width-md);
}
.container-lg {
max-width: var(--content-width-lg);
}
/* 现代卡片系统 */
.card {
background: var(--bg-elevated);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
padding: var(--space-6);
position: relative;
transition: all var(--duration-200) var(--ease-out);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--primary-200), transparent);
opacity: 0;
transition: opacity var(--duration-200) var(--ease-out);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--border-focus);
}
.card:hover::before {
opacity: 1;
}
.card-interactive {
cursor: pointer;
user-select: none;
}
.card-elevated {
box-shadow: var(--shadow-md);
}
.card-elevated:hover {
box-shadow: var(--shadow-xl);
transform: translateY(-4px);
}
/* 玻璃态效果卡片 */
.card-glass {
background: var(--bg-overlay);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* 现代按钮系统 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: none;
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-5);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
font-family: inherit;
line-height: 1;
cursor: pointer;
user-select: none;
white-space: nowrap;
transition: all var(--duration-150) var(--ease-out);
position: relative;
overflow: hidden;
text-decoration: none;
}
.btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left var(--duration-300) var(--ease-out);
}
.btn:hover::before {
left: 100%;
}
.btn:active {
transform: scale(0.98);
}
.btn:focus-visible {
outline: 2px solid var(--primary-300);
outline-offset: 2px;
}
/* 主要按钮 */
.btn-primary {
background: linear-gradient(135deg, var(--primary-600), var(--primary-500));
color: white;
box-shadow: var(--shadow-primary);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--primary-700), var(--primary-600));
box-shadow: var(--shadow-primary-lg);
transform: translateY(-1px);
}
/* 次要按钮 */
.btn-secondary {
background: var(--bg-elevated);
color: var(--text-secondary);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-xs);
}
.btn-secondary:hover {
background: var(--neutral-50);
border-color: var(--border-focus);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
/* 幽灵按钮 */
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--neutral-100);
color: var(--text-primary);
}
/* 按钮尺寸 */
.btn-sm {
padding: var(--space-2) var(--space-4);
font-size: var(--font-size-xs);
border-radius: var(--radius-md);
}
.btn-lg {
padding: var(--space-4) var(--space-8);
font-size: var(--font-size-md);
border-radius: var(--radius-xl);
}
/* 现代表单样式 */
.form-group {
margin-bottom: var(--space-5);
position: relative;
}
.form-label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
margin-bottom: var(--space-2);
line-height: var(--line-height-snug);
}
.form-input {
width: 100%;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
color: var(--text-primary);
font-size: var(--font-size-sm);
font-family: inherit;
line-height: var(--line-height-normal);
transition: all var(--duration-150) var(--ease-out);
outline: none;
box-shadow: var(--shadow-xs);
}
.form-input::placeholder {
color: var(--text-light);
}
.form-input:focus {
border-color: var(--primary-300);
box-shadow: 0 0 0 3px rgba(139, 77, 255, 0.1), var(--shadow-sm);
transform: translateY(-1px);
}
.form-input:hover:not(:focus) {
border-color: var(--neutral-300);
}
/* 现代徽章系统 */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: 1;
white-space: nowrap;
}
.badge-primary {
background: var(--primary-100);
color: var(--primary-700);
}
.badge-success {
background: var(--success-50);
color: var(--success-600);
}
.badge-warning {
background: var(--warning-50);
color: var(--warning-600);
}
.badge-error {
background: var(--error-50);
color: var(--error-600);
}
/* 现代分割线 */
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-color), transparent);
border: none;
margin: var(--space-6) 0;
}
.divider-vertical {
width: 1px;
height: auto;
background: linear-gradient(180deg, transparent, var(--border-color), transparent);
margin: 0 var(--space-4);
}
/* 响应式网格 */
.grid {
display: grid;
gap: var(--space-6);
}
.grid-cols-1 { grid-template-columns: repeat(1, 1fr); }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
/* Flexbox 工具类 */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-start { justify-content: flex-start; }
.justify-end { justify-content: flex-end; }
/* 间距工具类 */
.gap-1 { gap: var(--space-1); }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
.gap-8 { gap: var(--space-8); }
/* 文本工具类 */
.text-xs { font-size: var(--font-size-xs); }
.text-sm { font-size: var(--font-size-sm); }
.text-base { font-size: var(--font-size-md); }
.text-lg { font-size: var(--font-size-lg); }
.text-xl { font-size: var(--font-size-xl); }
.text-2xl { font-size: var(--font-size-2xl); }
.text-3xl { font-size: var(--font-size-3xl); }
.font-normal { font-weight: var(--font-weight-normal); }
.font-medium { font-weight: var(--font-weight-medium); }
.font-semibold { font-weight: var(--font-weight-semibold); }
.font-bold { font-weight: var(--font-weight-bold); }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-light { color: var(--text-light); }
.text-accent { color: var(--text-accent); }
/* 现代动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes shimmer {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.animate-fade-in {
animation: fadeIn var(--duration-300) var(--ease-out) both;
}
.animate-slide-up {
animation: slideInUp var(--duration-300) var(--ease-out) both;
}
.animate-scale-in {
animation: scaleIn var(--duration-200) var(--ease-out) both;
}
.animate-shimmer {
background: linear-gradient(90deg, var(--neutral-100) 25%, var(--neutral-50) 50%, var(--neutral-100) 75%);
background-size: 200px 100%;
animation: shimmer 1.5s infinite;
}
/* 响应式断点 */
@media (max-width: 640px) {
.container {
padding: var(--space-4);
}
.grid-cols-4 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(1, 1fr); }
.grid-cols-2 { grid-template-columns: repeat(1, 1fr); }
.btn {
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
}
.btn-lg {
padding: var(--space-4) var(--space-6);
}
}
@media (max-width: 768px) {
.grid-cols-4 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 1024px) {
.container {
padding: var(--space-5);
}
}
/* 性能优化 */
.gpu-accelerated {
transform: translateZ(0);
will-change: transform;
}
/* 辅助功能增强 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 焦点指示器 */
.focus-visible {
outline: 2px solid var(--primary-300);
outline-offset: 2px;
}
/* 减动画偏好支持 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

@ -1,44 +0,0 @@
/* CSS Reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
a {
text-decoration: none;
color: inherit;
}
ul, ol {
list-style: none;
}
button {
background: none;
border: none;
cursor: pointer;
}

@ -1,196 +0,0 @@
/* 设计系统变量 - 2025现代UI趋势 */
/* 新的色彩系统 - 采用更现代的渐变和深度 */
:root {
/* 主色调 - 渐变式品牌色 */
--primary-50: #faf7ff;
--primary-100: #f3eeff;
--primary-200: #e9ddff;
--primary-300: #d4bfff;
--primary-400: #bc96ff;
--primary-500: #9c69ff;
--primary-600: #8b4dff;
--primary-700: #7b3fff;
--primary-800: #6236c7;
--primary-900: #4c2a99;
/* 辅助色 - 现代中性色调 */
--neutral-25: #fcfcfc;
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
--neutral-200: #e5e5e5;
--neutral-300: #d4d4d4;
--neutral-400: #a3a3a3;
--neutral-500: #737373;
--neutral-600: #525252;
--neutral-700: #404040;
--neutral-800: #262626;
--neutral-900: #171717;
/* 成功/错误/警告色 - 柔和现代 */
--success-50: #f0fdf4;
--success-500: #22c55e;
--success-600: #16a34a;
--error-50: #fef2f2;
--error-500: #ef4444;
--error-600: #dc2626;
--warning-50: #fffbeb;
--warning-500: #f59e0b;
--warning-600: #d97706;
/* 主要变量映射 */
--primary-color: var(--primary-600);
--primary-light: var(--primary-500);
--primary-dark: var(--primary-700);
--secondary-color: var(--neutral-100);
/* 背景色 - 层次化设计 */
--bg-primary: #ffffff;
--bg-secondary: var(--neutral-50);
--bg-elevated: #ffffff;
--bg-overlay: rgba(255, 255, 255, 0.95);
/* 文字颜色 */
--text-primary: var(--neutral-900);
--text-secondary: var(--neutral-700);
--text-light: var(--neutral-500);
--text-accent: var(--primary-600);
/* 边框 */
--border-color: var(--neutral-200);
--border-light: var(--neutral-100);
--border-focus: var(--primary-300);
/* 现代化圆角系统 */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--radius-full: 9999px;
/* 兼容旧变量 */
--border-radius-sm: var(--radius-sm);
--border-radius-md: var(--radius-md);
--border-radius-lg: var(--radius-lg);
--border-radius-full: var(--radius-full);
/* 现代化间距系统 */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
/* 兼容旧间距变量 */
--spacing-xs: var(--space-1);
--spacing-sm: var(--space-3);
--spacing-md: var(--space-4);
--spacing-lg: var(--space-6);
--spacing-xl: var(--space-8);
--spacing-xxl: var(--space-12);
/* 新的阴影系统 - 更现代的层次感 */
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
/* 彩色阴影 - 品牌特色 */
--shadow-primary: 0 10px 15px -3px rgba(156, 105, 255, 0.1), 0 4px 6px -4px rgba(156, 105, 255, 0.1);
--shadow-primary-lg: 0 20px 25px -5px rgba(156, 105, 255, 0.1), 0 8px 10px -6px rgba(156, 105, 255, 0.1);
/* 字体系统 */
--font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-family-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-size-xxl: var(--font-size-3xl);
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* 线高 */
--line-height-tight: 1.25;
--line-height-snug: 1.375;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
/* 动画与过渡 */
--duration-75: 75ms;
--duration-100: 100ms;
--duration-150: 150ms;
--duration-200: 200ms;
--duration-300: 300ms;
--duration-500: 500ms;
--ease-linear: linear;
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
/* 兼容旧变量 */
--transition-fast: var(--duration-150) var(--ease-out);
--transition-normal: var(--duration-200) var(--ease-in-out);
--transition-slow: var(--duration-300) var(--ease-in-out);
/* 布局 */
--content-max-width: 1200px;
--content-width-sm: 640px;
--content-width-md: 768px;
--content-width-lg: 1024px;
/* Z-index 层级 */
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
--z-toast: 1080;
}
/* 暗色模式变量 */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: var(--neutral-900);
--bg-secondary: var(--neutral-800);
--bg-elevated: var(--neutral-800);
--bg-overlay: rgba(23, 23, 23, 0.95);
--text-primary: var(--neutral-100);
--text-secondary: var(--neutral-300);
--text-light: var(--neutral-400);
--border-color: var(--neutral-700);
--border-light: var(--neutral-800);
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
}
}

@ -1,493 +0,0 @@
<template>
<div class="account-manager-container">
<el-row :gutter="24">
<el-col :xs="24" :sm="24" :md="10" :lg="10" :xl="10">
<el-card class="profile-section" shadow="hover">
<template #header>
<div class="card-header">
<span>个人资料</span>
<el-button
type="primary"
link
@click="toggleProfileEdit"
>
{{ isProfileEditable ? '取消' : '编辑' }}
</el-button>
</div>
</template>
<div class="avatar-section">
<div
class="avatar-container"
@mouseenter="handleAvatarHover(true)"
@mouseleave="handleAvatarHover(false)"
@click="openAvatarDialog"
>
<img :src="avatarUrl" alt="User Avatar" class="avatar-image">
<div v-if="isAvatarHovered" class="avatar-overlay">
<el-icon><Camera /></el-icon>
<span>修改头像</span>
</div>
</div>
</div>
<el-form
:model="profileForm"
ref="profileFormRef"
label-width="80px"
:disabled="!isProfileEditable"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="profileForm.username" :disabled="!isProfileEditable" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="profileForm.gender">
<el-radio v-for="option in genderOptions" :key="option.value" :value="option.value">
{{ option.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="个人简介" prop="bio">
<el-input v-model="profileForm.bio" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="学院" prop="department">
<el-input v-model="profileForm.department" />
</el-form-item>
<el-form-item label="专业" prop="major">
<el-input v-model="profileForm.major" />
</el-form-item>
<el-form-item label="年级" prop="grade">
<el-input v-model="profileForm.grade" />
</el-form-item>
</el-form>
<div class="profile-actions" v-if="isProfileEditable">
<el-button type="primary" @click="submitProfile"></el-button>
<el-button @click="toggleProfileEdit"></el-button>
</div>
<div v-else>
<el-button type="primary" @click="toggleProfileEdit"></el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="14" :lg="14" :xl="14">
<el-card class="account-info-section" shadow="hover" style="margin-bottom: 20px;">
<template #header>
<div class="card-header">
<span>账号信息</span>
</div>
</template>
<div v-if="userStore.userInfo" class="account-info-details">
<p><strong>用户ID:</strong> {{ userStore.userInfo.id }}</p>
<p><strong>当前用户名:</strong> {{ userStore.userInfo.username }}</p>
<p><strong>邮箱:</strong> {{ userStore.userInfo.email }}</p>
<p><strong>学号:</strong> {{ userStore.userInfo.studentId || '未设置' }}</p>
<p><strong>学院:</strong> {{ userStore.userInfo.department || '未设置' }}</p>
<p><strong>专业:</strong> {{ userStore.userInfo.major || '未设置' }}</p>
<p><strong>年级:</strong> {{ userStore.userInfo.grade || '未设置' }}</p>
<p><strong>积分:</strong> {{ userStore.userInfo.points ?? 'N/A' }}</p>
<p><strong>角色:</strong> {{ userStore.userInfo.role === 1 ? '管理员' : '普通用户' }}</p>
<p><strong>邮箱已验证:</strong> {{ userStore.userInfo.isVerified === 1 ? '是' : '否' }}</p>
</div>
</el-card>
<el-card class="password-section" shadow="hover">
<template #header>
<div class="card-header">
<span>密码修改</span>
<el-button
type="primary"
link
@click="togglePasswordEdit"
>
{{ isPasswordEditable ? '取消' : '修改密码' }}
</el-button>
</div>
</template>
<el-form :model="passwordForm" label-width="80px" :disabled="!isPasswordEditable">
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordForm.newPassword" type="password" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordForm.confirmPassword" type="password" />
</el-form-item>
<el-form-item label="验证码" prop="code">
<el-input v-model="passwordForm.code" />
<el-button
type="primary"
@click="handleSendCode"
:disabled="countdown > 0"
>
{{ codeButtonText }}
</el-button>
</el-form-item>
</el-form>
<div class="password-actions" v-if="isPasswordEditable">
<el-button type="primary" @click="submitPassword"></el-button>
<el-button @click="togglePasswordEdit"></el-button>
</div>
</el-card>
</el-col>
</el-row>
<!-- 头像上传对话框 -->
<el-dialog
v-model="isAvatarDialogVisible"
title="更换头像"
width="400px"
>
<div class="avatar-upload-container">
<div class="avatar-preview-container">
<img
v-if="avatarPreviewUrl"
:src="avatarPreviewUrl"
class="avatar-preview"
>
<div v-else class="avatar-placeholder">
<el-icon><Plus /></el-icon>
<span>选择图片</span>
</div>
</div>
<input
type="file"
accept="image/*"
@change="handleAvatarChange"
class="avatar-input"
id="avatar-input"
>
<label for="avatar-input" class="btn btn-primary">选择图片</label>
</div>
<template #footer>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="isAvatarDialogVisible = false">取消</button>
<button class="btn btn-primary" @click="uploadAvatar" :disabled="!avatarPreviewUrl">上传</button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { useForm } from 'vee-validate';
import { ElMessage } from 'element-plus';
import { useUserStore } from '../stores';
import { useEmailCode } from '../hooks/useEmailCode';
import { Camera, Plus } from '@element-plus/icons-vue';
const userStore = useUserStore();
const { sendEmailCode, countdown } = useEmailCode();
//
const isProfileEditable = ref(false);
const isPasswordEditable = ref(false);
//
const profileForm = reactive({
username: '',
gender: 0,
bio: '',
department: '',
major: '',
grade: '',
});
//
const passwordForm = reactive({
newPassword: '',
confirmPassword: '',
code: '',
});
//
const avatarUrl = ref('');
const isAvatarHovered = ref(false);
const isAvatarDialogVisible = ref(false);
const avatarPreviewUrl = ref('');
const selectedAvatarFile = ref<File | null>(null);
//
const genderOptions = [
{ label: '男', value: 1 },
{ label: '女', value: 2 },
{ label: '保密', value: 0 }
];
//
const { handleSubmit: handleProfileSubmit, setValues: setProfileValues } = useForm({
initialValues: profileForm
});
const { handleSubmit: handlePasswordSubmit } = useForm({
initialValues: passwordForm
});
//
const fetchUserInfo = async () => {
const userInfo = await userStore.fetchUserInfo();
if (userInfo) {
profileForm.username = userInfo.username || '';
profileForm.gender = userInfo.gender || 0;
profileForm.bio = userInfo.bio || '';
profileForm.department = userInfo.department || '';
profileForm.major = userInfo.major || '';
profileForm.grade = userInfo.grade || '';
avatarUrl.value = userInfo.avatar || '/images/默认头像.jpg';
}
};
//
const toggleProfileEdit = () => {
if (isProfileEditable.value) {
//
const userInfo = userStore.userInfo;
if (userInfo) {
profileForm.username = userInfo.username || '';
profileForm.gender = userInfo.gender || 0;
profileForm.bio = userInfo.bio || '';
profileForm.department = userInfo.department || '';
profileForm.major = userInfo.major || '';
profileForm.grade = userInfo.grade || '';
}
}
isProfileEditable.value = !isProfileEditable.value;
};
//
const togglePasswordEdit = () => {
if (isPasswordEditable.value) {
//
passwordForm.newPassword = '';
passwordForm.confirmPassword = '';
passwordForm.code = '';
}
isPasswordEditable.value = !isPasswordEditable.value;
};
//
const submitProfile = handleProfileSubmit(async (values) => {
console.log('Submitting profile with values:', values);
try {
const dataToSubmit = {
username: profileForm.username,
bio: profileForm.bio,
gender: profileForm.gender,
department: profileForm.department,
major: profileForm.major,
grade: profileForm.grade,
};
const success = await userStore.updateProfile(dataToSubmit);
if (success) {
isProfileEditable.value = false;
// ElMessage.success(''); // Success message is now handled in the store
} else {
// General error message is handled in the store, but we might want specific handling here if needed
// ElMessage.error('');
}
} catch (error: any) {
if (error.response && error.response.status === 409) {
ElMessage.error(error.response.data.message || '用户名已被占用,请选择其他用户名');
} else if (error.response && error.response.data && error.response.data.message) {
ElMessage.error('更新失败: ' + error.response.data.message);
} else {
ElMessage.error('个人资料更新失败,请稍后再试');
}
console.error('Error updating profile:', error);
}
});
//
const submitPassword = handlePasswordSubmit(async (values) => {
console.log('Submitting password change with passwordForm state:', passwordForm);
try {
const dataToSubmit = {
newPassword: passwordForm.newPassword,
code: passwordForm.code
};
const success = await userStore.updatePassword(dataToSubmit);
if (success) {
isPasswordEditable.value = false;
passwordForm.newPassword = '';
passwordForm.confirmPassword = '';
passwordForm.code = '';
// ElMessage.success(''); // Handled in store
} else {
// ElMessage.error(''); // General error, store might provide specifics
}
} catch (error: any) {
if (error.response && error.response.data && error.response.data.message) {
ElMessage.error('更新失败: ' + error.response.data.message);
} else {
ElMessage.error('密码更新失败,请稍后再试');
}
console.error('Error updating password:', error);
}
});
//
const handleSendCode = async () => {
if (!userStore.userInfo?.email) {
ElMessage.warning('无法获取邮箱地址');
return;
}
await sendEmailCode(userStore.userInfo.email);
};
//
const handleAvatarHover = (hovered: boolean) => {
isAvatarHovered.value = hovered;
};
const openAvatarDialog = () => {
isAvatarDialogVisible.value = true;
};
const handleAvatarChange = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
const file = input.files[0];
selectedAvatarFile.value = file;
const reader = new FileReader();
reader.onload = (e) => {
avatarPreviewUrl.value = e.target?.result as string;
};
reader.readAsDataURL(file);
}
};
const uploadAvatar = async () => {
if (!selectedAvatarFile.value) {
ElMessage.warning('请先选择头像');
return;
}
try {
const formData = new FormData();
formData.append('avatar', selectedAvatarFile.value);
// API
// const res = await userApi.uploadAvatar(formData);
//
ElMessage.success('头像上传成功');
avatarUrl.value = avatarPreviewUrl.value;
isAvatarDialogVisible.value = false;
selectedAvatarFile.value = null;
avatarPreviewUrl.value = '';
} catch (error) {
console.error('上传头像失败:', error);
ElMessage.error('上传头像失败');
}
};
//
const codeButtonText = computed(() => {
return countdown.value > 0 ? `${countdown.value}秒后重新获取` : '获取验证码';
});
onMounted(() => {
fetchUserInfo();
});
</script>
<style scoped>
.account-manager-container {
padding: 20px;
max-width: 1200px;
margin: auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.1rem;
font-weight: bold;
}
.avatar-section {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.avatar-container {
width: 120px;
height: 120px;
border-radius: var(--border-radius-full);
overflow: hidden;
position: relative;
cursor: pointer;
border: 3px solid var(--primary-light);
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: white;
}
.avatar-overlay .el-icon {
font-size: 24px;
margin-bottom: var(--spacing-xs);
}
.profile-actions,
.password-actions {
margin-top: 20px;
text-align: right;
}
.account-info-details p {
margin-bottom: 10px;
font-size: 0.95rem;
}
.account-info-details strong {
margin-right: 8px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.el-col {
margin-bottom: 20px;
}
.profile-actions,
.password-actions {
text-align: center;
}
.el-form-item {
margin-bottom: 15px;
}
.el-card {
margin-bottom: 20px;
}
.el-col:last-child .el-card:last-child {
margin-bottom: 0; /* Remove margin from the very last card on mobile */
}
}
</style>

@ -1,813 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '../stores';
import userApi from '../api/user';
import type { UserStats } from '../api/user';
import { ElMessage, ElSkeleton, ElEmpty, ElIcon, ElButton, ElCard, ElDivider, ElAvatar, ElBadge } from 'element-plus';
import { Document, Star, ChatDotRound, View, Edit, Delete, Plus, TrendCharts, Trophy, Timer } from '@element-plus/icons-vue';
const router = useRouter();
const userStore = useUserStore();
//
const loading = ref(true);
const statsLoading = ref(true);
const postsLoading = ref(true);
const error = ref<string | null>(null);
//
const userPosts = ref<any[]>([]);
//
const userStats = ref<UserStats>({
totalPosts: 0,
totalLikes: 0,
totalComments: 0,
totalViews: 0
});
//
const fetchUserStats = async () => {
try {
statsLoading.value = true;
const response = await userApi.getUserStats();
if (response && response.code === 200 && response.data) {
userStats.value = response.data;
}
} catch (err: any) {
console.error('获取统计数据失败:', err);
} finally {
statsLoading.value = false;
}
};
//
const fetchRecentPosts = async () => {
try {
postsLoading.value = true;
const response = await userApi.getUserRecentPosts(5);
if (response && response.code === 200 && response.data) {
userPosts.value = response.data.list;
}
} catch (err: any) {
console.error('获取最近帖子失败:', err);
userPosts.value = [];
} finally {
postsLoading.value = false;
}
};
//
const formatDate = (dateString: string) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return '今天';
if (days === 1) return '昨天';
if (days < 7) return `${days}天前`;
return date.toLocaleDateString();
};
//
const goToPost = (postId: number) => {
router.push(`/post/${postId}`);
};
//
const editPost = (postId: number) => {
router.push(`/edit-post/${postId}`);
};
//
const deletePost = async (postId: number) => {
try {
ElMessage.success('删除成功');
await fetchRecentPosts();
} catch (err: any) {
ElMessage.error('删除失败');
}
};
//
const createNewPost = () => {
router.push('/create-post');
};
//
const getUserInitial = () => {
return userStore.userInfo?.nickname?.charAt(0) || userStore.userInfo?.username?.charAt(0) || 'U';
};
onMounted(async () => {
try {
loading.value = true;
if (!userStore.userInfo) {
await userStore.fetchUserInfo();
}
await Promise.all([fetchUserStats(), fetchRecentPosts()]);
} catch (err: any) {
error.value = '加载数据失败';
ElMessage.error('加载数据失败,请稍后重试');
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="home-page">
<!-- 欢迎区域 -->
<section class="hero-section">
<div class="hero-content">
<div class="user-welcome">
<el-avatar
:size="80"
:src="userStore.userInfo?.avatar"
class="user-avatar animate-scale-in"
>
{{ getUserInitial() }}
</el-avatar>
<div class="welcome-text">
<h1 class="welcome-title animate-fade-in">
欢迎回来{{ userStore.userInfo?.nickname || userStore.userInfo?.username || '用户' }}
</h1>
<p class="welcome-subtitle animate-slide-up">
今天也要加油哦 继续你的学习之旅吧
</p>
</div>
</div>
<div class="quick-actions animate-slide-up">
<el-button
type="primary"
:icon="Plus"
size="large"
@click="createNewPost"
class="action-button"
>
发布帖子
</el-button>
<el-button
:icon="ChatDotRound"
size="large"
@click="router.push('/forum')"
class="action-button"
>
浏览论坛
</el-button>
</div>
</div>
<!-- 装饰性背景 -->
<div class="hero-decoration">
<div class="decoration-circle decoration-circle--1"></div>
<div class="decoration-circle decoration-circle--2"></div>
<div class="decoration-circle decoration-circle--3"></div>
</div>
</section>
<!-- 统计数据 -->
<section class="stats-section">
<h2 class="section-title">数据概览</h2>
<div class="stats-grid">
<div class="stat-card animate-fade-in" style="animation-delay: 0.1s">
<div class="stat-header">
<div class="stat-icon stat-icon--primary">
<el-icon><Document /></el-icon>
</div>
<el-badge value="new" v-if="userStats.totalPosts > 0" class="stat-badge">
<span></span>
</el-badge>
</div>
<div class="stat-content">
<el-skeleton :loading="statsLoading" animated>
<template #template>
<el-skeleton-item variant="text" style="width: 40px; height: 32px;" />
<el-skeleton-item variant="text" style="width: 60px; height: 16px;" />
</template>
<template #default>
<div class="stat-value">{{ userStats.totalPosts }}</div>
<div class="stat-label">发布帖子</div>
</template>
</el-skeleton>
</div>
<div class="stat-trend">
<el-icon class="trend-icon trend-up"><TrendCharts /></el-icon>
<span class="trend-text">+12%</span>
</div>
</div>
<div class="stat-card animate-fade-in" style="animation-delay: 0.2s">
<div class="stat-header">
<div class="stat-icon stat-icon--success">
<el-icon><Star /></el-icon>
</div>
</div>
<div class="stat-content">
<el-skeleton :loading="statsLoading" animated>
<template #template>
<el-skeleton-item variant="text" style="width: 40px; height: 32px;" />
<el-skeleton-item variant="text" style="width: 60px; height: 16px;" />
</template>
<template #default>
<div class="stat-value">{{ userStats.totalLikes }}</div>
<div class="stat-label">获得点赞</div>
</template>
</el-skeleton>
</div>
<div class="stat-trend">
<el-icon class="trend-icon trend-up"><TrendCharts /></el-icon>
<span class="trend-text">+24%</span>
</div>
</div>
<div class="stat-card animate-fade-in" style="animation-delay: 0.3s">
<div class="stat-header">
<div class="stat-icon stat-icon--warning">
<el-icon><ChatDotRound /></el-icon>
</div>
</div>
<div class="stat-content">
<el-skeleton :loading="statsLoading" animated>
<template #template>
<el-skeleton-item variant="text" style="width: 40px; height: 32px;" />
<el-skeleton-item variant="text" style="width: 60px; height: 16px;" />
</template>
<template #default>
<div class="stat-value">{{ userStats.totalComments }}</div>
<div class="stat-label">收到评论</div>
</template>
</el-skeleton>
</div>
<div class="stat-trend">
<el-icon class="trend-icon trend-up"><TrendCharts /></el-icon>
<span class="trend-text">+8%</span>
</div>
</div>
<div class="stat-card animate-fade-in" style="animation-delay: 0.4s">
<div class="stat-header">
<div class="stat-icon stat-icon--info">
<el-icon><View /></el-icon>
</div>
</div>
<div class="stat-content">
<el-skeleton :loading="statsLoading" animated>
<template #template>
<el-skeleton-item variant="text" style="width: 40px; height: 32px;" />
<el-skeleton-item variant="text" style="width: 60px; height: 16px;" />
</template>
<template #default>
<div class="stat-value">{{ userStats.totalViews }}</div>
<div class="stat-label">帖子浏览</div>
</template>
</el-skeleton>
</div>
<div class="stat-trend">
<el-icon class="trend-icon trend-up"><TrendCharts /></el-icon>
<span class="trend-text">+18%</span>
</div>
</div>
</div>
</section>
<!-- 最近帖子 -->
<section class="posts-section">
<div class="section-header">
<div class="header-content">
<h2 class="section-title">最近动态</h2>
<p class="section-subtitle">你最近发布的帖子和互动</p>
</div>
<el-button
type="primary"
:icon="Plus"
@click="createNewPost"
class="header-action"
>
发布新帖
</el-button>
</div>
<div class="posts-container">
<el-skeleton :loading="postsLoading" :rows="3" animated class="posts-skeleton" />
<el-empty
v-if="!postsLoading && userPosts.length === 0"
description="还没有发布过帖子"
class="posts-empty"
>
<el-button type="primary" @click="createNewPost"></el-button>
</el-empty>
<div v-if="!postsLoading && userPosts.length > 0" class="posts-list">
<article
v-for="(post, index) in userPosts"
:key="post.id"
class="post-card animate-slide-up"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<div class="post-content" @click="goToPost(post.id)">
<div class="post-header">
<h3 class="post-title">{{ post.title }}</h3>
<div class="post-meta">
<el-icon class="meta-icon"><Timer /></el-icon>
<span class="post-date">{{ formatDate(post.createdAt) }}</span>
</div>
</div>
<p class="post-summary">
{{ post.summary || post.content?.substring(0, 120) + '...' || '暂无摘要' }}
</p>
<div class="post-stats">
<div class="stat-item">
<el-icon class="stat-icon"><Star /></el-icon>
<span class="stat-number">{{ post.likeCount || 0 }}</span>
</div>
<div class="stat-item">
<el-icon class="stat-icon"><ChatDotRound /></el-icon>
<span class="stat-number">{{ post.commentCount || 0 }}</span>
</div>
<div class="stat-item">
<el-icon class="stat-icon"><View /></el-icon>
<span class="stat-number">{{ post.viewCount || 0 }}</span>
</div>
</div>
</div>
<div class="post-actions">
<el-button
link
type="primary"
:icon="Edit"
@click.stop="editPost(post.id)"
class="action-btn"
>
编辑
</el-button>
<el-button
link
type="danger"
:icon="Delete"
@click.stop="deletePost(post.id)"
class="action-btn"
>
删除
</el-button>
</div>
</article>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.home-page {
min-height: 100vh;
background: var(--bg-secondary);
padding: var(--space-6);
}
/* 欢迎区域 */
.hero-section {
position: relative;
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
border-radius: var(--radius-2xl);
padding: var(--space-10) var(--space-8);
margin-bottom: var(--space-8);
overflow: hidden;
}
.hero-content {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
max-width: var(--content-max-width);
margin: 0 auto;
}
.user-welcome {
display: flex;
align-items: center;
gap: var(--space-6);
}
.user-avatar {
box-shadow: var(--shadow-xl);
border: 4px solid white;
}
.welcome-text {
flex: 1;
}
.welcome-title {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0 0 var(--space-2) 0;
background: linear-gradient(135deg, var(--primary-700), var(--primary-500));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-subtitle {
font-size: var(--font-size-lg);
color: var(--text-secondary);
margin: 0;
}
.quick-actions {
display: flex;
gap: var(--space-4);
}
.action-button {
box-shadow: var(--shadow-sm);
transition: all var(--duration-200) var(--ease-out);
}
.action-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* 装饰性背景 */
.hero-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
overflow: hidden;
}
.decoration-circle {
position: absolute;
background: linear-gradient(135deg, var(--primary-300), var(--primary-200));
border-radius: var(--radius-full);
opacity: 0.3;
}
.decoration-circle--1 {
width: 120px;
height: 120px;
top: -60px;
right: 10%;
animation: float 6s ease-in-out infinite;
}
.decoration-circle--2 {
width: 80px;
height: 80px;
bottom: -40px;
left: 20%;
animation: float 4s ease-in-out infinite reverse;
}
.decoration-circle--3 {
width: 160px;
height: 160px;
top: 50%;
right: -80px;
animation: float 8s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
/* 区域标题 */
.section-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0 0 var(--space-2) 0;
}
.section-subtitle {
color: var(--text-secondary);
margin: 0;
}
/* 统计数据区域 */
.stats-section {
margin-bottom: var(--space-8);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-6);
margin-top: var(--space-6);
}
.stat-card {
background: var(--bg-elevated);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
padding: var(--space-6);
position: relative;
transition: all var(--duration-200) var(--ease-out);
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--primary-500), var(--primary-300));
transform: scaleX(0);
transition: transform var(--duration-300) var(--ease-out);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-xl);
}
.stat-card:hover::before {
transform: scaleX(1);
}
.stat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
color: white;
}
.stat-icon--primary { background: linear-gradient(135deg, var(--primary-600), var(--primary-400)); }
.stat-icon--success { background: linear-gradient(135deg, var(--success-600), var(--success-500)); }
.stat-icon--warning { background: linear-gradient(135deg, var(--warning-600), var(--warning-500)); }
.stat-icon--info { background: linear-gradient(135deg, #3b82f6, #60a5fa); }
.stat-content {
margin-bottom: var(--space-3);
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--space-1);
}
.stat-trend {
display: flex;
align-items: center;
gap: var(--space-1);
}
.trend-icon {
font-size: var(--font-size-sm);
}
.trend-up {
color: var(--success-500);
}
.trend-text {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--success-600);
}
/* 帖子区域 */
.posts-section {
margin-bottom: var(--space-8);
}
.section-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: var(--space-6);
}
.header-content {
flex: 1;
}
.header-action {
box-shadow: var(--shadow-sm);
}
.posts-container {
background: var(--bg-elevated);
border-radius: var(--radius-xl);
padding: var(--space-6);
border: 1px solid var(--border-light);
}
.posts-skeleton,
.posts-empty {
padding: var(--space-8);
}
.posts-list {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.post-card {
background: var(--bg-elevated);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: var(--space-5);
transition: all var(--duration-200) var(--ease-out);
position: relative;
overflow: hidden;
}
.post-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--primary-500);
transform: scaleY(0);
transition: transform var(--duration-200) var(--ease-out);
}
.post-card:hover {
box-shadow: var(--shadow-lg);
transform: translateX(4px);
}
.post-card:hover::before {
transform: scaleY(1);
}
.post-content {
cursor: pointer;
margin-bottom: var(--space-4);
}
.post-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.post-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin: 0;
line-height: var(--line-height-snug);
transition: color var(--duration-150) var(--ease-out);
}
.post-content:hover .post-title {
color: var(--primary-600);
}
.post-meta {
display: flex;
align-items: center;
gap: var(--space-1);
color: var(--text-light);
font-size: var(--font-size-sm);
}
.meta-icon {
font-size: var(--font-size-xs);
}
.post-summary {
color: var(--text-secondary);
line-height: var(--line-height-relaxed);
margin: 0 0 var(--space-4) 0;
}
.post-stats {
display: flex;
gap: var(--space-4);
}
.stat-item {
display: flex;
align-items: center;
gap: var(--space-1);
color: var(--text-light);
font-size: var(--font-size-sm);
}
.stat-icon {
font-size: var(--font-size-sm);
}
.post-actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
padding-top: var(--space-3);
border-top: 1px solid var(--border-light);
}
.action-btn {
font-size: var(--font-size-sm);
padding: var(--space-1) var(--space-2);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.hero-content {
flex-direction: column;
gap: var(--space-6);
text-align: center;
}
.user-welcome {
flex-direction: column;
gap: var(--space-4);
}
}
@media (max-width: 768px) {
.home-page {
padding: var(--space-4);
}
.hero-section {
padding: var(--space-6);
}
.stats-grid {
grid-template-columns: 1fr;
gap: var(--space-4);
}
.section-header {
flex-direction: column;
gap: var(--space-4);
align-items: stretch;
}
.quick-actions {
flex-direction: column;
}
.post-header {
flex-direction: column;
gap: var(--space-2);
}
.post-actions {
justify-content: center;
}
}
@media (max-width: 480px) {
.posts-container {
padding: var(--space-4);
}
.welcome-title {
font-size: var(--font-size-2xl);
}
}
</style>
font-size: var(--font-size-2xl);

@ -1,797 +0,0 @@
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage, ElButton, ElInput, ElForm, ElFormItem, ElIcon, ElDivider, ElCard } from 'element-plus';
import { User, Lock, View, Hide, UserFilled, Message } from '@element-plus/icons-vue';
import { useUserStore } from '../stores';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
//
const isLogin = ref(true);
const showPassword = ref(false);
const loading = ref(false);
//
const loginForm = reactive({
username: '',
password: ''
});
//
const registerForm = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
nickname: ''
});
//
const loginRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
]
};
const registerRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '请输入正确的邮箱格式', trigger: 'blur' }
],
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 10, message: '昵称长度为2-10个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
validator: (rule: any, value: string, callback: Function) => {
if (value !== registerForm.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
},
trigger: 'blur'
}
]
};
//
const loginFormRef = ref();
const registerFormRef = ref();
//
const formTitle = computed(() => isLogin.value ? '欢迎回来' : '加入我们');
const submitText = computed(() => isLogin.value ? '登录' : '注册');
const switchText = computed(() => isLogin.value ? '还没有账号?点击注册' : '已有账号?点击登录');
// /
const toggleMode = () => {
isLogin.value = !isLogin.value;
//
if (loginFormRef.value) loginFormRef.value.resetFields();
if (registerFormRef.value) registerFormRef.value.resetFields();
};
//
const togglePasswordVisibility = () => {
showPassword.value = !showPassword.value;
};
//
const handleLogin = async () => {
if (!loginFormRef.value) return;
const valid = await loginFormRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
await userStore.login(loginForm.username, loginForm.password);
ElMessage.success('登录成功!');
// 访
const redirect = route.query.redirect as string;
router.push(redirect || '/home');
} catch (error: any) {
ElMessage.error(error?.message || '登录失败,请重试');
} finally {
loading.value = false;
}
};
//
const handleRegister = async () => {
if (!registerFormRef.value) return;
const valid = await registerFormRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
// registeremailpasswordcode使email
await userStore.register(
registerForm.email,
registerForm.password,
'' //
);
ElMessage.success('注册成功!');
//
isLogin.value = true;
loginForm.username = registerForm.username;
} catch (error: any) {
ElMessage.error(error?.message || '注册失败,请重试');
} finally {
loading.value = false;
}
};
//
const handleSubmit = () => {
if (isLogin.value) {
handleLogin();
} else {
handleRegister();
}
};
//
const quickLogin = () => {
loginForm.username = 'demo';
loginForm.password = '123456';
ElMessage.info('已填入演示账号,点击登录即可体验');
};
onMounted(() => {
//
if (userStore.isLoggedIn) {
router.push('/home');
}
});
</script>
<template>
<div class="login-page">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="decoration-circle decoration-circle--1"></div>
<div class="decoration-circle decoration-circle--2"></div>
<div class="decoration-circle decoration-circle--3"></div>
<div class="decoration-grid"></div>
</div>
<!-- 主内容 -->
<div class="login-container">
<!-- 左侧信息区 -->
<div class="info-section">
<div class="info-content">
<div class="logo">
<div class="logo-icon">
<span class="logo-text">UL</span>
</div>
<h1 class="brand-name">UniLife</h1>
</div>
<div class="info-text">
<h2 class="info-title">连接校园分享生活</h2>
<p class="info-description">
加入UniLife社区与同学们一起交流学习经验分享校园生活共同成长进步
</p>
</div>
<div class="features">
<div class="feature-item">
<div class="feature-icon">
<el-icon><UserFilled /></el-icon>
</div>
<div class="feature-text">
<h3>学术交流</h3>
<p>与同学讨论学术问题分享学习心得</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon">
<el-icon><Message /></el-icon>
</div>
<div class="feature-text">
<h3>校园动态</h3>
<p>获取最新的校园资讯和活动信息</p>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧表单区 -->
<div class="form-section">
<el-card class="form-card" shadow="hover">
<!-- 表单头部 -->
<div class="form-header">
<h2 class="form-title">{{ formTitle }}</h2>
<p class="form-subtitle">
{{ isLogin ? '登录您的账号继续使用' : '创建新账号开始您的校园之旅' }}
</p>
</div>
<!-- 登录表单 -->
<el-form
v-if="isLogin"
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="auth-form"
size="large"
@submit.prevent="handleSubmit"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
:prefix-icon="User"
clearable
class="form-input"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
:prefix-icon="Lock"
clearable
class="form-input"
@keyup.enter="handleSubmit"
>
<template #suffix>
<el-icon @click="togglePasswordVisibility" class="password-toggle">
<View v-if="showPassword" />
<Hide v-else />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-button
type="primary"
:loading="loading"
@click="handleSubmit"
class="submit-btn"
size="large"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form>
<!-- 注册表单 -->
<el-form
v-else
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
class="auth-form"
size="large"
@submit.prevent="handleSubmit"
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="请输入用户名"
:prefix-icon="User"
clearable
class="form-input"
/>
</el-form-item>
<el-form-item prop="nickname">
<el-input
v-model="registerForm.nickname"
placeholder="请输入昵称"
:prefix-icon="UserFilled"
clearable
class="form-input"
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
placeholder="请输入邮箱地址"
:prefix-icon="Message"
clearable
class="form-input"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
:prefix-icon="Lock"
clearable
class="form-input"
>
<template #suffix>
<el-icon @click="togglePasswordVisibility" class="password-toggle">
<View v-if="showPassword" />
<Hide v-else />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
:type="showPassword ? 'text' : 'password'"
placeholder="请再次输入密码"
:prefix-icon="Lock"
clearable
class="form-input"
@keyup.enter="handleSubmit"
/>
</el-form-item>
<el-button
type="primary"
:loading="loading"
@click="handleSubmit"
class="submit-btn"
size="large"
>
{{ loading ? '注册中...' : '注册' }}
</el-button>
</el-form>
<!-- 分割线 -->
<el-divider class="form-divider">
<span class="divider-text">或者</span>
</el-divider>
<!-- 快速登录/切换模式 -->
<div class="form-footer">
<el-button
v-if="isLogin"
@click="quickLogin"
class="quick-login-btn"
size="large"
plain
>
演示账号快速登录
</el-button>
<el-button
@click="toggleMode"
class="switch-mode-btn"
type="primary"
link
size="large"
>
{{ switchText }}
</el-button>
</div>
</el-card>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
position: relative;
overflow: hidden;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.decoration-circle {
position: absolute;
background: linear-gradient(135deg, var(--primary-300), var(--primary-200));
border-radius: var(--radius-full);
opacity: 0.3;
}
.decoration-circle--1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation: float 8s ease-in-out infinite;
}
.decoration-circle--2 {
width: 150px;
height: 150px;
bottom: 15%;
right: 15%;
animation: float 6s ease-in-out infinite reverse;
}
.decoration-circle--3 {
width: 100px;
height: 100px;
top: 60%;
left: 5%;
animation: float 10s ease-in-out infinite;
}
.decoration-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(rgba(139, 77, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(139, 77, 255, 0.05) 1px, transparent 1px);
background-size: 50px 50px;
}
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-30px) rotate(180deg); }
}
/* 主容器 */
.login-container {
display: flex;
max-width: 1000px;
width: 100%;
margin: 0 auto;
padding: var(--space-6);
gap: var(--space-8);
position: relative;
z-index: 2;
}
/* 信息区域 */
.info-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
border-radius: var(--radius-2xl);
margin-right: var(--space-4);
box-shadow: var(--shadow-lg);
}
.info-content {
max-width: 400px;
color: var(--text-primary);
padding: var(--space-8);
}
.logo {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-8);
}
.logo-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-xl);
background: linear-gradient(135deg, var(--primary-600), var(--primary-400));
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-xl);
}
.logo-text {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: white;
}
.brand-name {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
margin: 0;
background: linear-gradient(135deg, var(--primary-600), var(--primary-400));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.info-text {
margin-bottom: var(--space-8);
}
.info-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
margin: 0 0 var(--space-4) 0;
color: var(--text-primary);
}
.info-description {
font-size: var(--font-size-lg);
line-height: var(--line-height-relaxed);
margin: 0;
color: var(--text-secondary);
}
.features {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.feature-item {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--primary-50);
transition: all var(--duration-200) var(--ease-out);
}
.feature-item:hover {
background: var(--primary-100);
transform: translateX(4px);
}
.feature-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--primary-600), var(--primary-400));
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
color: white;
box-shadow: var(--shadow-sm);
}
.feature-text h3 {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
margin: 0 0 var(--space-1) 0;
color: var(--text-primary);
}
.feature-text p {
font-size: var(--font-size-sm);
margin: 0;
color: var(--text-secondary);
}
/* 表单区域 */
.form-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.form-card {
width: 100%;
max-width: 400px;
border-radius: var(--radius-2xl);
border: none;
box-shadow: var(--shadow-2xl);
overflow: hidden;
}
.form-card :deep(.el-card__body) {
padding: var(--space-8);
}
.form-header {
text-align: center;
margin-bottom: var(--space-8);
}
.form-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0 0 var(--space-2) 0;
}
.form-subtitle {
color: var(--text-secondary);
margin: 0;
font-size: var(--font-size-sm);
}
/* 表单样式 */
.auth-form {
margin-bottom: var(--space-6);
}
.form-input :deep(.el-input__wrapper) {
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-4);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
transition: all var(--duration-150) var(--ease-out);
}
.form-input :deep(.el-input__wrapper:hover) {
box-shadow: var(--shadow-md);
border-color: var(--primary-300);
}
.form-input :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 3px rgba(139, 77, 255, 0.1), var(--shadow-md);
border-color: var(--primary-500);
}
.password-toggle {
cursor: pointer;
color: var(--text-light);
transition: color var(--duration-150) var(--ease-out);
}
.password-toggle:hover {
color: var(--primary-500);
}
.submit-btn {
width: 100%;
height: 48px;
border-radius: var(--radius-lg);
font-weight: var(--font-weight-medium);
margin-top: var(--space-4);
box-shadow: var(--shadow-primary);
transition: all var(--duration-150) var(--ease-out);
}
.submit-btn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-primary-lg);
}
/* 分割线 */
.form-divider {
margin: var(--space-6) 0;
}
.divider-text {
color: var(--text-light);
font-size: var(--font-size-sm);
padding: 0 var(--space-3);
background: var(--bg-elevated);
}
/* 表单底部 */
.form-footer {
display: flex;
flex-direction: column;
gap: var(--space-3);
align-items: center;
}
.quick-login-btn {
width: 100%;
height: 44px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
color: var(--text-secondary);
transition: all var(--duration-150) var(--ease-out);
}
.quick-login-btn:hover {
border-color: var(--primary-300);
color: var(--primary-600);
background: var(--primary-50);
}
.switch-mode-btn {
font-size: var(--font-size-sm);
transition: all var(--duration-150) var(--ease-out);
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-container {
flex-direction: column;
padding: var(--space-4);
gap: var(--space-6);
}
.info-section {
margin-right: 0;
margin-bottom: var(--space-4);
}
.info-content {
padding: var(--space-6);
text-align: center;
}
.logo {
justify-content: center;
}
.features {
gap: var(--space-4);
}
.feature-item {
padding: var(--space-3);
}
.form-card {
max-width: none;
}
.form-card :deep(.el-card__body) {
padding: var(--space-6);
}
}
@media (max-width: 480px) {
.login-page {
padding: var(--space-4);
}
.info-content {
padding: var(--space-4);
}
.features {
flex-direction: column;
gap: var(--space-3);
}
.feature-item {
flex-direction: column;
text-align: center;
gap: var(--space-2);
}
.form-card :deep(.el-card__body) {
padding: var(--space-4);
}
.form-header {
margin-bottom: var(--space-6);
}
.form-title {
font-size: var(--font-size-xl);
}
}
</style>

@ -1,313 +0,0 @@
<template>
<div class="messages-view">
<div class="page-header">
<h1>消息中心</h1>
<p>查看你的所有通知和私信</p>
</div>
<!-- 消息分类 -->
<div class="message-tabs">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="系统通知" name="system">
<div class="message-list">
<el-skeleton :loading="loading" :rows="3" animated />
<el-empty v-if="!loading && systemMessages.length === 0" description="暂无系统通知">
<el-button type="primary" @click="refreshMessages"></el-button>
</el-empty>
<div v-if="!loading && systemMessages.length > 0">
<div v-for="message in systemMessages" :key="message.id" class="message-item">
<div class="message-header">
<div class="message-title">{{ message.title }}</div>
<div class="message-time">{{ formatTime(message.createdAt) }}</div>
</div>
<div class="message-content">{{ message.content }}</div>
<div class="message-actions" v-if="!message.isRead">
<el-button size="small" @click="markAsRead(message.id)"></el-button>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="评论回复" name="comments">
<div class="message-list">
<el-skeleton :loading="loading" :rows="3" animated />
<el-empty v-if="!loading && commentMessages.length === 0" description="暂无评论回复">
<el-button type="primary" @click="refreshMessages"></el-button>
</el-empty>
<div v-if="!loading && commentMessages.length > 0">
<div v-for="message in commentMessages" :key="message.id" class="message-item">
<div class="message-header">
<div class="message-title">{{ message.nickname }} 回复了你的帖子</div>
<div class="message-time">{{ formatTime(message.createdAt) }}</div>
</div>
<div class="message-content">{{ message.content }}</div>
<div class="message-actions">
<el-button size="small" type="primary" @click="goToPost(message.postId)"></el-button>
<el-button size="small" @click="markAsRead(message.id)" v-if="!message.isRead"></el-button>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="点赞通知" name="likes">
<div class="message-list">
<el-skeleton :loading="loading" :rows="3" animated />
<el-empty v-if="!loading && likeMessages.length === 0" description="暂无点赞通知">
<el-button type="primary" @click="refreshMessages"></el-button>
</el-empty>
<div v-if="!loading && likeMessages.length > 0">
<div v-for="message in likeMessages" :key="message.id" class="message-item">
<div class="message-header">
<div class="message-title">{{ message.nickname }} 点赞了你的帖子</div>
<div class="message-time">{{ formatTime(message.createdAt) }}</div>
</div>
<div class="message-content">{{ message.postTitle }}</div>
<div class="message-actions">
<el-button size="small" type="primary" @click="goToPost(message.postId)"></el-button>
<el-button size="small" @click="markAsRead(message.id)" v-if="!message.isRead"></el-button>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 操作按钮 -->
<div class="message-actions-bar">
<el-button @click="markAllAsRead"></el-button>
<el-button @click="clearReadMessages"></el-button>
<el-button type="primary" @click="refreshMessages"></el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElTabs, ElTabPane, ElSkeleton, ElEmpty, ElButton } from 'element-plus';
const router = useRouter();
//
const activeTab = ref('system');
const loading = ref(true);
//
const systemMessages = ref([
{
id: 1,
title: '欢迎使用UniLife',
content: '欢迎来到UniLife这里是你的大学生活助手。',
createdAt: '2023-12-01T10:00:00Z',
isRead: false
},
{
id: 2,
title: '系统维护通知',
content: '系统将于今晚进行维护预计持续2小时。',
createdAt: '2023-12-02T14:30:00Z',
isRead: true
}
]);
const commentMessages = ref([
{
id: 3,
nickname: '张同学',
content: '很有用的分享,谢谢!',
postId: 1,
postTitle: '大学生活经验分享',
createdAt: '2023-12-01T15:20:00Z',
isRead: false
}
]);
const likeMessages = ref([
{
id: 4,
nickname: '李同学',
postId: 1,
postTitle: '大学生活经验分享',
createdAt: '2023-12-01T12:10:00Z',
isRead: false
}
]);
//
const formatTime = (timeString: string) => {
const date = new Date(timeString);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`;
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
//
const handleTabClick = (tab: any) => {
console.log('切换到标签页:', tab.props.name);
//
};
//
const markAsRead = (messageId: number) => {
//
const allMessages = [...systemMessages.value, ...commentMessages.value, ...likeMessages.value];
const message = allMessages.find(msg => msg.id === messageId);
if (message) {
message.isRead = true;
ElMessage.success('已标记为已读');
}
};
//
const markAllAsRead = () => {
systemMessages.value.forEach(msg => msg.isRead = true);
commentMessages.value.forEach(msg => msg.isRead = true);
likeMessages.value.forEach(msg => msg.isRead = true);
ElMessage.success('已全部标记为已读');
};
//
const clearReadMessages = () => {
systemMessages.value = systemMessages.value.filter(msg => !msg.isRead);
commentMessages.value = commentMessages.value.filter(msg => !msg.isRead);
likeMessages.value = likeMessages.value.filter(msg => !msg.isRead);
ElMessage.success('已清除已读消息');
};
//
const refreshMessages = () => {
loading.value = true;
// API
setTimeout(() => {
loading.value = false;
ElMessage.success('刷新成功');
}, 1000);
};
//
const goToPost = (postId: number) => {
router.push(`/post/${postId}`);
};
onMounted(() => {
//
setTimeout(() => {
loading.value = false;
}, 1000);
});
</script>
<style scoped>
.messages-view {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.page-header {
margin-bottom: 30px;
}
.page-header h1 {
font-size: 28px;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.page-header p {
color: var(--el-text-color-secondary);
}
.message-tabs {
margin-bottom: 30px;
}
.message-list {
min-height: 300px;
}
.message-item {
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
margin-bottom: 12px;
background: var(--el-bg-color);
transition: box-shadow 0.2s;
}
.message-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.message-title {
font-weight: 600;
color: var(--el-text-color-primary);
}
.message-time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.message-content {
color: var(--el-text-color-regular);
line-height: 1.6;
margin-bottom: 12px;
}
.message-actions {
display: flex;
gap: 8px;
}
.message-actions-bar {
display: flex;
gap: 12px;
justify-content: center;
padding: 20px 0;
border-top: 1px solid var(--el-border-color-lighter);
}
/* 响应式设计 */
@media (max-width: 768px) {
.messages-view {
padding: 10px;
}
.message-header {
flex-direction: column;
align-items: flex-start;
}
.message-time {
margin-top: 4px;
}
.message-actions-bar {
flex-direction: column;
align-items: center;
}
}
</style>

@ -1,101 +0,0 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
const goHome = () => {
router.push('/');
};
const goBack = () => {
router.back();
};
</script>
<template>
<div class="not-found">
<div class="not-found-content">
<h1>404</h1>
<h2>页面不存在</h2>
<p>抱歉您访问的页面不存在或已被移除</p>
<div class="not-found-actions">
<button class="btn btn-primary" @click="goHome"></button>
<button class="btn btn-secondary" @click="goBack"></button>
</div>
</div>
<div class="not-found-image">
<img src="/images/默认头像.jpg" alt="404">
</div>
</div>
</template>
<style scoped>
.not-found {
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: var(--bg-gradient);
padding: var(--spacing-lg);
}
.not-found-content {
text-align: center;
margin-right: var(--spacing-xl);
}
.not-found-content h1 {
font-size: 120px;
color: var(--primary-color);
margin: 0;
line-height: 1;
}
.not-found-content h2 {
font-size: var(--font-size-xxl);
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
}
.not-found-content p {
font-size: var(--font-size-lg);
color: var(--text-secondary);
margin-bottom: var(--spacing-xl);
}
.not-found-actions {
display: flex;
justify-content: center;
gap: var(--spacing-lg);
}
.not-found-image {
max-width: 400px;
}
.not-found-image img {
width: 100%;
height: auto;
}
/* 响应式设计 */
@media (max-width: 768px) {
.not-found {
flex-direction: column;
}
.not-found-content {
margin-right: 0;
margin-bottom: var(--spacing-xl);
}
.not-found-content h1 {
font-size: 80px;
}
.not-found-image {
max-width: 300px;
}
}
</style>

@ -1,411 +0,0 @@
<template>
<div class="search-page">
<!-- 搜索头部 -->
<div class="search-header">
<div class="search-input-container">
<el-input
v-model="searchKeyword"
size="large"
placeholder="搜索帖子、资源..."
@keyup.enter="handleSearch"
clearable
>
<template #append>
<el-button @click="handleSearch" :loading="loading">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<!-- 搜索过滤器 -->
<div class="search-filters">
<el-radio-group v-model="searchType" @change="handleSearch">
<el-radio-button label="post">帖子</el-radio-button>
<el-radio-button label="resource">资源</el-radio-button>
</el-radio-group>
<el-select v-model="sortBy" placeholder="排序方式" @change="handleSearch" style="width: 120px; margin-left: 10px;">
<el-option label="最新" value="latest" />
<el-option label="热门" value="hot" />
<el-option label="点赞" value="likes" />
</el-select>
<el-select v-model="categoryId" placeholder="选择分类" @change="handleSearch" style="width: 150px; margin-left: 10px;" clearable>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</div>
</div>
<!-- 搜索结果 -->
<div class="search-content" v-loading="loading">
<!-- 搜索统计 -->
<div class="search-stats" v-if="searchResult">
<span>找到 {{ searchResult.total || 0 }} {{ searchType === 'post' ? '帖子' : '资源' }}</span>
<span v-if="searchResult.total" class="search-time">{{ searchResult.pages || 1 }}</span>
</div>
<!-- 帖子搜索结果 -->
<div class="search-results" v-if="searchType === 'post' && searchResult && searchResult.list && searchResult.list.length > 0">
<div
v-for="item in searchResult.list"
:key="item.id"
class="search-item post-item"
@click="handlePostClick(item)"
>
<div class="item-header">
<el-tag type="primary" size="small">帖子</el-tag>
<span class="item-title">{{ item.title }}</span>
</div>
<div class="item-content">
<p class="item-summary">{{ item.summary }}</p>
</div>
<div class="item-meta">
<div class="author-info">
<el-avatar :src="item.avatar" :size="20" />
<span class="author-name">{{ item.nickname }}</span>
</div>
<div class="meta-info">
<span class="category">{{ item.categoryName }}</span>
<span class="create-time">{{ formatTime(item.createdAt) }}</span>
<span class="stats">
<el-icon><StarFilled /></el-icon>{{ item.likeCount }}
<el-icon style="margin-left: 10px;"><View /></el-icon>{{ item.viewCount }}
<el-icon style="margin-left: 10px;"><ChatDotSquare /></el-icon>{{ item.commentCount }}
</span>
</div>
</div>
</div>
</div>
<!-- 资源搜索结果 -->
<div class="search-results" v-else-if="searchType === 'resource' && searchResult && searchResult.list && searchResult.list.length > 0">
<div
v-for="item in searchResult.list"
:key="item.id"
class="search-item resource-item"
@click="handleResourceClick(item)"
>
<div class="item-header">
<el-tag type="success" size="small">资源</el-tag>
<span class="item-title">{{ item.title }}</span>
</div>
<div class="item-content">
<p class="item-summary">{{ item.description }}</p>
</div>
<div class="item-meta">
<div class="author-info">
<el-avatar :src="item.avatar" :size="20" />
<span class="author-name">{{ item.nickname }}</span>
</div>
<div class="meta-info">
<span class="category">{{ item.categoryName }}</span>
<span class="file-info">{{ formatFileSize(item.fileSize) }} · {{ item.fileType }}</span>
<span class="create-time">{{ formatTime(item.createdAt) }}</span>
<span class="stats">
<el-icon><StarFilled /></el-icon>{{ item.likeCount }}
<el-icon style="margin-left: 10px;"><Download /></el-icon>{{ item.downloadCount }}
</span>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-else-if="searchResult && (!searchResult.list || searchResult.list.length === 0)"
:description="`没有找到相关${searchType === 'post' ? '帖子' : '资源'}`"
/>
<!-- 分页 -->
<div class="pagination" v-if="searchResult && (searchResult.total || 0) > 0">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="searchResult.total || 0"
layout="prev, pager, next, jumper"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search, StarFilled, View, ChatDotSquare, Download } from '@element-plus/icons-vue'
import { searchPosts, getCategories } from '@/api/forum'
import resourceApi from '@/api/resource'
const route = useRoute()
const router = useRouter()
//
const loading = ref(false)
const searchKeyword = ref('')
const searchType = ref<'post' | 'resource'>('post')
const sortBy = ref<'latest' | 'hot' | 'likes'>('latest')
const categoryId = ref<number>()
const currentPage = ref(1)
const pageSize = ref(10)
//
const searchResult = ref<any>()
const categories = ref<any[]>([])
//
onMounted(async () => {
// URL
const keyword = route.query.keyword as string
if (keyword) {
searchKeyword.value = keyword
handleSearch()
}
//
await loadCategories()
})
//
watch(() => route.query.keyword, (newKeyword) => {
if (newKeyword) {
searchKeyword.value = newKeyword as string
handleSearch()
}
})
//
const loadCategories = async () => {
try {
const response = await getCategories()
categories.value = response?.data?.list || response || []
} catch (error) {
console.error('获取分类失败:', error)
}
}
//
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
ElMessage.warning('请输入搜索关键词')
return
}
loading.value = true
currentPage.value = 1
try {
let response
if (searchType.value === 'post') {
response = await searchPosts(
searchKeyword.value,
currentPage.value,
pageSize.value,
categoryId.value,
sortBy.value
)
} else {
response = await resourceApi.getResources({
page: currentPage.value,
size: pageSize.value,
categoryId: categoryId.value,
keyword: searchKeyword.value
})
}
//
console.log('API响应:', response)
console.log('响应数据类型:', typeof response)
console.log('response.data:', response.data)
//
searchResult.value = response.data || response
console.log('最终搜索结果:', searchResult.value)
console.log('结果列表:', searchResult.value?.list)
// URL
router.replace({
query: { keyword: searchKeyword.value }
})
} catch (error) {
console.error('搜索失败:', error)
ElMessage.error('搜索失败,请稍后重试')
} finally {
loading.value = false
}
}
//
const handlePageChange = (page: number) => {
currentPage.value = page
handleSearch()
}
//
const handlePostClick = (post: any) => {
router.push(`/forum/post/${post.id}`)
}
//
const handleResourceClick = (resource: any) => {
router.push(`/resources/${resource.id}`)
}
//
const formatTime = (time: string) => {
return new Date(time).toLocaleString()
}
//
const formatFileSize = (size: number) => {
if (size < 1024) return size + 'B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + 'KB'
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + 'MB'
return (size / (1024 * 1024 * 1024)).toFixed(1) + 'GB'
}
</script>
<style scoped>
.search-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.search-header {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.search-input-container {
margin-bottom: 15px;
}
.search-filters {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.search-content {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
min-height: 400px;
}
.search-stats {
padding: 20px 20px 10px;
color: #666;
border-bottom: 1px solid #f0f0f0;
}
.search-results {
padding: 20px;
}
.search-item {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
cursor: pointer;
transition: all 0.3s ease;
}
.search-item:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
.item-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.item-title {
font-size: 16px;
font-weight: 600;
margin-left: 10px;
color: #333;
}
.item-content {
margin-bottom: 15px;
}
.item-summary {
color: #666;
line-height: 1.6;
margin: 0;
}
.item-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
color: #999;
}
.author-info {
display: flex;
align-items: center;
gap: 8px;
}
.meta-info {
display: flex;
align-items: center;
gap: 15px;
}
.stats {
display: flex;
align-items: center;
gap: 5px;
}
.pagination {
padding: 20px;
display: flex;
justify-content: center;
border-top: 1px solid #f0f0f0;
}
@media (max-width: 768px) {
.search-page {
padding: 10px;
}
.search-filters {
flex-direction: column;
align-items: stretch;
}
.item-meta {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>

@ -1,410 +0,0 @@
<template>
<div class="settings-view">
<div class="page-header">
<h1>设置</h1>
<p>管理你的账户偏好和应用设置</p>
</div>
<div class="settings-content">
<!-- 通知设置 -->
<el-card class="settings-card" shadow="hover">
<template #header>
<h3>通知设置</h3>
</template>
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">邮件通知</div>
<div class="setting-desc">接收新评论和点赞的邮件通知</div>
</div>
<el-switch v-model="notificationSettings.email" />
</div>
<el-divider />
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">浏览器通知</div>
<div class="setting-desc">在浏览器中显示实时通知</div>
</div>
<el-switch v-model="notificationSettings.browser" />
</div>
<el-divider />
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">私信通知</div>
<div class="setting-desc">接收新私信的通知</div>
</div>
<el-switch v-model="notificationSettings.message" />
</div>
</el-card>
<!-- 隐私设置 -->
<el-card class="settings-card" shadow="hover">
<template #header>
<h3>隐私设置</h3>
</template>
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">个人资料可见性</div>
<div class="setting-desc">其他用户是否可以查看你的个人资料</div>
</div>
<el-select v-model="privacySettings.profileVisibility" style="width: 140px;">
<el-option label="公开" value="public" />
<el-option label="仅好友" value="friends" />
<el-option label="私密" value="private" />
</el-select>
</div>
<el-divider />
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">在线状态</div>
<div class="setting-desc">是否显示你的在线状态</div>
</div>
<el-switch v-model="privacySettings.showOnlineStatus" />
</div>
<el-divider />
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">活动历史</div>
<div class="setting-desc">是否保存你的活动历史记录</div>
</div>
<el-switch v-model="privacySettings.saveActivityHistory" />
</div>
</el-card>
<!-- 界面设置 -->
<el-card class="settings-card" shadow="hover">
<template #header>
<h3>界面设置</h3>
</template>
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">主题模式</div>
<div class="setting-desc">选择你喜欢的界面主题</div>
</div>
<el-select v-model="interfaceSettings.theme" style="width: 120px;">
<el-option label="自动" value="auto" />
<el-option label="浅色" value="light" />
<el-option label="深色" value="dark" />
</el-select>
</div>
<el-divider />
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">语言</div>
<div class="setting-desc">选择界面显示语言</div>
</div>
<el-select v-model="interfaceSettings.language" style="width: 120px;">
<el-option label="中文" value="zh-CN" />
<el-option label="English" value="en-US" />
</el-select>
</div>
<el-divider />
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">页面大小</div>
<div class="setting-desc">每页显示的内容数量</div>
</div>
<el-select v-model="interfaceSettings.pageSize" style="width: 100px;">
<el-option label="10" :value="10" />
<el-option label="20" :value="20" />
<el-option label="50" :value="50" />
</el-select>
</div>
</el-card>
<!-- 账户安全 -->
<el-card class="settings-card" shadow="hover">
<template #header>
<h3>账户安全</h3>
</template>
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">两步验证</div>
<div class="setting-desc">为你的账户添加额外的安全保护</div>
</div>
<el-button size="small" @click="setupTwoFactor">
{{ securitySettings.twoFactorEnabled ? '已启用' : '启用' }}
</el-button>
</div>
<el-divider />
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">登录通知</div>
<div class="setting-desc">新设备登录时发送通知</div>
</div>
<el-switch v-model="securitySettings.loginNotification" />
</div>
<el-divider />
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">会话管理</div>
<div class="setting-desc">查看和管理你的登录会话</div>
</div>
<el-button size="small" @click="manageSessions"></el-button>
</div>
</el-card>
<!-- 数据管理 -->
<el-card class="settings-card" shadow="hover">
<template #header>
<h3>数据管理</h3>
</template>
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">数据导出</div>
<div class="setting-desc">下载你的所有数据</div>
</div>
<el-button size="small" @click="exportData"></el-button>
</div>
<el-divider />
<div class="setting-item">
<div class="setting-info">
<div class="setting-title">清除缓存</div>
<div class="setting-desc">清除本地缓存数据</div>
</div>
<el-button size="small" @click="clearCache"></el-button>
</div>
<el-divider />
<div class="setting-item danger">
<div class="setting-info">
<div class="setting-title">删除账户</div>
<div class="setting-desc">永久删除你的账户和所有数据</div>
</div>
<el-button size="small" type="danger" @click="deleteAccount"></el-button>
</div>
</el-card>
</div>
<!-- 保存按钮 -->
<div class="save-actions">
<el-button @click="resetSettings"></el-button>
<el-button type="primary" @click="saveSettings" :loading="saving">保存设置</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox, ElCard, ElSwitch, ElSelect, ElOption, ElButton, ElDivider } from 'element-plus';
//
const notificationSettings = ref({
email: true,
browser: false,
message: true
});
const privacySettings = ref({
profileVisibility: 'public',
showOnlineStatus: true,
saveActivityHistory: true
});
const interfaceSettings = ref({
theme: 'auto',
language: 'zh-CN',
pageSize: 20
});
const securitySettings = ref({
twoFactorEnabled: false,
loginNotification: true
});
const saving = ref(false);
//
const saveSettings = async () => {
saving.value = true;
try {
// API
await new Promise(resolve => setTimeout(resolve, 1000));
// API
// await settingsApi.saveSettings({
// notification: notificationSettings.value,
// privacy: privacySettings.value,
// interface: interfaceSettings.value,
// security: securitySettings.value
// });
ElMessage.success('设置保存成功');
} catch (error) {
ElMessage.error('设置保存失败');
} finally {
saving.value = false;
}
};
//
const resetSettings = () => {
notificationSettings.value = {
email: true,
browser: false,
message: true
};
privacySettings.value = {
profileVisibility: 'public',
showOnlineStatus: true,
saveActivityHistory: true
};
interfaceSettings.value = {
theme: 'auto',
language: 'zh-CN',
pageSize: 20
};
securitySettings.value = {
twoFactorEnabled: false,
loginNotification: true
};
ElMessage.success('设置已重置');
};
//
const setupTwoFactor = () => {
ElMessage.info('两步验证功能开发中');
};
//
const manageSessions = () => {
ElMessage.info('会话管理功能开发中');
};
//
const exportData = () => {
ElMessage.info('数据导出功能开发中');
};
//
const clearCache = () => {
localStorage.clear();
sessionStorage.clear();
ElMessage.success('缓存已清除');
};
//
const deleteAccount = async () => {
try {
await ElMessageBox.confirm(
'确定要删除账户吗?此操作不可逆,所有数据将永久丢失。',
'删除账户',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'error'
}
);
ElMessage.info('账户删除功能开发中');
} catch {
//
}
};
onMounted(() => {
//
// loadUserSettings();
});
</script>
<style scoped>
.settings-view {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.page-header {
margin-bottom: 30px;
}
.page-header h1 {
font-size: 28px;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.page-header p {
color: var(--el-text-color-secondary);
}
.settings-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.settings-card {
overflow: visible;
}
.settings-card :deep(.el-card__header) {
padding: 18px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.settings-card h3 {
margin: 0;
font-size: 18px;
color: var(--el-text-color-primary);
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
}
.setting-item.danger {
border-left: 3px solid var(--el-color-danger);
padding-left: 16px;
}
.setting-info {
flex: 1;
}
.setting-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.setting-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.save-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
/* 响应式设计 */
@media (max-width: 768px) {
.settings-view {
padding: 10px;
}
.setting-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.save-actions {
flex-direction: column;
align-items: center;
}
}
</style>

@ -1,278 +0,0 @@
<template>
<div class="create-post-view">
<div class="page-header">
<h1 class="page-title">{{ isEditing ? '编辑帖子' : '发布新帖子' }}</h1>
<el-button @click="goBack" class="back-button" :icon="ArrowLeft">返回</el-button>
</div>
<el-card class="post-form-card">
<el-form
ref="postFormRef"
:model="postForm"
:rules="postRules"
label-position="top"
@submit.prevent="handleSubmit"
>
<el-form-item label="标题" prop="title">
<el-input
v-model="postForm.title"
placeholder="请输入帖子标题5-50字"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="分类" prop="categoryId">
<el-select
v-model="postForm.categoryId"
placeholder="请选择帖子分类"
style="width: 100%"
:loading="loadingCategories"
>
<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="content">
<el-input
v-model="postForm.content"
type="textarea"
placeholder="请输入帖子内容10-5000字"
:rows="12"
maxlength="5000"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="submitting"
style="width: 120px"
>
{{ isEditing ? '保存更改' : '发布帖子' }}
</el-button>
<el-button @click="goBack" style="margin-left: 16px">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage, ElForm } from 'element-plus';
import { ArrowLeft } from '@element-plus/icons-vue';
import postApi from '@/api/post';
import type { CategoryItem, CreatePostParams } from '@/api/post';
import { useUserStore } from '@/stores';
//
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
//
const isEditing = computed(() => !!route.params.id);
const postId = computed(() => route.params.id ? Number(route.params.id) : null);
//
const postFormRef = ref<InstanceType<typeof ElForm> | null>(null);
const postForm = reactive<CreatePostParams>({
title: '',
content: '',
categoryId: null as unknown as number // null
});
const postRules = {
title: [
{ required: true, message: '请输入帖子标题', trigger: 'blur' },
{ min: 5, max: 50, message: '标题长度应在5到50个字符之间', trigger: 'blur' }
],
categoryId: [
{ required: true, message: '请选择帖子分类', trigger: 'change' },
{ type: 'number', min: 1, message: '请选择有效的分类', trigger: 'change' }
],
content: [
{ required: true, message: '请输入帖子内容', trigger: 'blur' },
{ min: 10, max: 5000, message: '内容长度应在10到5000个字符之间', trigger: 'blur' }
]
};
//
const submitting = ref(false);
//
const categories = ref<CategoryItem[]>([]);
const loadingCategories = ref(false);
//
const fetchCategories = async () => {
loadingCategories.value = true;
try {
const res = await postApi.getCategories();
if (res.code === 200 && res.data.list) {
categories.value = res.data.list;
} else {
ElMessage.error('获取分类失败');
}
} catch (error) {
console.error('获取分类出错:', error);
ElMessage.error('网络错误,请稍后重试');
} finally {
loadingCategories.value = false;
}
};
//
const fetchPostDetail = async () => {
if (!isEditing.value || !postId.value) return;
try {
const res = await postApi.getPostDetail(postId.value);
if (res.code === 200 && res.data) {
//
postForm.title = res.data.title;
postForm.content = res.data.content;
postForm.categoryId = res.data.categoryId;
} else {
ElMessage.error('获取帖子详情失败');
//
router.push('/');
}
} catch (error) {
console.error('获取帖子详情出错:', error);
ElMessage.error('网络错误,请稍后重试');
router.push('/');
}
};
//
const goBack = () => {
router.back();
};
//
const handleSubmit = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录');
router.push({
path: '/login',
query: { redirect: route.fullPath }
});
return;
}
//
if (!postForm.categoryId || postForm.categoryId <= 0) {
ElMessage.warning('请选择有效的帖子分类');
return;
}
await postFormRef.value?.validate(async (valid, fields) => {
if (!valid) {
console.log('表单验证失败:', fields);
return;
}
submitting.value = true;
try {
let res;
if (isEditing.value && postId.value) {
//
res = await postApi.updatePost(postId.value, postForm);
if (res.code === 200) {
ElMessage.success('帖子更新成功');
router.push(`/post/${postId.value}`);
} else {
ElMessage.error(res.message || '帖子更新失败');
}
} else {
//
res = await postApi.createPost(postForm);
if (res.code === 200 && res.data.postId) {
ElMessage.success('帖子发布成功');
router.push(`/post/${res.data.postId}`);
} else {
ElMessage.error(res.message || '帖子发布失败');
}
}
} catch (error: any) {
console.error('提交帖子出错:', error);
ElMessage.error(error.message || '网络错误,请稍后重试');
} finally {
submitting.value = false;
}
});
};
//
onMounted(async () => {
//
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录');
router.push({
path: '/login',
query: { redirect: route.fullPath }
});
return;
}
//
await fetchCategories();
//
if (isEditing.value) {
await fetchPostDetail();
}
});
</script>
<style scoped>
.create-post-view {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
font-size: 1.5rem;
margin: 0;
color: var(--el-text-color-primary);
}
.post-form-card {
margin-bottom: 40px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.create-post-view {
padding: 16px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.back-button {
align-self: flex-start;
}
}
</style>

@ -1,248 +0,0 @@
<template>
<div class="my-posts-container">
<el-card class="my-posts-card" shadow="hover">
<template #header>
<div class="card-header">
<h3>我的帖子</h3>
<el-button type="primary" @click="createNewPost"></el-button>
</div>
</template>
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="posts.length === 0" class="empty-container">
<el-empty description="您还没有发布过帖子" />
<el-button type="primary" @click="createNewPost"></el-button>
</div>
<div v-else class="posts-list">
<el-table :data="posts" style="width: 100%">
<el-table-column prop="title" label="标题" min-width="180">
<template #default="{ row }">
<router-link :to="`/forum/post/${row.id}`" class="post-title">
{{ row.title }}
</router-link>
</template>
</el-table-column>
<el-table-column prop="categoryName" label="分类" width="120" />
<el-table-column prop="viewCount" label="浏览" width="80" align="center" />
<el-table-column prop="likeCount" label="点赞" width="80" align="center" />
<el-table-column prop="commentCount" label="评论" width="80" align="center" />
<el-table-column prop="createdAt" label="发布时间" width="150">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
plain
@click="editPost(row.id)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
plain
@click="deletePost(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useUserStore } from '@/stores/user';
import { deletePost as apiDeletePost } from '@/api/forum';
import request from '@/api/request';
//
const router = useRouter();
const userStore = useUserStore();
//
const loading = ref(true);
const posts = ref([]);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const currentSort = ref('latest');
//
const fetchUserPosts = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录');
router.push('/login');
return;
}
loading.value = true;
try {
//
if (!userStore.userInfo) {
await userStore.fetchUserInfo();
}
const userId = userStore.userInfo?.id;
if (!userId) {
throw new Error('无法获取用户ID');
}
// 使API
const response = await request({
url: `/posts/user/${userId}`,
method: 'get',
params: {
page: currentPage.value,
size: pageSize.value,
sort: currentSort.value
}
});
posts.value = response.data.list || [];
total.value = response.data.total || 0;
} catch (error) {
console.error('获取用户帖子失败:', error);
ElMessage.error('获取用户帖子列表失败');
} finally {
loading.value = false;
}
};
//
const editPost = (postId) => {
router.push(`/edit-post/${postId}`);
};
//
const deletePost = async (postId) => {
try {
await ElMessageBox.confirm('确定要删除这篇帖子吗?此操作不可逆。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await apiDeletePost(postId);
ElMessage.success('删除成功');
fetchUserPosts(); //
} catch (error) {
if (error !== 'cancel') {
console.error('删除帖子失败:', error);
ElMessage.error('删除帖子失败');
}
}
};
//
const createNewPost = () => {
router.push('/create-post');
};
//
const handleSizeChange = (size) => {
pageSize.value = size;
fetchUserPosts();
};
const handleCurrentChange = (page) => {
currentPage.value = page;
fetchUserPosts();
};
//
const formatDateTime = (dateTimeStr) => {
if (!dateTimeStr) return '';
const date = new Date(dateTimeStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
//
onMounted(() => {
fetchUserPosts();
});
</script>
<style scoped>
.my-posts-container {
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.my-posts-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-weight: 500;
}
.loading-container, .empty-container {
padding: 40px 0;
text-align: center;
}
.empty-container .el-button {
margin-top: 16px;
}
.posts-list {
margin-top: 16px;
}
.post-title {
color: var(--el-color-primary);
text-decoration: none;
font-weight: 500;
}
.post-title:hover {
text-decoration: underline;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

@ -1,208 +0,0 @@
<template>
<div class="post-detail-view">
<el-button @click="goBack" class="back-button" :icon="ArrowLeft">返回列表</el-button>
<el-skeleton :rows="8" animated v-if="postStore.loading && !postStore.currentPost" />
<el-alert
v-if="postStore.error && !postStore.currentPost"
:title="`获取帖子详情失败: ${postStore.error}`"
type="error"
show-icon
:closable="false"
/>
<el-card v-if="postStore.currentPost" class="post-content-card">
<template #header>
<h1>{{ postStore.currentPost.title }}</h1>
<div class="post-meta-detail">
<span>作者: {{ postStore.currentPost.nickname }}</span>
<span>分类: {{ postStore.currentPost.categoryName }}</span>
<span>发布于: {{ formatDate(postStore.currentPost.createdAt) }}</span>
<span v-if="postStore.currentPost.updatedAt && postStore.currentPost.updatedAt !== postStore.currentPost.createdAt">
更新于: {{ formatDate(postStore.currentPost.updatedAt) }}
</span>
</div>
</template>
<div class="post-body" v-html="postStore.currentPost.content"></div>
<el-divider />
<div class="post-stats-actions">
<div class="post-stats">
<span><el-icon><View /></el-icon> {{ postStore.currentPost.viewCount }} </span>
<span><el-icon><Pointer /></el-icon> {{ postStore.currentPost.likeCount }} </span>
<span><el-icon><ChatDotRound /></el-icon> {{ postStore.currentPost.commentCount }} </span>
</div>
<div class="post-actions">
<el-button
v-if="userStore.isLoggedIn"
:type="postStore.currentPost.isLiked ? 'primary' : ''"
:loading="likingPost"
@click="toggleLike"
>
<el-icon><Pointer /></el-icon>
{{ postStore.currentPost.isLiked ? '已点赞' : '点赞' }}
</el-button>
<el-button v-else @click="goLogin">
<el-icon><Pointer /></el-icon>
点赞
</el-button>
</div>
</div>
</el-card>
<!-- 评论区组件 -->
<el-card class="comments-section" v-if="postStore.currentPost">
<CommentSection :post-id="postStore.currentPost.id" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, watch, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { usePostStore } from '@/stores/postStore';
import { useUserStore } from '@/stores';
import { ElMessage, ElIcon, ElButton, ElCard, ElSkeleton, ElAlert, ElDivider } from 'element-plus';
import { View, Pointer, ChatDotRound, ArrowLeft } from '@element-plus/icons-vue';
import CommentSection from '@/components/CommentSection.vue';
const route = useRoute();
const router = useRouter();
const postStore = usePostStore();
const userStore = useUserStore();
//
const likingPost = ref(false);
const formatDate = (dateString?: string) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
const goBack = () => {
router.push('/'); //
};
// /
const toggleLike = async () => {
if (!postStore.currentPost) return;
likingPost.value = true;
try {
await postStore.likePost(postStore.currentPost.id);
} finally {
likingPost.value = false;
}
};
//
const goLogin = () => {
router.push({
path: '/login',
query: { redirect: route.fullPath }
});
};
const loadPostDetails = (id: string | number) => {
const postId = Number(id);
if (isNaN(postId)) {
ElMessage.error('无效的帖子ID');
router.push('/forum'); // Redirect if ID is invalid
return;
}
postStore.fetchPostDetail(postId);
};
// Fetch post details when the component is mounted and when the route param changes
onMounted(() => {
if (route.params.id) {
loadPostDetails(route.params.id as string);
}
});
watch(
() => route.params.id,
(newId) => {
if (newId && newId !== postStore.currentPost?.id?.toString()) {
loadPostDetails(newId as string);
}
}
);
</script>
<style scoped>
.post-detail-view {
padding: 20px;
max-width: 900px;
margin: 0 auto;
}
.back-button {
margin-bottom: 20px;
}
.post-content-card {
margin-bottom: 20px;
}
h1 {
font-size: 2em;
margin-bottom: 10px;
}
.post-meta-detail {
font-size: 0.9em;
color: var(--el-text-color-secondary);
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.post-body {
line-height: 1.8;
color: var(--el-text-color-primary);
/* Add more styling if content is rich text (e.g., from a WYSIWYG editor) */
}
.post-body :deep(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.post-stats-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
.post-stats {
display: flex;
gap: 20px;
align-items: center;
font-size: 0.9em;
color: var(--el-text-color-secondary);
}
.post-stats span {
display: flex;
align-items: center;
gap: 5px;
}
.post-actions {
display: flex;
gap: 10px;
}
.comments-section {
margin-top: 30px;
}
</style>

@ -1,961 +0,0 @@
<template>
<div class="forum-page">
<!-- 顶部横幅 -->
<section class="forum-hero">
<div class="hero-content">
<div class="hero-text">
<h1 class="hero-title">UniLife 论坛</h1>
<p class="hero-subtitle">分享知识交流想法共同成长</p>
</div>
<div class="hero-stats">
<div class="stat-item">
<span class="stat-number">{{ postStore.totalPosts || 0 }}</span>
<span class="stat-label">帖子总数</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ postStore.categories?.length || 0 }}</span>
<span class="stat-label">分类数量</span>
</div>
</div>
</div>
</section>
<!-- 搜索和筛选区域 -->
<section class="search-section">
<div class="search-container">
<div class="search-main">
<div class="search-input-wrapper">
<el-input
v-model="searchKeyword"
placeholder="搜索帖子标题或内容..."
@keyup.enter="handleSearch"
@clear="clearSearch"
clearable
size="large"
class="search-input"
>
<template #prefix>
<el-icon class="search-icon"><Search /></el-icon>
</template>
<template #append>
<el-button
@click="handleSearch"
:loading="searchLoading"
type="primary"
class="search-btn"
>
搜索
</el-button>
</template>
</el-input>
</div>
<div class="filter-controls">
<el-select
v-model="selectedCategoryComputed"
placeholder="选择分类"
clearable
@clear="clearCategorySelection"
size="large"
class="category-select"
>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
<el-option label="全部分类" value=""></el-option>
<el-option
v-for="category in postStore.categories"
:key="category.id"
:label="category.name"
:value="category.id"
></el-option>
</el-select>
<el-button
type="primary"
:icon="Edit"
@click="createNewPost"
size="large"
class="create-post-btn"
>
发布帖子
</el-button>
</div>
</div>
<!-- 状态提示 -->
<div class="status-alerts">
<el-alert
v-if="postStore.loadingCategories"
title="正在加载分类..."
type="info"
:closable="false"
show-icon
class="status-alert"
/>
<el-alert
v-if="postStore.errorCategories"
:title="`分类加载失败: ${postStore.errorCategories}`"
type="error"
:closable="false"
show-icon
class="status-alert"
/>
</div>
</div>
</section>
<!-- 帖子列表区域 -->
<section class="posts-section">
<!-- 搜索状态提示 -->
<div v-if="postStore.isSearching" class="search-status">
<el-alert
:title="`搜索 '${postStore.searchKeyword}' 的结果 (共 ${postStore.totalPosts} 个帖子)`"
type="info"
show-icon
:closable="false"
>
<template #default>
<el-button size="small" @click="clearSearch"></el-button>
</template>
</el-alert>
</div>
<!-- 加载状态 -->
<div v-if="postStore.loading && postStore.posts.length === 0" class="loading-container">
<div class="posts-skeleton">
<div v-for="i in 5" :key="i" class="skeleton-card">
<el-skeleton animated>
<template #template>
<div class="skeleton-header">
<el-skeleton-item variant="text" style="width: 60%; height: 24px;" />
<el-skeleton-item variant="text" style="width: 80px; height: 16px;" />
</div>
<el-skeleton-item variant="text" style="width: 100%; height: 16px; margin: 12px 0;" />
<el-skeleton-item variant="text" style="width: 80%; height: 16px;" />
<div class="skeleton-footer">
<el-skeleton-item variant="text" style="width: 120px; height: 14px;" />
<el-skeleton-item variant="text" style="width: 60px; height: 14px;" />
</div>
</template>
</el-skeleton>
</div>
</div>
</div>
<!-- 错误状态 -->
<div v-else-if="postStore.error && postStore.posts.length === 0" class="error-container">
<el-empty description="加载失败,请稍后重试">
<el-button type="primary" @click="postStore.fetchPosts({ pageNum: 1 })">
重新加载
</el-button>
</el-empty>
</div>
<!-- 空状态 -->
<div v-else-if="!postStore.loading && postStore.posts.length === 0 && !postStore.error" class="empty-container">
<el-empty description="暂无帖子,成为第一个发帖的人吧!">
<el-button type="primary" @click="createNewPost">
发布帖子
</el-button>
</el-empty>
</div>
<!-- 帖子列表 -->
<div v-else class="posts-container">
<div class="posts-grid">
<article
v-for="(post, index) in postStore.posts"
:key="post.id"
class="post-card animate-fade-in"
:style="{ animationDelay: `${index * 0.05}s` }"
@click="navigateToPostDetail(post.id)"
>
<!-- 帖子头部 -->
<header class="post-header">
<div class="post-category">
<el-icon><FolderOpened /></el-icon>
<span>{{ post.categoryName || '未分类' }}</span>
</div>
<div class="post-date">
<el-icon><Clock /></el-icon>
<span>{{ formatDate(post.createdAt) }}</span>
</div>
</header>
<!-- 帖子内容 -->
<div class="post-content">
<h2 class="post-title">{{ post.title }}</h2>
<p class="post-summary">{{ post.summary || '暂无摘要' }}</p>
</div>
<!-- 帖子底部 -->
<footer class="post-footer">
<div class="post-author">
<el-avatar :size="32" class="author-avatar">
{{ post.nickname?.charAt(0) || 'U' }}
</el-avatar>
<span class="author-name">{{ post.nickname || '匿名用户' }}</span>
</div>
<div class="post-stats">
<div class="stat-item">
<el-icon class="stat-icon"><View /></el-icon>
<span class="stat-text">{{ post.viewCount || 0 }}</span>
</div>
<div class="stat-item">
<el-icon class="stat-icon"><Pointer /></el-icon>
<span class="stat-text">{{ post.likeCount || 0 }}</span>
</div>
<div class="stat-item">
<el-icon class="stat-icon"><ChatDotRound /></el-icon>
<span class="stat-text">{{ post.commentCount || 0 }}</span>
</div>
</div>
</footer>
<!-- 点赞按钮 -->
<div class="post-actions">
<el-button
v-if="userStore.isLoggedIn"
:type="post.isLiked ? 'primary' : ''"
:loading="likingPostId === post.id"
@click.stop="toggleLike(post)"
class="like-btn"
round
>
<el-icon><Pointer /></el-icon>
<span>{{ post.isLiked ? '已点赞' : '点赞' }}</span>
</el-button>
<el-button
v-else
@click.stop="goLogin"
class="like-btn"
round
>
<el-icon><Pointer /></el-icon>
<span>点赞</span>
</el-button>
</div>
</article>
</div>
<!-- 分页 -->
<div class="pagination-container" v-if="postStore.totalPages > 1">
<el-pagination
background
layout="sizes, prev, pager, next, jumper, ->, total"
:total="postStore.totalPosts"
:page-size="postStore.pageSize"
:page-sizes="[10, 20, 30, 50]"
:current-page="postStore.currentPage"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
class="pagination"
/>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { usePostStore } from '@/stores/postStore';
import { useUserStore } from '@/stores';
import { ElMessage, ElIcon, ElSkeleton, ElAlert, ElPagination, ElEmpty, ElSelect, ElOption, ElButton, ElInput, ElAvatar } from 'element-plus';
import { User, FolderOpened, View, Pointer, ChatDotRound, Clock, Edit, Search } from '@element-plus/icons-vue';
const router = useRouter();
const postStore = usePostStore();
const userStore = useUserStore();
//
const searchKeyword = ref('');
const searchLoading = ref(false);
//
const likingPostId = ref<number | null>(null);
//
const selectedCategoryComputed = computed({
get: () => postStore.selectedCategoryId ?? "",
set: (value) => {
postStore.selectCategory(value === "" ? null : value);
}
});
//
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
//
postStore.clearSearch();
postStore.fetchPosts({ pageNum: 1 });
return;
}
searchLoading.value = true;
try {
//
await postStore.searchPosts({
keyword: searchKeyword.value,
categoryId: postStore.selectedCategoryId,
pageNum: 1
});
} catch (error) {
console.error('搜索失败:', error);
ElMessage.error('搜索失败,请稍后重试');
} finally {
searchLoading.value = false;
}
};
//
const clearSearch = () => {
searchKeyword.value = '';
postStore.clearSearch();
postStore.fetchPosts({ pageNum: 1 });
};
//
const clearCategorySelection = () => {
postStore.selectCategory(null);
postStore.fetchPosts({ pageNum: 1 });
};
//
const formatDate = (dateString?: string) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return '今天';
if (days === 1) return '昨天';
if (days < 7) return `${days}天前`;
return date.toLocaleDateString();
};
//
const navigateToPostDetail = (postId: number) => {
router.push({ name: 'PostDetail', params: { id: postId.toString() } });
};
//
const createNewPost = () => {
if (userStore.isLoggedIn) {
router.push({ name: 'CreatePost' });
} else {
ElMessage.warning('请先登录');
router.push({
path: '/login',
query: { redirect: '/create-post' }
});
}
};
// /
const toggleLike = async (post: any) => {
likingPostId.value = post.id;
try {
await postStore.likePost(post.id);
} finally {
likingPostId.value = null;
}
};
//
const goLogin = () => {
ElMessage.warning('请先登录');
router.push({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
});
};
//
const handleCurrentChange = (page: number) => {
if (postStore.isSearching && postStore.searchKeyword) {
// 使
postStore.searchPosts({
keyword: postStore.searchKeyword,
categoryId: postStore.selectedCategoryId,
pageNum: page
});
} else {
// 使
postStore.fetchPosts({ pageNum: page });
}
};
const handleSizeChange = (size: number) => {
if (postStore.isSearching && postStore.searchKeyword) {
// 使
postStore.searchPosts({
keyword: postStore.searchKeyword,
categoryId: postStore.selectedCategoryId,
pageNum: 1,
pageSize: size
});
} else {
// 使
postStore.fetchPosts({ pageNum: 1, pageSize: size });
}
};
onMounted(async () => {
await postStore.fetchCategories();
postStore.fetchPosts({ pageNum: postStore.currentPage, pageSize: postStore.pageSize });
});
</script>
<style scoped>
.forum-page {
min-height: 100vh;
background: var(--bg-secondary);
}
/* 顶部横幅 */
.forum-hero {
background: linear-gradient(135deg, var(--primary-600) 0%, var(--primary-400) 100%);
color: white;
padding: var(--space-10) 0;
position: relative;
overflow: hidden;
}
.forum-hero::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
}
.hero-content {
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 var(--space-6);
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 2;
}
.hero-text {
flex: 1;
}
.hero-title {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-bold);
margin: 0 0 var(--space-3) 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.hero-subtitle {
font-size: var(--font-size-xl);
margin: 0;
opacity: 0.9;
font-weight: var(--font-weight-normal);
}
.hero-stats {
display: flex;
gap: var(--space-8);
}
.hero-stats .stat-item {
text-align: center;
}
.hero-stats .stat-number {
display: block;
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
line-height: 1;
margin-bottom: var(--space-1);
}
.hero-stats .stat-label {
font-size: var(--font-size-sm);
opacity: 0.8;
}
/* 搜索区域 */
.search-section {
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
position: sticky;
top: 72px;
z-index: var(--z-sticky);
}
.search-container {
max-width: var(--content-max-width);
margin: 0 auto;
padding: var(--space-6);
}
.search-main {
display: flex;
gap: var(--space-4);
align-items: center;
}
.search-input-wrapper {
flex: 1;
max-width: 600px;
}
.search-input {
box-shadow: var(--shadow-sm);
}
.search-input :deep(.el-input__wrapper) {
border-radius: var(--radius-xl);
padding: var(--space-2) var(--space-4);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
transition: all var(--duration-150) var(--ease-out);
}
.search-input :deep(.el-input__wrapper:hover) {
box-shadow: var(--shadow-md);
border-color: var(--primary-300);
}
.search-input :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 3px rgba(139, 77, 255, 0.1), var(--shadow-md);
border-color: var(--primary-500);
}
.search-btn {
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-5);
font-weight: var(--font-weight-medium);
}
.filter-controls {
display: flex;
gap: var(--space-3);
align-items: center;
}
.category-select {
min-width: 200px;
}
.category-select :deep(.el-input__wrapper) {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.create-post-btn {
padding: var(--space-3) var(--space-6);
border-radius: var(--radius-lg);
font-weight: var(--font-weight-medium);
box-shadow: var(--shadow-sm);
transition: all var(--duration-150) var(--ease-out);
}
.create-post-btn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.status-alerts {
margin-top: var(--space-4);
}
.status-alert {
border-radius: var(--radius-lg);
}
/* 帖子列表区域 */
.posts-section {
max-width: var(--content-max-width);
margin: 0 auto;
padding: var(--space-8) var(--space-6);
}
/* 搜索状态 */
.search-status {
margin-bottom: var(--space-6);
}
.loading-container,
.error-container,
.empty-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.posts-skeleton {
width: 100%;
max-width: 800px;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.skeleton-card {
background: var(--bg-elevated);
border-radius: var(--radius-xl);
padding: var(--space-6);
border: 1px solid var(--border-light);
}
.skeleton-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
}
.skeleton-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-4);
}
/* 帖子卡片网格 */
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: var(--space-6);
margin-bottom: var(--space-8);
}
.post-card {
background: var(--bg-elevated);
border: 2px solid var(--border-light);
border-radius: var(--radius-xl);
padding: 0;
cursor: pointer;
transition: all var(--duration-200) var(--ease-out);
position: relative;
overflow: hidden;
height: fit-content;
box-shadow: var(--shadow-md);
}
.post-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--primary-500), var(--primary-300));
transform: scaleX(0);
transition: transform var(--duration-300) var(--ease-out);
}
.post-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, transparent 0%, rgba(139, 77, 255, 0.03) 100%);
opacity: 0;
transition: opacity var(--duration-200) var(--ease-out);
}
.post-card:hover {
transform: translateY(-6px);
box-shadow: var(--shadow-2xl);
border-color: var(--primary-200);
}
.post-card:hover::before {
transform: scaleX(1);
}
.post-card:hover::after {
opacity: 1;
}
/* 帖子头部 */
.post-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-5) var(--space-6) var(--space-3) var(--space-6);
background: var(--neutral-50);
border-bottom: 1px solid var(--border-light);
position: relative;
z-index: 2;
}
.post-category {
display: flex;
align-items: center;
gap: var(--space-1);
background: linear-gradient(135deg, var(--primary-500), var(--primary-400));
color: white;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
box-shadow: var(--shadow-sm);
}
.post-date {
display: flex;
align-items: center;
gap: var(--space-1);
color: var(--text-light);
font-size: var(--font-size-xs);
background: var(--neutral-100);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
/* 帖子内容 */
.post-content {
padding: var(--space-5) var(--space-6);
position: relative;
z-index: 2;
background: var(--bg-elevated);
}
.post-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin: 0 0 var(--space-3) 0;
line-height: var(--line-height-snug);
transition: color var(--duration-150) var(--ease-out);
}
.post-card:hover .post-title {
color: var(--primary-600);
}
.post-summary {
color: var(--text-secondary);
line-height: var(--line-height-relaxed);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
position: relative;
}
.post-summary::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 30px;
height: 20px;
background: linear-gradient(90deg, transparent, var(--bg-elevated));
}
/* 帖子底部 */
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) var(--space-6);
background: var(--neutral-25);
border-top: 1px solid var(--border-light);
position: relative;
z-index: 2;
}
.post-author {
display: flex;
align-items: center;
gap: var(--space-3);
background: white;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
}
.author-avatar {
box-shadow: var(--shadow-sm);
border: 2px solid var(--primary-100);
}
.author-name {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.post-stats {
display: flex;
gap: var(--space-4);
}
.stat-item {
display: flex;
align-items: center;
gap: var(--space-1);
color: var(--text-light);
font-size: var(--font-size-xs);
background: white;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: all var(--duration-150) var(--ease-out);
}
.stat-item:hover {
background: var(--primary-50);
color: var(--primary-600);
}
.stat-icon {
font-size: var(--font-size-sm);
}
/* 帖子操作 */
.post-actions {
display: flex;
justify-content: center;
padding: var(--space-4) var(--space-6) var(--space-5) var(--space-6);
background: var(--bg-elevated);
position: relative;
z-index: 2;
}
.like-btn {
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
transition: all var(--duration-150) var(--ease-out);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.like-btn:hover {
transform: scale(1.05);
box-shadow: var(--shadow-md);
}
/* 分页 */
.pagination-container {
display: flex;
justify-content: center;
margin-top: var(--space-8);
}
.pagination {
background: var(--bg-elevated);
padding: var(--space-4);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.posts-grid {
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: var(--space-5);
}
.hero-content {
flex-direction: column;
gap: var(--space-6);
text-align: center;
}
.hero-stats {
gap: var(--space-6);
}
}
@media (max-width: 768px) {
.forum-hero {
padding: var(--space-8) 0;
}
.hero-title {
font-size: var(--font-size-3xl);
}
.hero-subtitle {
font-size: var(--font-size-lg);
}
.search-main {
flex-direction: column;
gap: var(--space-4);
}
.search-input-wrapper {
max-width: none;
}
.filter-controls {
width: 100%;
justify-content: space-between;
}
.category-select {
flex: 1;
margin-right: var(--space-3);
}
.posts-grid {
grid-template-columns: 1fr;
gap: var(--space-4);
}
.hero-stats {
flex-direction: column;
gap: var(--space-4);
}
.hero-stats .stat-item {
display: flex;
align-items: center;
gap: var(--space-3);
}
.hero-stats .stat-number {
margin-bottom: 0;
}
}
@media (max-width: 480px) {
.posts-section {
padding: var(--space-6) var(--space-4);
}
.search-container {
padding: var(--space-4);
}
.post-card {
padding: var(--space-4);
}
.filter-controls {
flex-direction: column;
}
.category-select {
margin-right: 0;
margin-bottom: var(--space-3);
}
}
</style>

@ -1,504 +0,0 @@
<template>
<div class="my-resources-container">
<div class="page-header">
<h1 class="page-title">我的资源</h1>
<el-button type="primary" @click="handleUpload">
<el-icon><Upload /></el-icon>
</el-button>
</div>
<el-tabs v-model="activeTab" @tab-click="handleTabChange">
<el-tab-pane label="我上传的资源" name="uploaded">
<el-table
v-loading="loading"
:data="resources"
style="width: 100%"
empty-text="暂无资源"
>
<el-table-column prop="title" label="资源标题" min-width="200">
<template #default="scope">
<div class="resource-title-cell">
<el-icon v-if="getFileIcon(scope.row.fileType)" :size="20">
<component :is="getFileIcon(scope.row.fileType)" />
</el-icon>
<router-link :to="`/resource/${scope.row.id}`" class="resource-link">
{{ scope.row.title }}
</router-link>
</div>
</template>
</el-table-column>
<el-table-column prop="categoryName" label="分类" width="120" />
<el-table-column prop="fileSize" label="大小" width="120">
<template #default="scope">
{{ formatFileSize(scope.row.fileSize) }}
</template>
</el-table-column>
<el-table-column prop="downloadCount" label="下载次数" width="100" />
<el-table-column prop="likeCount" label="点赞数" width="100" />
<el-table-column prop="createdAt" label="上传时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="small" @click="handleDownload(scope.row.id)"></el-button>
<el-button size="small" type="primary" @click="handleEdit(scope.row)"></el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row.id)"></el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-tab-pane>
</el-tabs>
<!-- 上传资源对话框 -->
<el-dialog
v-model="uploadDialogVisible"
title="上传资源"
width="500px"
>
<el-form :model="uploadForm" label-width="80px" :rules="uploadRules" ref="uploadFormRef">
<el-form-item label="标题" prop="title">
<el-input v-model="uploadForm.title" placeholder="请输入资源标题"></el-input>
</el-form-item>
<el-form-item label="分类" prop="categoryId">
<el-select v-model="uploadForm.categoryId" placeholder="请选择分类">
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="uploadForm.description"
type="textarea"
:rows="3"
placeholder="请输入资源描述"
></el-input>
</el-form-item>
<el-form-item label="文件" prop="file">
<el-upload
class="resource-upload"
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-button type="primary">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">
文件大小不超过50MB
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUpload" :loading="uploading">
上传
</el-button>
</span>
</template>
</el-dialog>
<!-- 编辑资源对话框 -->
<el-dialog
v-model="editDialogVisible"
title="编辑资源信息"
width="500px"
>
<el-form :model="editForm" label-width="80px" :rules="editRules" ref="editFormRef">
<el-form-item label="标题" prop="title">
<el-input v-model="editForm.title" placeholder="请输入资源标题"></el-input>
</el-form-item>
<el-form-item label="分类" prop="categoryId">
<el-select v-model="editForm.categoryId" placeholder="请选择分类">
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="editForm.description"
type="textarea"
:rows="3"
placeholder="请输入资源描述"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitEdit" :loading="submitting">
保存
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus';
import { resourceApi } from '@/api';
import { Upload, Document, Picture, Files, Folder, Grid, Reading, Promotion } from '@element-plus/icons-vue';
const router = useRouter();
//
const resources = ref<any[]>([]);
const categories = ref<any[]>([]);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const loading = ref(false);
const activeTab = ref('uploaded');
//
const uploadDialogVisible = ref(false);
const uploadFormRef = ref<FormInstance>();
const uploading = ref(false);
const uploadForm = reactive({
title: '',
description: '',
categoryId: undefined as number | undefined,
file: null as File | null
});
const uploadRules = {
title: [{ required: true, message: '请输入资源标题', trigger: 'blur' }],
categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }],
file: [{ required: true, message: '请上传文件', trigger: 'change' }]
};
//
const editDialogVisible = ref(false);
const editFormRef = ref<FormInstance>();
const submitting = ref(false);
const currentEditId = ref<number | null>(null);
const editForm = reactive({
title: '',
description: '',
categoryId: undefined as number | undefined
});
const editRules = {
title: [{ required: true, message: '请输入资源标题', trigger: 'blur' }],
categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }]
};
//
onMounted(async () => {
await Promise.all([
fetchMyResources(),
fetchCategories()
]);
});
//
const fetchMyResources = async () => {
loading.value = true;
try {
const params = {
page: currentPage.value,
size: pageSize.value
};
const res = await resourceApi.getMyResources(params);
if (res.code === 200) {
resources.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
console.error('获取我的资源列表失败:', error);
ElMessage.error('获取我的资源列表失败');
} finally {
loading.value = false;
}
};
//
const fetchCategories = async () => {
try {
const res = await resourceApi.getResourceCategories();
if (res.code === 200) {
categories.value = res.data.list;
}
} catch (error) {
console.error('获取分类列表失败:', error);
}
};
//
const handleTabChange = () => {
fetchMyResources();
};
//
const handleSizeChange = (val: number) => {
pageSize.value = val;
fetchMyResources();
};
//
const handleCurrentChange = (val: number) => {
currentPage.value = val;
fetchMyResources();
};
//
const handleUpload = () => {
uploadDialogVisible.value = true;
};
//
const handleFileChange = (file: any) => {
uploadForm.file = file.raw;
};
//
const handleFileRemove = () => {
uploadForm.file = null;
};
//
const submitUpload = async () => {
if (!uploadFormRef.value) return;
await uploadFormRef.value.validate(async (valid) => {
if (valid && uploadForm.file) {
uploading.value = true;
try {
const formData = new FormData();
formData.append('file', uploadForm.file);
formData.append('title', uploadForm.title);
formData.append('categoryId', String(uploadForm.categoryId));
formData.append('description', uploadForm.description || '');
const res = await resourceApi.uploadResource(formData);
if (res.code === 200) {
ElMessage.success('资源上传成功');
uploadDialogVisible.value = false;
resetUploadForm();
fetchMyResources();
}
} catch (error) {
console.error('上传资源失败:', error);
ElMessage.error('上传资源失败');
} finally {
uploading.value = false;
}
}
});
};
//
const resetUploadForm = () => {
uploadForm.title = '';
uploadForm.description = '';
uploadForm.categoryId = undefined;
uploadForm.file = null;
if (uploadFormRef.value) {
uploadFormRef.value.resetFields();
}
};
//
const handleEdit = (resource: any) => {
currentEditId.value = resource.id;
editForm.title = resource.title;
editForm.description = resource.description || '';
editForm.categoryId = resource.categoryId;
editDialogVisible.value = true;
};
//
const submitEdit = async () => {
if (!editFormRef.value || !currentEditId.value) return;
await editFormRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true;
try { const res = await resourceApi.updateResource(currentEditId.value!, editForm); if (res.code === 200) {
ElMessage.success('资源信息更新成功');
editDialogVisible.value = false;
fetchMyResources();
}
} catch (error) {
console.error('更新资源信息失败:', error);
ElMessage.error('更新资源信息失败');
} finally {
submitting.value = false;
}
}
});
};
//
const handleDownload = async (id: number) => {
try {
const res = await resourceApi.downloadResource(id);
if (res.code === 200) {
//
const link = document.createElement('a');
link.href = res.data.fileUrl;
link.download = res.data.fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
//
fetchMyResources();
}
} catch (error) {
console.error('下载资源失败:', error);
ElMessage.error('下载资源失败');
}
};
//
const handleDelete = (id: number) => {
ElMessageBox.confirm(
'确定要删除该资源吗?此操作不可恢复',
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
const res = await resourceApi.deleteResource(id);
if (res.code === 200) {
ElMessage.success('资源删除成功');
fetchMyResources();
}
} catch (error) {
console.error('删除资源失败:', error);
ElMessage.error('删除资源失败');
}
}).catch(() => {
//
});
};
//
const getFileIcon = (fileType: string) => {
if (fileType.includes('pdf')) {
return Document;
} else if (fileType.includes('image')) {
return Picture;
} else if (fileType.includes('word')) {
return Reading;
} else if (fileType.includes('excel')) {
return Grid;
} else if (fileType.includes('powerpoint')) {
return Promotion;
} else if (fileType.includes('zip') || fileType.includes('rar')) {
return Folder;
} else {
return Files;
}
};
//
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
//
const formatFileSize = (size: number) => {
if (size < 1024) {
return size + ' B';
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB';
} else if (size < 1024 * 1024 * 1024) {
return (size / (1024 * 1024)).toFixed(2) + ' MB';
} else {
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
};
</script>
<style scoped>
.my-resources-container {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.resource-title-cell {
display: flex;
align-items: center;
gap: 8px;
}
.resource-link {
color: var(--el-color-primary);
text-decoration: none;
}
.resource-link:hover {
text-decoration: underline;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: center;
}
.resource-upload {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

@ -1,484 +0,0 @@
<template>
<div class="resource-detail-container">
<el-card class="resource-detail-card" v-loading="loading">
<template v-if="resource">
<div class="resource-header">
<div class="resource-title-section">
<h1 class="resource-title">{{ resource.title }}</h1>
<el-tag size="small" type="primary">{{ resource.categoryName }}</el-tag>
</div>
<div class="resource-actions">
<el-button type="primary" @click="handleDownload">
<el-icon><Download /></el-icon>
</el-button>
<el-button
:type="resource.isLiked ? 'danger' : 'default'"
@click="handleLike"
>
<el-icon><Star /></el-icon> {{ resource.isLiked ? '' : '' }} ({{ resource.likeCount }})
</el-button>
<el-dropdown v-if="isOwner" trigger="click">
<el-button>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleEdit"></el-dropdown-item>
<el-dropdown-item @click="handleDelete" style="color: var(--el-color-danger)">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<el-divider />
<div class="resource-info">
<div class="resource-meta">
<div class="resource-uploader">
<el-avatar :size="40" :src="resource.avatar"></el-avatar>
<div class="uploader-info">
<div class="uploader-name">{{ resource.nickname }}</div>
<div class="upload-time">上传于 {{ formatDate(resource.createdAt) }}</div>
</div>
</div>
<div class="resource-stats">
<div class="stat-item">
<el-icon><View /></el-icon>
<span>下载次数: {{ resource.downloadCount }}</span>
</div>
<div class="stat-item">
<el-icon><Star /></el-icon>
<span>点赞数: {{ resource.likeCount }}</span>
</div>
<div class="stat-item">
<el-icon><Files /></el-icon>
<span>文件大小: {{ formatFileSize(resource.fileSize) }}</span>
</div>
<div class="stat-item">
<el-icon><Document /></el-icon>
<span>文件类型: {{ formatFileType(resource.fileType) }}</span>
</div>
</div>
</div>
<el-divider />
<div class="resource-description">
<h3>资源描述</h3>
<div class="description-content">
{{ resource.description || '暂无描述' }}
</div>
</div>
<el-divider />
<div class="resource-preview" v-if="canPreview">
<h3>预览</h3>
<div class="preview-container">
<img v-if="isImage" :src="resource.fileUrl" alt="资源预览" class="preview-image" />
<div v-else class="no-preview">
<el-icon size="48"><Document /></el-icon>
<p>该文件类型不支持在线预览</p>
</div>
</div>
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="资源不存在或已被删除" />
</el-card>
<!-- 编辑资源对话框 -->
<el-dialog
v-model="editDialogVisible"
title="编辑资源信息"
width="500px"
>
<el-form :model="editForm" label-width="80px" :rules="editRules" ref="editFormRef">
<el-form-item label="标题" prop="title">
<el-input v-model="editForm.title" placeholder="请输入资源标题"></el-input>
</el-form-item>
<el-form-item label="分类" prop="categoryId">
<el-select v-model="editForm.categoryId" placeholder="请选择分类">
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="editForm.description"
type="textarea"
:rows="3"
placeholder="请输入资源描述"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitEdit" :loading="submitting">
保存
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus';
import { resourceApi } from '@/api';
import { useUserStore } from '@/stores';
import { Download, Star, More, View, Files, Document } from '@element-plus/icons-vue';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const resourceId = computed(() => Number(route.params.id));
//
const resource = ref<any>(null);
const loading = ref(true);
const categories = ref<any[]>([]);
//
const editDialogVisible = ref(false);
const editFormRef = ref<FormInstance>();
const submitting = ref(false);
const editForm = reactive({
title: '',
description: '',
categoryId: undefined as number | undefined
});
const editRules = {
title: [{ required: true, message: '请输入资源标题', trigger: 'blur' }],
categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }]
};
//
const isOwner = computed(() => { if (!resource.value || !userStore.userInfo) return false; return resource.value.userId === userStore.userInfo.id;});
const isImage = computed(() => {
if (!resource.value) return false;
return resource.value.fileType.includes('image');
});
const canPreview = computed(() => {
if (!resource.value) return false;
return isImage.value;
});
//
onMounted(async () => {
await Promise.all([
fetchResourceDetail(),
fetchCategories()
]);
});
//
const fetchResourceDetail = async () => {
loading.value = true;
try {
const res = await resourceApi.getResourceDetail(resourceId.value);
if (res.code === 200) {
resource.value = res.data;
}
} catch (error) {
console.error('获取资源详情失败:', error);
ElMessage.error('获取资源详情失败');
} finally {
loading.value = false;
}
};
//
const fetchCategories = async () => {
try {
const res = await resourceApi.getResourceCategories();
if (res.code === 200) {
categories.value = res.data.list;
}
} catch (error) {
console.error('获取分类列表失败:', error);
}
};
//
const handleDownload = async () => {
try {
const res = await resourceApi.downloadResource(resourceId.value);
if (res.code === 200) {
//
const link = document.createElement('a');
link.href = res.data.fileUrl;
link.download = res.data.fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
//
fetchResourceDetail();
}
} catch (error) {
console.error('下载资源失败:', error);
ElMessage.error('下载资源失败');
}
};
//
const handleLike = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录');
router.push('/login');
return;
}
try {
const res = await resourceApi.likeResource(resourceId.value);
if (res.code === 200) {
//
resource.value.isLiked = !resource.value.isLiked;
resource.value.likeCount += resource.value.isLiked ? 1 : -1;
ElMessage.success(resource.value.isLiked ? '点赞成功' : '取消点赞成功');
}
} catch (error) {
console.error('点赞操作失败:', error);
ElMessage.error('点赞操作失败');
}
};
//
const handleEdit = () => {
editForm.title = resource.value.title;
editForm.description = resource.value.description || '';
editForm.categoryId = resource.value.categoryId;
editDialogVisible.value = true;
};
//
const submitEdit = async () => {
if (!editFormRef.value) return;
await editFormRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true;
try {
const res = await resourceApi.updateResource(resourceId.value, editForm);
if (res.code === 200) {
ElMessage.success('资源信息更新成功');
editDialogVisible.value = false;
fetchResourceDetail();
}
} catch (error) {
console.error('更新资源信息失败:', error);
ElMessage.error('更新资源信息失败');
} finally {
submitting.value = false;
}
}
});
};
//
const handleDelete = () => {
ElMessageBox.confirm(
'确定要删除该资源吗?此操作不可恢复',
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
const res = await resourceApi.deleteResource(resourceId.value);
if (res.code === 200) {
ElMessage.success('资源删除成功');
router.push('/resources');
}
} catch (error) {
console.error('删除资源失败:', error);
ElMessage.error('删除资源失败');
}
}).catch(() => {
//
});
};
//
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
//
const formatFileSize = (size: number) => {
if (size < 1024) {
return size + ' B';
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB';
} else if (size < 1024 * 1024 * 1024) {
return (size / (1024 * 1024)).toFixed(2) + ' MB';
} else {
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
};
//
const formatFileType = (fileType: string) => {
const typeMap: Record<string, string> = {
'application/pdf': 'PDF文档',
'application/msword': 'Word文档',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word文档',
'application/vnd.ms-excel': 'Excel表格',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel表格',
'application/vnd.ms-powerpoint': 'PowerPoint演示文稿',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint演示文稿',
'application/zip': 'ZIP压缩文件',
'application/x-rar-compressed': 'RAR压缩文件',
'image/jpeg': 'JPEG图片',
'image/png': 'PNG图片',
'image/gif': 'GIF图片',
'text/plain': '文本文件'
};
return typeMap[fileType] || fileType;
};
</script>
<style scoped>
.resource-detail-container {
padding: 20px;
}
.resource-detail-card {
border-radius: 8px;
}
.resource-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.resource-title-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.resource-title {
font-size: 24px;
margin: 0;
}
.resource-actions {
display: flex;
gap: 10px;
}
.resource-meta {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.resource-uploader {
display: flex;
align-items: center;
gap: 15px;
}
.uploader-info {
display: flex;
flex-direction: column;
}
.uploader-name {
font-weight: 500;
font-size: 16px;
}
.upload-time {
color: #999;
font-size: 14px;
margin-top: 5px;
}
.resource-stats {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.stat-item {
display: flex;
align-items: center;
gap: 5px;
color: #666;
}
.resource-description {
margin: 20px 0;
}
.description-content {
white-space: pre-line;
line-height: 1.6;
color: #333;
}
.resource-preview {
margin: 20px 0;
}
.preview-container {
margin-top: 15px;
border: 1px solid #eee;
border-radius: 8px;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.preview-image {
max-width: 100%;
max-height: 500px;
object-fit: contain;
}
.no-preview {
display: flex;
flex-direction: column;
align-items: center;
color: #999;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

@ -1,510 +0,0 @@
<template>
<div class="resource-list-container">
<div class="resource-header">
<h1 class="page-title">学习资源共享</h1>
<div class="resource-actions">
<el-button type="primary" @click="handleUpload" v-if="isLoggedIn">
<el-icon><Upload /></el-icon>
</el-button>
</div>
</div>
<div class="resource-filters">
<el-card shadow="never" class="filter-card">
<div class="filter-row">
<div class="filter-item">
<span class="filter-label">分类</span>
<el-radio-group v-model="filters.categoryId" @change="handleFilterChange">
<el-radio-button :label="null">全部</el-radio-button>
<el-radio-button v-for="category in categories" :key="category.id" :label="category.id">
{{ category.name }}
</el-radio-button>
</el-radio-group>
</div>
<div class="filter-item">
<el-input
v-model="filters.keyword"
placeholder="搜索资源"
class="search-input"
@keyup.enter="handleSearch"
clearable
@clear="handleSearch"
>
<template #suffix>
<el-icon class="search-icon" @click="handleSearch"><Search /></el-icon>
</template>
</el-input>
</div>
</div>
</el-card>
</div>
<div class="resource-content">
<el-empty v-if="resources.length === 0" description="暂无资源" />
<el-row :gutter="20" v-else>
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="resource in resources" :key="resource.id" class="resource-col">
<el-card class="resource-card" shadow="hover" @click="viewResourceDetail(resource.id)">
<div class="resource-icon">
<el-icon v-if="resource.fileType.includes('pdf')" size="40"><Document /></el-icon>
<el-icon v-else-if="resource.fileType.includes('image')" size="40"><Picture /></el-icon>
<el-icon v-else-if="resource.fileType.includes('word')" size="40"><Reading /></el-icon>
<el-icon v-else-if="resource.fileType.includes('excel')" size="40"><Grid /></el-icon>
<el-icon v-else-if="resource.fileType.includes('powerpoint')" size="40"><Promotion /></el-icon>
<el-icon v-else-if="resource.fileType.includes('zip') || resource.fileType.includes('rar')" size="40"><Folder /></el-icon>
<el-icon v-else size="40"><Files /></el-icon>
</div>
<h3 class="resource-title">{{ resource.title }}</h3>
<p class="resource-description" v-if="resource.description">{{ resource.description }}</p>
<div class="resource-meta">
<span class="resource-category">{{ resource.categoryName }}</span>
<span class="resource-size">{{ formatFileSize(resource.fileSize) }}</span>
</div>
<div class="resource-footer">
<div class="resource-uploader">
<el-avatar :size="24" :src="resource.avatar"></el-avatar>
<span>{{ resource.nickname }}</span>
</div>
<div class="resource-stats">
<span class="resource-downloads">
<el-icon><Download /></el-icon> {{ resource.downloadCount }}
</span>
<span class="resource-likes">
<el-icon><Star /></el-icon> {{ resource.likeCount }}
</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 上传资源对话框 -->
<el-dialog
v-model="uploadDialogVisible"
title="上传资源"
width="500px"
>
<el-form :model="uploadForm" label-width="80px" :rules="uploadRules" ref="uploadFormRef">
<el-form-item label="标题" prop="title">
<el-input v-model="uploadForm.title" placeholder="请输入资源标题"></el-input>
</el-form-item>
<el-form-item label="分类" prop="categoryId">
<el-select v-model="uploadForm.categoryId" placeholder="请选择分类">
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="uploadForm.description"
type="textarea"
:rows="3"
placeholder="请输入资源描述"
></el-input>
</el-form-item>
<el-form-item label="文件" prop="file">
<el-upload
class="resource-upload"
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-button type="primary">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">
文件大小不超过50MB
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUpload" :loading="uploading">
上传
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus';
import { resourceApi } from '@/api';
import { useUserStore } from '@/stores';
import { Document, Picture, Reading, Grid, Promotion, Folder, Files, Upload, Download, Star, Search } from '@element-plus/icons-vue';
const router = useRouter();
const userStore = useUserStore();
const isLoggedIn = computed(() => userStore.isLoggedIn);
//
const resources = ref<any[]>([]);
const categories = ref<any[]>([]);
const currentPage = ref(1);
const pageSize = ref(12);
const total = ref(0);
const loading = ref(false);
//
const filters = reactive({
categoryId: null as number | null,
keyword: ''
});
//
const uploadDialogVisible = ref(false);
const uploadFormRef = ref<FormInstance>();
const uploading = ref(false);
const uploadForm = reactive({
title: '',
description: '',
categoryId: undefined as number | undefined,
file: null as File | null
});
const uploadRules = {
title: [{ required: true, message: '请输入资源标题', trigger: 'blur' }],
categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }],
file: [{ required: true, message: '请上传文件', trigger: 'change' }]
};
//
onMounted(async () => {
await Promise.all([
fetchResources(),
fetchCategories()
]);
});
//
const fetchResources = async () => {
loading.value = true;
try {
const params: any = {
page: currentPage.value,
size: pageSize.value
};
// categoryIdnull
if (filters.categoryId !== null) {
params.category = filters.categoryId; // category
}
// keyword
if (filters.keyword && filters.keyword.trim()) {
params.keyword = filters.keyword.trim();
}
console.log('请求参数:', params); //
const res = await resourceApi.getResources(params);
if (res.code === 200) {
resources.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
console.error('获取资源列表失败:', error);
ElMessage.error('获取资源列表失败');
} finally {
loading.value = false;
}
};
//
const fetchCategories = async () => {
try {
const res = await resourceApi.getResourceCategories();
if (res.code === 200) {
categories.value = res.data.list;
}
} catch (error) {
console.error('获取分类列表失败:', error);
}
};
//
const handleFilterChange = () => {
currentPage.value = 1;
fetchResources();
};
//
const handleSearch = () => {
currentPage.value = 1;
fetchResources();
};
//
const handleSizeChange = (val: number) => {
pageSize.value = val;
fetchResources();
};
//
const handleCurrentChange = (val: number) => {
currentPage.value = val;
fetchResources();
};
//
const viewResourceDetail = (id: number) => {
router.push(`/resource/${id}`);
};
//
const handleUpload = () => {
if (!isLoggedIn.value) {
ElMessage.warning('请先登录');
router.push('/login');
return;
}
uploadDialogVisible.value = true;
};
//
const handleFileChange = (file: any) => {
uploadForm.file = file.raw;
};
//
const handleFileRemove = () => {
uploadForm.file = null;
};
//
const submitUpload = async () => {
if (!uploadFormRef.value) return;
await uploadFormRef.value.validate(async (valid) => {
if (valid && uploadForm.file) {
uploading.value = true;
try {
const formData = new FormData();
formData.append('file', uploadForm.file);
formData.append('title', uploadForm.title);
formData.append('categoryId', String(uploadForm.categoryId));
formData.append('description', uploadForm.description || '');
const res = await resourceApi.uploadResource(formData);
if (res.code === 200) {
ElMessage.success('资源上传成功');
uploadDialogVisible.value = false;
resetUploadForm();
fetchResources();
}
} catch (error) {
console.error('上传资源失败:', error);
ElMessage.error('上传资源失败');
} finally {
uploading.value = false;
}
}
});
};
//
const resetUploadForm = () => {
uploadForm.title = '';
uploadForm.description = '';
uploadForm.categoryId = undefined;
uploadForm.file = null;
if (uploadFormRef.value) {
uploadFormRef.value.resetFields();
}
};
//
const formatFileSize = (size: number) => {
if (size < 1024) {
return size + ' B';
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB';
} else if (size < 1024 * 1024 * 1024) {
return (size / (1024 * 1024)).toFixed(2) + ' MB';
} else {
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
};
</script>
<style scoped>
.resource-list-container {
padding: 20px;
}
.resource-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.resource-filters {
margin-bottom: 20px;
}
.filter-card {
border-radius: 8px;
}
.filter-row {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.filter-item {
display: flex;
align-items: center;
}
.filter-label {
margin-right: 10px;
font-weight: 500;
}
.search-input {
width: 250px;
}
.search-icon {
cursor: pointer;
}
.resource-content {
margin-top: 20px;
}
.resource-col {
margin-bottom: 20px;
}
.resource-card {
height: 100%;
cursor: pointer;
transition: transform 0.3s;
}
.resource-card:hover {
transform: translateY(-5px);
}
.resource-icon {
display: flex;
justify-content: center;
margin-bottom: 15px;
color: var(--el-color-primary);
}
.resource-title {
font-size: 16px;
margin: 10px 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.resource-description {
color: #666;
font-size: 14px;
margin: 10px 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.resource-meta {
display: flex;
justify-content: space-between;
margin: 10px 0;
font-size: 13px;
}
.resource-category {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
padding: 2px 8px;
border-radius: 4px;
}
.resource-size {
color: #999;
}
.resource-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
font-size: 13px;
}
.resource-uploader {
display: flex;
align-items: center;
gap: 5px;
}
.resource-stats {
display: flex;
gap: 10px;
}
.resource-downloads, .resource-likes {
display: flex;
align-items: center;
gap: 4px;
color: #666;
}
.pagination-container {
margin-top: 30px;
display: flex;
justify-content: center;
}
.resource-upload {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

@ -1,868 +0,0 @@
<template>
<div class="course-table-container">
<div class="course-header">
<h1 class="page-title">课程表</h1>
<div class="course-actions">
<el-button type="primary" @click="handleAddCourse" v-if="isLoggedIn">
<el-icon><Plus /></el-icon>
</el-button>
</div>
</div>
<div class="course-filters">
<el-card shadow="never" class="filter-card">
<div class="filter-row">
<div class="semester-selector">
<span class="filter-label">学期</span>
<el-select v-model="currentSemester" placeholder="选择学期" style="min-width: 220px;" @change="handleSemesterChange">
<el-option label="2023-2024学年第一学期" value="2023-1"></el-option>
<el-option label="2023-2024学年第二学期" value="2023-2"></el-option>
</el-select>
</div>
<div class="week-selector">
<span class="filter-label">周次</span>
<el-select v-model="currentWeek" placeholder="选择周次">
<el-option v-for="week in 20" :key="week" :label="`第${week}周`" :value="week"></el-option>
</el-select>
</div>
<div class="view-selector">
<el-radio-group v-model="viewMode" size="small">
<el-radio-button label="week">周视图</el-radio-button>
<el-radio-button label="day">日视图</el-radio-button>
</el-radio-group>
</div>
</div>
</el-card>
</div>
<!-- 周视图 -->
<div v-if="viewMode === 'week'" class="week-view">
<el-card shadow="hover" class="timetable-card">
<div class="timetable">
<!-- 时间列 -->
<div class="time-column">
<div class="header-cell"></div>
<div class="time-cell" v-for="time in timeSlots" :key="time.id">
{{ time.label }}
</div>
</div>
<!-- 星期列 -->
<div v-for="day in 7" :key="day" class="day-column">
<div class="header-cell">{{ getDayLabel(day) }}</div>
<div
class="course-cell"
v-for="time in timeSlots"
:key="time.id"
@click="handleCellClick(day, time.id)"
>
<div
v-for="course in getCoursesForTimeSlot(day, time.id)"
:key="course.id"
class="course-item"
:style="{ backgroundColor: course.color || '#409EFF' }"
@click.stop="handleCourseClick(course)"
>
<div class="course-name">{{ course.name }}</div>
<div class="course-info">{{ course.location }}</div>
<div class="course-info">{{ course.teacher }}</div>
</div>
</div>
</div>
</div>
</el-card>
</div>
<!-- 日视图 -->
<div v-else class="day-view">
<el-card shadow="hover" class="day-card">
<div class="day-header">
<el-button-group>
<el-button @click="previousDay">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button>{{ getDayLabel(currentDay) }}</el-button>
<el-button @click="nextDay">
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
<div class="day-timetable">
<div class="time-column">
<div class="time-cell" v-for="time in timeSlots" :key="time.id">
{{ time.label }}
</div>
</div>
<div class="day-courses-column">
<div
class="course-cell"
v-for="time in timeSlots"
:key="time.id"
@click="handleCellClick(currentDay, time.id)"
>
<div
v-for="course in getCoursesForTimeSlot(currentDay, time.id)"
:key="course.id"
class="course-item day-course-item"
:style="{ backgroundColor: course.color || '#409EFF' }"
@click.stop="handleCourseClick(course)"
>
<div class="course-name">{{ course.name }}</div>
<div class="course-info">{{ course.location }}</div>
<div class="course-info">{{ course.teacher }}</div>
</div>
</div>
</div>
</div>
</el-card>
</div>
<!-- 添加/编辑课程对话框 -->
<el-dialog
v-model="courseDialogVisible"
:title="isEditing ? '编辑课程' : '添加课程'"
width="500px"
>
<el-form :model="courseForm" label-width="80px" :rules="courseRules" ref="courseFormRef">
<el-form-item label="课程名称" prop="name">
<el-input v-model="courseForm.name" placeholder="请输入课程名称"></el-input>
</el-form-item>
<el-form-item label="教师" prop="teacher">
<el-input v-model="courseForm.teacher" placeholder="请输入教师姓名"></el-input>
</el-form-item>
<el-form-item label="地点" prop="location">
<el-input v-model="courseForm.location" placeholder="请输入上课地点"></el-input>
</el-form-item>
<el-form-item label="星期" prop="dayOfWeek">
<el-select v-model="courseForm.dayOfWeek" placeholder="请选择星期">
<el-option v-for="day in 7" :key="day" :label="getDayLabel(day)" :value="day"></el-option>
</el-select>
</el-form-item>
<el-form-item label="开始时间" prop="startTime">
<el-time-select
v-model="courseForm.startTime"
placeholder="请选择开始时间"
start="08:00"
step="00:30"
end="22:00"
></el-time-select>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-time-select
v-model="courseForm.endTime"
placeholder="请选择结束时间"
start="08:00"
step="00:30"
end="22:00"
:min-time="courseForm.startTime"
></el-time-select>
</el-form-item>
<el-form-item label="开始周次" prop="startWeek">
<el-input-number v-model="courseForm.startWeek" :min="1" :max="20"></el-input-number>
</el-form-item>
<el-form-item label="结束周次" prop="endWeek">
<el-input-number v-model="courseForm.endWeek" :min="courseForm.startWeek" :max="20"></el-input-number>
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="courseForm.color"></el-color-picker>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="courseDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitCourse" :loading="submitting">
保存
</el-button>
</span>
</template>
</el-dialog>
<!-- 课程详情对话框 -->
<el-dialog
v-model="courseDetailVisible"
title="课程详情"
width="400px"
>
<div v-if="selectedCourse" class="course-detail">
<h3 class="detail-title">{{ selectedCourse.name }}</h3>
<div class="detail-item">
<span class="detail-label">教师</span>
<span>{{ selectedCourse.teacher || '未设置' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">地点</span>
<span>{{ selectedCourse.location || '未设置' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">时间</span>
<span>{{ getDayLabel(selectedCourse.dayOfWeek) }} {{ selectedCourse.startTime }} - {{ selectedCourse.endTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">周次</span>
<span>{{ selectedCourse.startWeek }} - {{ selectedCourse.endWeek }}</span>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="courseDetailVisible = false">关闭</el-button>
<el-button type="primary" @click="handleEditCourse" v-if="isOwner"></el-button>
<el-button type="danger" @click="handleDeleteCourse" v-if="isOwner"></el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus';
import { scheduleApi } from '@/api';
import { useUserStore } from '@/stores';
import { Plus, ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
const userStore = useUserStore();
const isLoggedIn = computed(() => userStore.isLoggedIn);
//
const courses = ref<any[]>([]);
const loading = ref(false);
const currentSemester = ref('2023-1');
const currentWeek = ref(1);
const viewMode = ref('week');
const currentDay = ref(1); // 1-7
//
const timeSlots = [
{ id: 1, label: '第1节 8:00-8:45' },
{ id: 2, label: '第2节 8:55-9:40' },
{ id: 3, label: '第3节 10:00-10:45' },
{ id: 4, label: '第4节 10:55-11:40' },
{ id: 5, label: '第5节 13:30-14:15' },
{ id: 6, label: '第6节 14:25-15:10' },
{ id: 7, label: '第7节 15:30-16:15' },
{ id: 8, label: '第8节 16:25-17:10' },
{ id: 9, label: '第9节 18:30-19:15' },
{ id: 10, label: '第10节 19:25-20:10' },
{ id: 11, label: '第11节 20:20-21:05' },
{ id: 12, label: '第12节 21:15-22:00' }
];
//
const courseDialogVisible = ref(false);
const courseFormRef = ref<FormInstance>();
const submitting = ref(false);
const isEditing = ref(false);
const courseForm = reactive({
id: undefined as number | undefined,
name: '',
teacher: '',
location: '',
dayOfWeek: 1,
startTime: '',
endTime: '',
startWeek: 1,
endWeek: 16,
semester: '2023-1', //
color: '#409EFF'
});
const courseRules = {
name: [{ required: true, message: '请输入课程名称', trigger: 'blur' }],
dayOfWeek: [{ required: true, message: '请选择星期', trigger: 'change' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
startWeek: [{ required: true, message: '请输入开始周次', trigger: 'blur' }],
endWeek: [{ required: true, message: '请输入结束周次', trigger: 'blur' }]
};
//
const courseDetailVisible = ref(false);
const selectedCourse = ref<any>(null);
const isOwner = computed(() => {
if (!selectedCourse.value || !userStore.userInfo) return false;
return selectedCourse.value.userId === userStore.userInfo.id;
});
//
onMounted(async () => {
await fetchCourses();
});
//
const fetchCourses = async () => {
loading.value = true;
try {
let res;
if (currentSemester.value) {
//
res = await scheduleApi.getCoursesBySemester(currentSemester.value);
} else {
//
res = await scheduleApi.getAllCourses();
}
if (res.code === 200) {
courses.value = res.data.list;
}
} catch (error) {
console.error('获取课程列表失败:', error);
ElMessage.error('获取课程列表失败');
} finally {
loading.value = false;
}
};
//
const getDayLabel = (day: number) => {
const days = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return days[day] || '';
};
//
const getCoursesForTimeSlot = (day: number, timeSlotId: number) => {
return courses.value.filter(course => {
//
if (course.dayOfWeek !== day) return false;
//
if (currentWeek.value < course.startWeek || currentWeek.value > course.endWeek) return false;
//
const courseStartHour = parseInt(course.startTime.split(':')[0]);
const courseStartMinute = parseInt(course.startTime.split(':')[1]);
const courseEndHour = parseInt(course.endTime.split(':')[0]);
const courseEndMinute = parseInt(course.endTime.split(':')[1]);
const slotStartHour = parseInt(timeSlots[timeSlotId - 1].label.split(' ')[1].split('-')[0].split(':')[0]);
const slotStartMinute = parseInt(timeSlots[timeSlotId - 1].label.split(' ')[1].split('-')[0].split(':')[1]);
const slotEndHour = parseInt(timeSlots[timeSlotId - 1].label.split(' ')[1].split('-')[1].split(':')[0]);
const slotEndMinute = parseInt(timeSlots[timeSlotId - 1].label.split(' ')[1].split('-')[1].split(':')[1]);
const courseStartTime = courseStartHour * 60 + courseStartMinute;
const courseEndTime = courseEndHour * 60 + courseEndMinute;
const slotStartTime = slotStartHour * 60 + slotStartMinute;
const slotEndTime = slotEndHour * 60 + slotEndMinute;
// true
return (courseStartTime <= slotEndTime && courseEndTime >= slotStartTime);
});
};
//
const handleCellClick = (day: number, timeSlotId: number) => {
if (!isLoggedIn.value) {
ElMessage.warning('请先登录');
return;
}
//
isEditing.value = false;
courseForm.id = undefined;
courseForm.name = '';
courseForm.teacher = '';
courseForm.location = '';
courseForm.dayOfWeek = day;
//
const timeSlot = timeSlots[timeSlotId - 1];
const timeRange = timeSlot.label.split(' ')[1].split('-');
courseForm.startTime = timeRange[0];
courseForm.endTime = timeRange[1];
courseForm.startWeek = currentWeek.value;
courseForm.endWeek = currentWeek.value + 15 > 20 ? 20 : currentWeek.value + 15;
courseForm.semester = currentSemester.value; //
courseForm.color = getRandomColor();
courseDialogVisible.value = true;
};
//
const handleCourseClick = (course: any) => {
selectedCourse.value = course;
courseDetailVisible.value = true;
};
//
const handleAddCourse = () => {
if (!isLoggedIn.value) {
ElMessage.warning('请先登录');
return;
}
isEditing.value = false;
courseForm.id = undefined;
courseForm.name = '';
courseForm.teacher = '';
courseForm.location = '';
courseForm.dayOfWeek = 1;
courseForm.startTime = '08:00';
courseForm.endTime = '09:40';
courseForm.startWeek = 1;
courseForm.endWeek = 16;
courseForm.semester = currentSemester.value; //
courseForm.color = getRandomColor();
courseDialogVisible.value = true;
};
//
const handleEditCourse = () => {
if (!selectedCourse.value) return;
isEditing.value = true;
courseForm.id = selectedCourse.value.id;
courseForm.name = selectedCourse.value.name;
courseForm.teacher = selectedCourse.value.teacher || '';
courseForm.location = selectedCourse.value.location || '';
courseForm.dayOfWeek = selectedCourse.value.dayOfWeek;
courseForm.startTime = selectedCourse.value.startTime;
courseForm.endTime = selectedCourse.value.endTime;
courseForm.startWeek = selectedCourse.value.startWeek;
courseForm.endWeek = selectedCourse.value.endWeek;
courseForm.color = selectedCourse.value.color || '#409EFF';
courseDetailVisible.value = false;
courseDialogVisible.value = true;
};
//
const handleDeleteCourse = () => {
if (!selectedCourse.value) return;
ElMessageBox.confirm(
'确定要删除该课程吗?此操作不可恢复',
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
const res = await scheduleApi.deleteCourse(selectedCourse.value.id);
if (res.code === 200) {
ElMessage.success('课程删除成功');
courseDetailVisible.value = false;
fetchCourses();
}
} catch (error) {
console.error('删除课程失败:', error);
ElMessage.error('删除课程失败');
}
}).catch(() => {
//
});
};
//
const submitCourse = async () => {
if (!courseFormRef.value) return;
await courseFormRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true;
try {
// HH:mm:ss
const formatTime = (time: string) => {
if (!time) return '';
//
let formattedTime = time.trim();
// HH:mm:ss
if (/^\d{2}:\d{2}:\d{2}$/.test(formattedTime)) {
return formattedTime;
}
// H:mm HH:mm
if (/^\d{1,2}:\d{2}$/.test(formattedTime)) {
const parts = formattedTime.split(':');
const hours = parts[0].padStart(2, '0'); //
const minutes = parts[1];
return `${hours}:${minutes}:00`;
}
// H:mm:ss
if (/^\d{1}:\d{2}:\d{2}$/.test(formattedTime)) {
const parts = formattedTime.split(':');
const hours = parts[0].padStart(2, '0');
return `${hours}:${parts[1]}:${parts[2]}`;
}
//
try {
const parts = formattedTime.split(':');
if (parts.length >= 2) {
const hours = parts[0].padStart(2, '0');
const minutes = parts[1].padStart(2, '0');
const seconds = parts[2] ? parts[2].padStart(2, '0') : '00';
return `${hours}:${minutes}:${seconds}`;
}
} catch (e) {
console.error('时间格式化失败:', time, e);
}
return time; //
};
//
const conflictParams = {
dayOfWeek: courseForm.dayOfWeek,
startTime: formatTime(courseForm.startTime),
endTime: formatTime(courseForm.endTime),
excludeCourseId: courseForm.id
};
const conflictRes = await scheduleApi.checkCourseConflict(conflictParams);
if (conflictRes.code === 200 && conflictRes.data.hasConflict) {
ElMessageBox.confirm(
`检测到时间冲突,有${conflictRes.data.conflictCount}门课程与当前时间段冲突,是否继续?`,
'时间冲突',
{
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
saveCourse();
}).catch(() => {
submitting.value = false;
});
} else {
saveCourse();
}
} catch (error) {
console.error('检查时间冲突失败:', error);
ElMessage.error('检查时间冲突失败');
submitting.value = false;
}
}
});
};
//
const saveCourse = async () => {
try {
// HH:mm:ss
const formatTime = (time: string) => {
if (!time) return '';
//
let formattedTime = time.trim();
// HH:mm:ss
if (/^\d{2}:\d{2}:\d{2}$/.test(formattedTime)) {
return formattedTime;
}
// H:mm HH:mm
if (/^\d{1,2}:\d{2}$/.test(formattedTime)) {
const parts = formattedTime.split(':');
const hours = parts[0].padStart(2, '0'); //
const minutes = parts[1];
return `${hours}:${minutes}:00`;
}
// H:mm:ss
if (/^\d{1}:\d{2}:\d{2}$/.test(formattedTime)) {
const parts = formattedTime.split(':');
const hours = parts[0].padStart(2, '0');
return `${hours}:${parts[1]}:${parts[2]}`;
}
//
try {
const parts = formattedTime.split(':');
if (parts.length >= 2) {
const hours = parts[0].padStart(2, '0');
const minutes = parts[1].padStart(2, '0');
const seconds = parts[2] ? parts[2].padStart(2, '0') : '00';
return `${hours}:${minutes}:${seconds}`;
}
} catch (e) {
console.error('时间格式化失败:', time, e);
}
return time; //
};
//
const courseData = {
name: courseForm.name,
teacher: courseForm.teacher || undefined,
location: courseForm.location || undefined,
dayOfWeek: courseForm.dayOfWeek,
startTime: formatTime(courseForm.startTime),
endTime: formatTime(courseForm.endTime),
startWeek: courseForm.startWeek,
endWeek: courseForm.endWeek,
semester: courseForm.semester,
color: courseForm.color || '#409EFF'
};
//
console.log('原始时间数据:', {
startTime: courseForm.startTime,
endTime: courseForm.endTime
});
console.log('格式化后时间:', {
startTime: courseData.startTime,
endTime: courseData.endTime
});
console.log('完整发送数据:', courseData);
//
if (!courseForm.teacher) {
delete (courseData as any).teacher;
}
if (!courseForm.location) {
delete (courseData as any).location;
}
let res;
if (isEditing.value && courseForm.id) {
//
res = await scheduleApi.updateCourse(courseForm.id, courseData);
} else {
//
res = await scheduleApi.createCourse(courseData);
}
if (res.code === 200) {
ElMessage.success(isEditing.value ? '课程更新成功' : '课程添加成功');
courseDialogVisible.value = false;
fetchCourses();
}
} catch (error) {
console.error(isEditing.value ? '更新课程失败:' : '添加课程失败:', error);
ElMessage.error(isEditing.value ? '更新课程失败' : '添加课程失败');
} finally {
submitting.value = false;
}
};
//
const previousDay = () => {
currentDay.value = currentDay.value === 1 ? 7 : currentDay.value - 1;
};
//
const nextDay = () => {
currentDay.value = currentDay.value === 7 ? 1 : currentDay.value + 1;
};
//
const getRandomColor = () => {
const colors = [
'#409EFF', //
'#67C23A', // 绿
'#E6A23C', //
'#F56C6C', //
'#909399', //
'#9966CC', //
'#FF9900', //
'#19CAAD', //
'#8CC7B5', // 绿
'#A0EEE1', //
'#BEE7E9', //
'#BEEDC7', // 绿
'#D6D5B7', //
'#D1BA74', //
'#E6CEAC', //
'#ECAD9E' //
];
return colors[Math.floor(Math.random() * colors.length)];
};
//
const handleSemesterChange = () => {
fetchCourses();
};
</script>
<style scoped>
.course-table-container {
padding: 20px;
}
.course-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.course-filters {
margin-bottom: 20px;
}
.filter-card {
border-radius: 8px;
}
.filter-row {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.filter-label {
margin-right: 10px;
font-weight: 500;
}
/* 周视图样式 */
.week-view {
margin-top: 20px;
}
.timetable-card {
border-radius: 8px;
}
.timetable {
display: flex;
min-height: 800px;
}
.time-column, .day-column {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #eee;
}
.time-column {
flex: 0 0 120px;
}
.header-cell {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
background-color: #f5f7fa;
border-bottom: 1px solid #eee;
}
.time-cell {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #eee;
font-size: 12px;
}
.course-cell {
height: 60px;
border-bottom: 1px solid #eee;
position: relative;
cursor: pointer;
}
.course-item {
position: absolute;
top: 2px;
left: 2px;
right: 2px;
bottom: 2px;
border-radius: 4px;
padding: 4px;
color: white;
font-size: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
}
.course-item:hover {
transform: scale(1.02);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.course-name {
font-weight: bold;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.course-info {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 10px;
}
/* 日视图样式 */
.day-view {
margin-top: 20px;
}
.day-card {
border-radius: 8px;
}
.day-header {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.day-timetable {
display: flex;
min-height: 800px;
}
.day-courses-column {
flex: 1;
display: flex;
flex-direction: column;
}
.day-course-item {
padding: 8px;
}
/* 课程详情样式 */
.course-detail {
padding: 10px;
}
.detail-title {
font-size: 18px;
margin-bottom: 15px;
color: #303133;
}
.detail-item {
margin-bottom: 10px;
display: flex;
}
.detail-label {
font-weight: 500;
width: 60px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

@ -1,839 +0,0 @@
<template>
<div class="schedule-container">
<div class="schedule-header">
<h1 class="page-title">日程管理</h1>
<div class="schedule-actions">
<el-button type="primary" @click="handleAddSchedule" v-if="isLoggedIn">
<el-icon><Plus /></el-icon>
</el-button>
</div>
</div>
<div class="schedule-content">
<el-row :gutter="20">
<!-- 日历视图 -->
<el-col :span="16">
<el-card shadow="hover" class="calendar-card">
<div class="calendar-header">
<div class="month-selector">
<el-button-group>
<el-button @click="prevMonth">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button>{{ currentYearMonth }}</el-button>
<el-button @click="nextMonth">
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
<div class="view-selector">
<el-radio-group v-model="calendarView" size="small">
<el-radio-button label="month">月视图</el-radio-button>
<el-radio-button label="week">周视图</el-radio-button>
<el-radio-button label="day">日视图</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="calendar-body">
<!-- 月视图 -->
<div v-if="calendarView === 'month'" class="month-view">
<!-- 星期标题 -->
<div class="week-header">
<div v-for="day in weekDays" :key="day" class="week-day">{{ day }}</div>
</div>
<!-- 日期网格 -->
<div class="month-grid">
<div
v-for="(date, index) in calendarDays"
:key="index"
class="date-cell"
:class="{
'other-month': date.otherMonth,
'today': isToday(date.date),
'has-events': hasEvents(date.date)
}"
@click="selectDate(date.date)"
>
<div class="date-number">{{ date.day }}</div>
<div class="date-events">
<div
v-for="event in getEventsForDate(date.date)"
:key="event.id"
class="event-dot"
:style="{ backgroundColor: event.color || '#409EFF' }"
:title="event.title"
></div>
</div>
</div>
</div>
</div>
<!-- 周视图 -->
<div v-else-if="calendarView === 'week'" class="week-view">
<!-- 星期标题 -->
<div class="week-header">
<div class="time-column-header"></div>
<div
v-for="(date, index) in weekDates"
:key="index"
class="week-day-header"
:class="{ 'today': isToday(date) }"
>
<div>{{ weekDays[index] }}</div>
<div>{{ formatDate(date, 'MM-DD') }}</div>
</div>
</div>
<!-- 时间网格 -->
<div class="week-grid">
<div class="time-column">
<div
v-for="hour in 24"
:key="hour"
class="time-cell"
>
{{ formatHour(hour - 1) }}
</div>
</div>
<div
v-for="(date, dayIndex) in weekDates"
:key="dayIndex"
class="day-column"
>
<div
v-for="hour in 24"
:key="hour"
class="hour-cell"
@click="handleAddScheduleAt(date, hour - 1)"
>
<div
v-for="event in getEventsForDateAndHour(date, hour - 1)"
:key="event.id"
class="event-item"
:style="{
backgroundColor: event.color || '#409EFF',
top: calculateEventTop(event, hour - 1) + 'px',
height: calculateEventHeight(event) + 'px'
}"
@click.stop="handleEventClick(event)"
>
<div class="event-title">{{ event.title }}</div>
<div class="event-time">{{ formatEventTime(event) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 日视图 -->
<div v-else class="day-view">
<!-- 日期标题 -->
<div class="day-header">
<div class="time-column-header"></div>
<div
class="day-date-header"
:class="{ 'today': isToday(selectedDate) }"
>
<div>{{ formatDate(selectedDate, 'YYYY年MM月DD日') }}</div>
<div>{{ weekDays[new Date(selectedDate).getDay()] }}</div>
</div>
</div>
<!-- 时间网格 -->
<div class="day-grid">
<div class="time-column">
<div
v-for="hour in 24"
:key="hour"
class="time-cell"
>
{{ formatHour(hour - 1) }}
</div>
</div>
<div class="day-events-column">
<div
v-for="hour in 24"
:key="hour"
class="hour-cell"
@click="handleAddScheduleAt(selectedDate, hour - 1)"
>
<div
v-for="event in getEventsForDateAndHour(selectedDate, hour - 1)"
:key="event.id"
class="event-item day-event-item"
:style="{
backgroundColor: event.color || '#409EFF',
top: calculateEventTop(event, hour - 1) + 'px',
height: calculateEventHeight(event) + 'px'
}"
@click.stop="handleEventClick(event)"
>
<div class="event-title">{{ event.title }}</div>
<div class="event-time">{{ formatEventTime(event) }}</div>
<div v-if="event.location" class="event-location">
<el-icon><Location /></el-icon> {{ event.location }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</el-card>
</el-col>
<!-- 日程列表 -->
<el-col :span="8">
<el-card shadow="hover" class="events-card">
<template #header>
<div class="events-header">
<span>{{ formatDate(selectedDate, 'YYYY年MM月DD日') }} 的日程</span>
<el-button
type="primary"
size="small"
@click="handleAddScheduleAt(selectedDate)"
v-if="isLoggedIn"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
<div class="events-list">
<el-empty v-if="selectedDateEvents.length === 0" description="暂无日程" />
<el-timeline v-else>
<el-timeline-item
v-for="event in selectedDateEvents"
:key="event.id"
:color="event.color || '#409EFF'"
:timestamp="formatEventTime(event)"
>
<el-card class="event-card" @click="handleEventClick(event)">
<h4>{{ event.title }}</h4>
<p v-if="event.location">
<el-icon><Location /></el-icon> {{ event.location }}
</p>
<p v-if="event.description" class="event-description">
{{ event.description }}
</p>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
<!-- 日程对话框 -->
<ScheduleDialog
v-model:visible="scheduleDialogVisible"
:is-editing="isEditingSchedule"
:schedule="currentSchedule"
:initial-date="dialogInitialDate"
:initial-hour="dialogInitialHour"
@submit="handleScheduleSubmit"
/>
<!-- 日程详情对话框 -->
<ScheduleDetailDialog
v-model:visible="scheduleDetailVisible"
:schedule="currentSchedule"
@edit="handleEditSchedule"
@delete="handleDeleteSchedule"
/>
</template>
<style scoped>
.schedule-container {
padding: 20px;
}
.schedule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.schedule-content {
margin-top: 20px;
}
.calendar-card, .events-card {
border-radius: 8px;
height: 100%;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
/* 月视图样式 */
.month-view {
min-height: 600px;
}
.week-header {
display: flex;
border-bottom: 1px solid #eee;
}
.week-day {
flex: 1;
text-align: center;
padding: 10px;
font-weight: bold;
}
.month-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: minmax(100px, auto);
gap: 1px;
background-color: #eee;
}
.date-cell {
background-color: white;
padding: 5px;
min-height: 100px;
cursor: pointer;
}
.date-cell:hover {
background-color: #f5f7fa;
}
.date-cell.other-month {
color: #c0c4cc;
}
.date-cell.today {
background-color: #ecf5ff;
}
.date-cell.has-events .date-number {
font-weight: bold;
color: var(--el-color-primary);
}
.date-number {
font-size: 14px;
margin-bottom: 5px;
}
.date-events {
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* 周视图和日视图样式 */
.week-view, .day-view {
min-height: 600px;
}
.week-day-header, .day-date-header {
flex: 1;
text-align: center;
padding: 10px;
font-weight: bold;
display: flex;
flex-direction: column;
gap: 5px;
}
.week-day-header.today, .day-date-header.today {
background-color: #ecf5ff;
color: var(--el-color-primary);
}
.time-column-header {
width: 60px;
}
.week-grid, .day-grid {
display: flex;
height: 600px;
overflow-y: auto;
}
.time-column {
width: 60px;
flex-shrink: 0;
border-right: 1px solid #eee;
}
.time-cell {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #eee;
font-size: 12px;
}
.day-column, .day-events-column {
flex: 1;
position: relative;
border-right: 1px solid #eee;
}
.hour-cell {
height: 60px;
border-bottom: 1px solid #eee;
position: relative;
}
.event-item {
position: absolute;
left: 2px;
right: 2px;
border-radius: 4px;
padding: 4px;
color: white;
font-size: 12px;
overflow: hidden;
cursor: pointer;
z-index: 1;
transition: all 0.3s;
}
.event-item:hover {
transform: scale(1.02);
z-index: 2;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.event-title {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-time, .event-location {
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 2px;
}
.day-event-item {
padding: 8px;
}
/* 日程列表样式 */
.events-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.events-list {
max-height: 600px;
overflow-y: auto;
}
.event-card {
cursor: pointer;
transition: all 0.3s;
}
.event-card:hover {
transform: translateY(-2px);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.event-description {
color: #606266;
font-size: 12px;
margin-top: 5px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox, FormInstance } from 'element-plus';
import { scheduleApi } from '@/api';
import { useUserStore } from '@/stores';
import { Plus, ArrowLeft, ArrowRight, Location } from '@element-plus/icons-vue';
import dayjs from 'dayjs';
import ScheduleDialog from './components/ScheduleDialog.vue';
import ScheduleDetailDialog from './components/ScheduleDetailDialog.vue';
const userStore = useUserStore();
const isLoggedIn = computed(() => userStore.isLoggedIn);
//
const calendarView = ref('month');
const currentDate = ref(new Date());
const selectedDate = ref(new Date());
const events = ref<any[]>([]);
const loading = ref(false);
//
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
//
const currentYearMonth = computed(() => {
return dayjs(currentDate.value).format('YYYY年MM月');
});
const calendarDays = computed(() => {
const year = currentDate.value.getFullYear();
const month = currentDate.value.getMonth();
//
const firstDay = new Date(year, month, 1).getDay();
//
const daysInMonth = new Date(year, month + 1, 0).getDate();
//
const daysInPrevMonth = new Date(year, month, 0).getDate();
const days = [];
//
for (let i = firstDay - 1; i >= 0; i--) {
const prevMonthDay = daysInPrevMonth - i;
const date = new Date(year, month - 1, prevMonthDay);
days.push({
day: prevMonthDay,
date,
otherMonth: true
});
}
//
for (let i = 1; i <= daysInMonth; i++) {
const date = new Date(year, month, i);
days.push({
day: i,
date,
otherMonth: false
});
}
// 4267
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
const date = new Date(year, month + 1, i);
days.push({
day: i,
date,
otherMonth: true
});
}
return days;
});
const weekDates = computed(() => {
const date = new Date(selectedDate.value);
const day = date.getDay(); // 0-6, 0 is Sunday
//
date.setDate(date.getDate() - day);
// 7
const dates = [];
for (let i = 0; i < 7; i++) {
const weekDate = new Date(date);
weekDate.setDate(date.getDate() + i);
dates.push(weekDate);
}
return dates;
});
const selectedDateEvents = computed(() => {
return events.value.filter(event => {
const eventDate = new Date(event.startTime);
return isSameDay(eventDate, selectedDate.value);
}).sort((a, b) => {
return new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
});
});
//
onMounted(async () => {
await fetchEvents();
});
//
const fetchEvents = async () => {
loading.value = true;
try {
const res = await scheduleApi.getAllSchedules();
if (res.code === 200) {
events.value = res.data.list;
}
} catch (error) {
console.error('获取日程列表失败:', error);
ElMessage.error('获取日程列表失败');
} finally {
loading.value = false;
}
};
//
const prevMonth = () => {
const date = new Date(currentDate.value);
date.setMonth(date.getMonth() - 1);
currentDate.value = date;
};
const nextMonth = () => {
const date = new Date(currentDate.value);
date.setMonth(date.getMonth() + 1);
currentDate.value = date;
};
const selectDate = (date: Date) => {
selectedDate.value = date;
};
//
const isToday = (date: Date) => {
const today = new Date();
return isSameDay(date, today);
};
const isSameDay = (date1: Date, date2: Date) => {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
};
const formatDate = (date: Date, format: string = 'YYYY-MM-DD') => {
return dayjs(date).format(format);
};
const formatHour = (hour: number) => {
return `${hour.toString().padStart(2, '0')}:00`;
};
const formatEventTime = (event: any) => {
const startTime = dayjs(event.startTime);
const endTime = dayjs(event.endTime);
if (event.isAllDay) {
return '全天';
}
return `${startTime.format('HH:mm')} - ${endTime.format('HH:mm')}`;
};
//
const hasEvents = (date: Date) => {
return events.value.some(event => {
const eventDate = new Date(event.startTime);
return isSameDay(eventDate, date);
});
};
const getEventsForDate = (date: Date) => {
return events.value.filter(event => {
const eventDate = new Date(event.startTime);
return isSameDay(eventDate, date);
});
};
const getEventsForDateAndHour = (date: Date, hour: number) => {
return events.value.filter(event => {
const eventStartDate = new Date(event.startTime);
const eventEndDate = new Date(event.endTime);
//
if (!isSameDay(eventStartDate, date)) {
return false;
}
//
const eventStartHour = eventStartDate.getHours();
const eventEndHour = eventEndDate.getHours();
//
if (event.isAllDay) {
return true;
}
//
return (eventStartHour <= hour && eventEndHour > hour) ||
(eventStartHour === hour);
});
};
const calculateEventTop = (event: any, hour: number) => {
const eventStartDate = new Date(event.startTime);
const eventStartHour = eventStartDate.getHours();
const eventStartMinute = eventStartDate.getMinutes();
//
if (event.isAllDay) {
return 0;
}
//
if (eventStartHour === hour) {
return (eventStartMinute / 60) * 60; // 60px
}
return 0;
};
const calculateEventHeight = (event: any) => {
const eventStartDate = new Date(event.startTime);
const eventEndDate = new Date(event.endTime);
//
if (event.isAllDay) {
return 30;
}
//
const durationMinutes = (eventEndDate.getTime() - eventStartDate.getTime()) / (1000 * 60);
// 60px
return (durationMinutes / 60) * 60;
};
//
const scheduleDialogVisible = ref(false);
const scheduleDetailVisible = ref(false);
const isEditingSchedule = ref(false);
const currentSchedule = ref<any>(null);
const dialogInitialDate = ref<Date | null>(null);
const dialogInitialHour = ref<number | undefined>(undefined);
//
const handleAddSchedule = () => {
if (!isLoggedIn.value) {
ElMessage.warning('请先登录');
return;
}
// 使
handleAddScheduleAt(selectedDate.value);
};
//
const handleAddScheduleAt = (date: Date, hour?: number) => {
if (!isLoggedIn.value) {
ElMessage.warning('请先登录');
return;
}
isEditingSchedule.value = false;
currentSchedule.value = null;
dialogInitialDate.value = date;
dialogInitialHour.value = hour;
scheduleDialogVisible.value = true;
};
//
const handleEventClick = (event: any) => {
currentSchedule.value = event;
scheduleDetailVisible.value = true;
};
//
const handleEditSchedule = (schedule: any) => {
isEditingSchedule.value = true;
currentSchedule.value = schedule;
scheduleDialogVisible.value = true;
};
//
const handleDeleteSchedule = async (id: number) => {
try {
const res = await scheduleApi.deleteSchedule(id);
if (res.code === 200) {
ElMessage.success('日程删除成功');
fetchEvents();
}
} catch (error) {
console.error('删除日程失败:', error);
ElMessage.error('删除日程失败');
}
};
//
const handleScheduleSubmit = async (data: any) => {
try {
let res;
if (isEditingSchedule.value && data.id) {
//
res = await scheduleApi.updateSchedule(data.id, {
title: data.title,
description: data.description,
startTime: data.startTime,
endTime: data.endTime,
location: data.location,
isAllDay: data.isAllDay,
reminder: data.reminder,
color: data.color
});
if (res.code === 200) {
ElMessage.success('日程更新成功');
}
} else {
//
res = await scheduleApi.createSchedule({
title: data.title,
description: data.description,
startTime: data.startTime,
endTime: data.endTime,
location: data.location,
isAllDay: data.isAllDay,
reminder: data.reminder,
color: data.color
});
if (res.code === 200) {
ElMessage.success('日程创建成功');
}
}
//
fetchEvents();
} catch (error) {
console.error('提交日程失败:', error);
ElMessage.error('提交日程失败');
}
};
</script>

@ -1,164 +0,0 @@
<template>
<el-dialog
v-model="dialogVisible"
title="日程详情"
width="400px"
>
<div v-if="schedule" class="schedule-detail">
<h3 class="detail-title" :style="{ color: schedule.color || '#409EFF' }">{{ schedule.title }}</h3>
<div class="detail-item">
<span class="detail-label">时间</span>
<span>{{ formatScheduleTime(schedule) }}</span>
</div>
<div class="detail-item" v-if="schedule.location">
<span class="detail-label">地点</span>
<span>{{ schedule.location }}</span>
</div>
<div class="detail-item" v-if="schedule.reminder">
<span class="detail-label">提醒</span>
<span>{{ formatReminder(schedule.reminder) }}</span>
</div>
<div class="detail-item" v-if="schedule.description">
<span class="detail-label">描述</span>
<div class="detail-description">{{ schedule.description }}</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">关闭</el-button>
<el-button type="primary" @click="handleEdit" v-if="isOwner"></el-button>
<el-button type="danger" @click="handleDelete" v-if="isOwner"></el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, defineProps, defineEmits } from 'vue';
import { ElMessageBox } from 'element-plus';
import dayjs from 'dayjs';
import { useUserStore } from '@/stores';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
schedule: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['update:visible', 'edit', 'delete']);
//
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
});
//
const userStore = useUserStore();
const isOwner = computed(() => {
if (!props.schedule || !userStore.user) return false;
return props.schedule.userId === userStore.user.id;
});
//
const formatScheduleTime = (schedule: any) => {
if (!schedule) return '';
const startTime = dayjs(schedule.startTime);
const endTime = dayjs(schedule.endTime);
if (schedule.isAllDay === 1) {
//
if (startTime.format('YYYY-MM-DD') === endTime.format('YYYY-MM-DD')) {
return `${startTime.format('YYYY年MM月DD日')} 全天`;
}
return `${startTime.format('YYYY年MM月DD日')} - ${endTime.format('YYYY年MM月DD日')} 全天`;
}
//
if (startTime.format('YYYY-MM-DD') === endTime.format('YYYY-MM-DD')) {
return `${startTime.format('YYYY年MM月DD日 HH:mm')} - ${endTime.format('HH:mm')}`;
}
return `${startTime.format('YYYY年MM月DD日 HH:mm')} - ${endTime.format('YYYY年MM月DD日 HH:mm')}`;
};
//
const formatReminder = (minutes: number) => {
if (minutes === 0) return '不提醒';
if (minutes === 5) return '5分钟前';
if (minutes === 15) return '15分钟前';
if (minutes === 30) return '30分钟前';
if (minutes === 60) return '1小时前';
if (minutes === 120) return '2小时前';
if (minutes === 1440) return '1天前';
return `${minutes}分钟前`;
};
//
const handleEdit = () => {
emit('edit', props.schedule);
dialogVisible.value = false;
};
//
const handleDelete = () => {
ElMessageBox.confirm(
'确定要删除该日程吗?此操作不可恢复',
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
emit('delete', props.schedule.id);
dialogVisible.value = false;
}).catch(() => {
//
});
};
</script>
<style scoped>
.schedule-detail {
padding: 10px;
}
.detail-title {
font-size: 18px;
margin-bottom: 15px;
}
.detail-item {
margin-bottom: 10px;
display: flex;
}
.detail-label {
font-weight: 500;
width: 60px;
flex-shrink: 0;
}
.detail-description {
white-space: pre-line;
color: #606266;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

@ -1,294 +0,0 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEditing ? '编辑日程' : '添加日程'"
width="500px"
@closed="handleClosed"
>
<el-form :model="form" label-width="80px" :rules="rules" ref="formRef">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入日程标题"></el-input>
</el-form-item>
<el-form-item label="全天" prop="isAllDay">
<el-switch v-model="form.isAllDay" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
<el-form-item label="开始时间" prop="startTime">
<el-date-picker
v-if="form.isAllDay === 1"
v-model="form.startDate"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
></el-date-picker>
<el-date-picker
v-else
v-model="form.startTime"
type="datetime"
placeholder="选择日期时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm:00"
></el-date-picker>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker
v-if="form.isAllDay === 1"
v-model="form.endDate"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled-date="disabledEndDate"
></el-date-picker>
<el-date-picker
v-else
v-model="form.endTime"
type="datetime"
placeholder="选择日期时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm:00"
:disabled-date="disabledEndDate"
></el-date-picker>
</el-form-item>
<el-form-item label="地点" prop="location">
<el-input v-model="form.location" placeholder="请输入地点"></el-input>
</el-form-item>
<el-form-item label="提醒" prop="reminder">
<el-select v-model="form.reminder" placeholder="请选择提醒时间">
<el-option label="不提醒" :value="0"></el-option>
<el-option label="5分钟前" :value="5"></el-option>
<el-option label="15分钟前" :value="15"></el-option>
<el-option label="30分钟前" :value="30"></el-option>
<el-option label="1小时前" :value="60"></el-option>
<el-option label="2小时前" :value="120"></el-option>
<el-option label="1天前" :value="1440"></el-option>
</el-select>
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="form.color"></el-color-picker>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入日程描述"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">
保存
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, defineProps, defineEmits } from 'vue';
import { ElMessage, FormInstance } from 'element-plus';
import dayjs from 'dayjs';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
isEditing: {
type: Boolean,
default: false
},
schedule: {
type: Object,
default: () => ({})
},
initialDate: {
type: Date,
default: () => new Date()
},
initialHour: {
type: Number,
default: undefined
}
});
const emit = defineEmits(['update:visible', 'submit', 'delete']);
//
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
});
//
const formRef = ref<FormInstance>();
const submitting = ref(false);
//
const form = reactive({
id: undefined as number | undefined,
title: '',
isAllDay: 0,
startTime: '',
endTime: '',
startDate: '',
endDate: '',
location: '',
reminder: 0,
color: '#409EFF',
description: ''
});
//
const rules = {
title: [{ required: true, message: '请输入日程标题', trigger: 'blur' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
startDate: [{ required: true, message: '请选择开始日期', trigger: 'change' }],
endDate: [{ required: true, message: '请选择结束日期', trigger: 'change' }]
};
// props
watch(() => props.visible, (val) => {
if (val) {
initForm();
}
});
//
const initForm = () => {
if (props.isEditing && props.schedule) {
//
form.id = props.schedule.id;
form.title = props.schedule.title;
form.isAllDay = props.schedule.isAllDay;
form.location = props.schedule.location || '';
form.reminder = props.schedule.reminder || 0;
form.color = props.schedule.color || '#409EFF';
form.description = props.schedule.description || '';
if (form.isAllDay === 1) {
form.startDate = dayjs(props.schedule.startTime).format('YYYY-MM-DD');
form.endDate = dayjs(props.schedule.endTime).format('YYYY-MM-DD');
} else {
form.startTime = dayjs(props.schedule.startTime).format('YYYY-MM-DDTHH:mm:00');
form.endTime = dayjs(props.schedule.endTime).format('YYYY-MM-DDTHH:mm:00');
}
} else {
//
form.id = undefined;
form.title = '';
form.isAllDay = 0;
form.location = '';
form.reminder = 0;
form.color = getRandomColor();
form.description = '';
//
const initialDate = props.initialDate || new Date();
const initialHour = props.initialHour !== undefined ? props.initialHour : initialDate.getHours();
const startDate = dayjs(initialDate);
const endDate = dayjs(initialDate).add(1, 'hour');
form.startDate = startDate.format('YYYY-MM-DD');
form.endDate = startDate.format('YYYY-MM-DD');
form.startTime = startDate.hour(initialHour).minute(0).format('YYYY-MM-DDTHH:mm:00');
form.endTime = startDate.hour(initialHour + 1).minute(0).format('YYYY-MM-DDTHH:mm:00');
}
};
//
const disabledEndDate = (time: Date) => {
if (form.isAllDay === 1) {
return dayjs(time).isBefore(dayjs(form.startDate));
} else {
return dayjs(time).isBefore(dayjs(form.startTime));
}
};
//
const submitForm = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true;
try {
//
const data: any = {
title: form.title,
isAllDay: form.isAllDay,
location: form.location,
reminder: form.reminder,
color: form.color,
description: form.description
};
//
if (form.isAllDay === 1) {
data.startTime = `${form.startDate}T00:00:00`;
data.endTime = `${form.endDate}T23:59:59`;
} else {
data.startTime = form.startTime;
data.endTime = form.endTime;
}
//
emit('submit', {
id: form.id,
...data
});
dialogVisible.value = false;
} catch (error) {
console.error('提交日程失败:', error);
ElMessage.error('提交日程失败');
} finally {
submitting.value = false;
}
}
});
};
//
const handleClosed = () => {
formRef.value?.resetFields();
};
//
const getRandomColor = () => {
const colors = [
'#409EFF', //
'#67C23A', // 绿
'#E6A23C', //
'#F56C6C', //
'#909399', //
'#9966CC', //
'#FF9900', //
'#19CAAD', //
'#8CC7B5', // 绿
'#A0EEE1', //
'#BEE7E9', //
'#BEEDC7', // 绿
'#D6D5B7', //
'#D1BA74', //
'#E6CEAC', //
'#ECAD9E' //
];
return colors[Math.floor(Math.random() * colors.length)];
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

@ -1,10 +0,0 @@
{ "extends": "@vue/tsconfig/tsconfig.dom.json", "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "baseUrl": ".", "paths": { "@/*": ["src/*"] }, /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true },
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": [
"src/**/*_test.vue",
"src/**/*.test.vue",
"src/**/*.spec.vue",
"src/**/__tests__/*",
"src/components/Personal/AcountManager_test.vue"
]
}

@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

@ -1,13 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
}
}
})

@ -1,62 +0,0 @@
# 配置前端环境
---
### 检查node版本
```
npm -v
10.9.2
```
### 安装Vite
```
npm create vite@latest my-vue-app
```
此时vite会提示你选择
1⃣Select a framework 👉 选择 Vue
2⃣ Select a variant 👉 选择 TypeScript
然后安装pnpm
```
npm install -g pnpm
```
检查是否安装成功
```
pnpm -v
```
进入项目(项目我已经建好了,上传到项目里了)
```
cd Front/unilife
```
### 安装Axios
``` cmd
pnpm add axios
```
用来连接前后端
### 安装veeValidate
```cmd
pnpm add vee-validate
pnpm install yup
```
用于表单验证
### 安装ElementPlus
```cmd
pnpm add element-plus
```
组件
### 安装VueRouter
```cmd
pnpm i vue-router
pnpm add vue-router@4
```
用来完成界面跳转同时完成vuerouter与ts的适配
### 安装elementplus的icon库
```
pnpm install @element-plus/icons-vue
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

@ -1,88 +0,0 @@
# 搜索功能优化说明
## 🔍 问题分析
您提到的搜索功能问题确实存在,当前架构有以下问题:
1. **架构冗余**:存在独立的`SearchController`和`SearchService`
2. **功能重复**`ResourceController`已有搜索功能且工作正常,但`PostController`缺少搜索
3. **不一致**资源搜索和帖子搜索使用不同的API路径
## ✅ 已完成的优化
### 1. 后端优化
- ✅ 为`PostController`添加了`keyword`参数支持
- ✅ 更新了`PostService`接口和实现,支持关键词搜索
- ✅ 修改了`PostMapper.xml`中的`searchPosts`方法
- ✅ 统一了搜索API格式
- 帖子搜索:`GET /posts?keyword=xxx&categoryId=xxx&sort=xxx`
- 资源搜索:`GET /resources?keyword=xxx&categoryId=xxx` (已存在)
### 2. 前端优化
- ✅ 更新了`forum.ts` API支持关键词搜索
- ✅ 简化了`SearchView.vue`移除对独立搜索API的依赖
- ✅ 统一使用各自模块的搜索功能
## 🗑️ 建议删除的冗余代码
以下文件可以安全删除,因为功能已被集成到各自的控制器中:
### 后端文件
```
unilife-server/src/main/java/com/unilife/controller/SearchController.java
unilife-server/src/main/java/com/unilife/service/SearchService.java
unilife-server/src/main/java/com/unilife/service/impl/SearchServiceImpl.java
unilife-server/src/main/java/com/unilife/model/dto/SearchDTO.java
unilife-server/src/main/java/com/unilife/model/vo/SearchResultVO.java
```
### 前端文件
```
Front/vue-unilife/src/api/search.ts (可删除或简化)
```
## 🎯 优化后的架构
### 搜索API统一格式
```javascript
// 搜索帖子
GET /posts?keyword=关键词&categoryId=分类ID&sort=排序方式&page=页码&size=每页数量
// 搜索资源
GET /resources?keyword=关键词&categoryId=分类ID&page=页码&size=每页数量
```
### 前端调用方式
```javascript
// 搜索帖子
import { searchPosts } from '@/api/forum'
const result = await searchPosts(keyword, page, size, categoryId, sort)
// 搜索资源
import resourceApi from '@/api/resource'
const result = await resourceApi.getResources({ keyword, page, size, categoryId })
```
## 🔧 测试建议
请测试以下场景确认搜索功能正常:
1. **帖子搜索**
- 访问 `/posts?keyword=测试`
- 确认能搜索到标题或内容包含"测试"的帖子
2. **资源搜索**
- 访问 `/resources?keyword=测试`
- 确认能搜索到标题或描述包含"测试"的资源
3. **前端搜索页面**
- 访问搜索页面,切换"帖子"和"资源"选项
- 确认搜索结果正常显示
## 📋 后续步骤
1. 测试新的搜索功能
2. 确认无问题后删除冗余的SearchController相关代码
3. 可以开始下一个功能模块的开发AI辅助学习模块
这样的架构更简洁、一致并且符合RESTful API设计原则。每个资源的搜索功能都在自己的控制器中处理避免了不必要的复杂性。

@ -1,168 +0,0 @@
# 搜索架构优化说明
## 优化前的问题
之前的搜索功能架构存在以下问题:
1. **冗余的搜索服务**:独立的 `SearchController``SearchService`
2. **不符合RESTful原则**:搜索功能应该是各个资源的子功能,而不是独立服务
3. **代码重复**搜索逻辑在各个Controller中已经存在SearchController重复实现
4. **维护复杂**:需要同时维护搜索服务和各个模块的搜索功能
## 优化后的架构
### 1. 删除冗余文件
已删除以下不必要的文件:
- `SearchController.java` - 独立的搜索控制器
- `SearchService.java` - 搜索服务接口
- `SearchServiceImpl.java` - 搜索服务实现
- `SearchDTO.java` - 搜索请求DTO
- `SearchResultVO.java` - 搜索结果VO
### 2. 直接使用各模块的搜索功能
#### 帖子搜索
- **端点**`GET /posts`
- **Controller**`PostController.getPostList()`
- **支持参数**
```java
@RequestParam(value = "keyword", required = false) String keyword
@RequestParam(value = "categoryId", required = false) Long categoryId
@RequestParam(value = "page", defaultValue = "1") Integer page
@RequestParam(value = "size", defaultValue = "10") Integer size
@RequestParam(value = "sort", defaultValue = "latest") String sort
```
#### 资源搜索
- **端点**`GET /resources`
- **Controller**`ResourceController.getResourceList()`
- **支持参数**
```java
@RequestParam(value = "keyword", required = false) String keyword
@RequestParam(value = "category", required = false) Long categoryId
@RequestParam(value = "page", defaultValue = "1") Integer page
@RequestParam(value = "size", defaultValue = "10") Integer size
```
### 3. 前端API调用优化
#### 帖子搜索
```typescript
// 前端调用Front/vue-unilife/src/api/post.ts
searchPosts(params: {
keyword: string;
categoryId?: number | null;
pageNum?: number;
pageSize?: number;
sort?: string
}) {
return get<ResponseType>('/posts', serverParams);
}
```
#### 资源搜索
```typescript
// 前端调用Front/vue-unilife/src/api/resource.ts
getResources(params: ResourceParams = {}) {
// ResourceParams 已包含 keyword 参数
return get<ResponseType>('/resources', params);
}
```
## 架构优势
### 1. 符合RESTful原则
- 帖子搜索 → `GET /posts?keyword=xxx`
- 资源搜索 → `GET /resources?keyword=xxx`
- 每个资源的搜索功能都在对应的Controller中
### 2. 代码简化
- 减少了冗余的Controller和Service
- 统一了搜索逻辑,不需要维护重复代码
- 降低了系统复杂度
### 3. 更好的可维护性
- 搜索逻辑与业务逻辑紧密结合
- 修改搜索功能时只需要修改对应的Controller和Service
- 减少了模块间的耦合
### 4. 性能优化
- 减少了不必要的代码层次
- 直接调用业务Service避免额外的转换
## 前端搜索功能
### 1. 论坛页面内搜索
- **路径**`/forum`
- **实现**`PostListView.vue`
- **特点**:搜索结果直接在当前页面显示,不跳转
### 2. 独立搜索页面
- **路径**`/search`
- **实现**`SearchView.vue`
- **特点**:支持帖子和资源的综合搜索
### 3. 资源页面内搜索
- **路径**`/resources`
- **实现**`ResourceListView.vue`
- **特点**:直接在资源列表页面进行搜索
## API端点对比
### 优化前(已删除)
```
GET /search?keyword=xxx&type=post # 搜索帖子
GET /search?keyword=xxx&type=resource # 搜索资源
GET /search/posts?keyword=xxx # 专门搜索帖子
GET /search/resources?keyword=xxx # 专门搜索资源
```
### 优化后(当前实现)
```
GET /posts?keyword=xxx # 搜索帖子集成在帖子列表API中
GET /resources?keyword=xxx # 搜索资源集成在资源列表API中
```
## 迁移说明
### 对于前端开发者
- 论坛搜索:使用 `postApi.searchPosts()``postApi.getPosts()`
- 资源搜索:使用 `resourceApi.getResources()`
- 不需要调用独立的搜索API
### 对于后端开发者
- 搜索逻辑已集成在 `PostService``ResourceService`
- 不需要维护独立的 `SearchService`
- 搜索功能通过各自的Controller端点暴露
## 测试验证
### 1. 帖子搜索测试
```bash
# 测试帖子搜索
curl "http://localhost:8080/posts?keyword=测试&page=1&size=10"
```
### 2. 资源搜索测试
```bash
# 测试资源搜索
curl "http://localhost:8080/resources?keyword=文档&page=1&size=10"
```
### 3. 前端功能测试
- 在论坛页面输入关键词搜索,验证结果显示
- 在资源页面进行搜索,验证功能正常
- 在独立搜索页面测试综合搜索功能
## 总结
通过这次架构优化:
1. ✅ **删除了冗余的SearchController和SearchService**
2. ✅ **搜索功能直接集成在各模块的Controller中**
3. ✅ **符合RESTful API设计原则**
4. ✅ **简化了代码结构,提高了可维护性**
5. ✅ **前端API调用更加直观**
6. ✅ **减少了系统复杂度**
现在的搜索架构更加清晰、简洁、符合最佳实践。

@ -1,148 +0,0 @@
# 论坛搜索功能优化说明
## 问题描述
用户反馈论坛搜索功能会跳转到新的搜索页面(`http://localhost:5173/search?keyword=12`),希望搜索结果直接在论坛页面(`http://localhost:5173/forum`)中显示,而不是跳转到独立的搜索页面。
## 解决方案
### 1. 修改PostStore状态管理
`Front/vue-unilife/src/stores/postStore.ts` 中:
- **添加搜索相关状态**
```typescript
// 搜索相关状态
searchKeyword: string | null;
isSearching: boolean;
```
- **添加搜索方法**
```typescript
// 搜索帖子
async searchPosts(params: { keyword: string; categoryId?: number | null; pageNum?: number; pageSize?: number })
// 清除搜索状态
clearSearch()
```
### 2. 修改PostAPI接口
`Front/vue-unilife/src/api/post.ts` 中:
- **添加搜索API方法**
```typescript
// 搜索帖子
searchPosts(params: { keyword: string; categoryId?: number | null; pageNum?: number; pageSize?: number; sort?: string })
```
### 3. 修改论坛页面组件
`Front/vue-unilife/src/views/forum/PostListView.vue` 中:
- **修改搜索处理函数**
- 原来:跳转到搜索页面 `router.push('/search?keyword=...')`
- 现在:直接调用 `postStore.searchPosts()` 在当前页面显示结果
- **添加搜索状态显示**
```vue
<!-- 搜索状态提示 -->
<div v-if="postStore.isSearching" class="search-status">
<el-alert
:title="`搜索 '${postStore.searchKeyword}' 的结果 (共 ${postStore.totalPosts} 个帖子)`"
type="info"
show-icon
:closable="false"
>
<template #default>
<el-button size="small" @click="clearSearch">清除搜索</el-button>
</template>
</el-alert>
</div>
```
- **优化分页处理**
- 在搜索状态下,分页使用搜索方法
- 在普通状态下,分页使用常规获取方法
## 功能特性
### 1. 无缝搜索体验
- 用户在论坛页面输入关键词搜索,结果直接在当前页面显示
- 不会跳转到新页面,保持用户在论坛的浏览体验
### 2. 搜索状态管理
- 显示当前搜索关键词和结果数量
- 提供"清除搜索"按钮,一键返回全部帖子列表
- 搜索状态下的分页功能正常工作
### 3. 分类筛选支持
- 搜索时可以结合分类筛选
- 支持在特定分类下进行关键词搜索
### 4. 空搜索处理
- 当搜索关键词为空时,自动清除搜索状态并显示全部帖子
- 避免无效搜索请求
## 使用方法
1. **进行搜索**
- 在论坛页面顶部搜索框输入关键词
- 点击搜索按钮或按回车键
- 搜索结果直接在当前页面显示
2. **清除搜索**
- 点击搜索状态提示中的"清除搜索"按钮
- 或者清空搜索框内容后再次搜索
3. **分页浏览**
- 在搜索结果中正常使用分页功能
- 分页会保持当前搜索条件
## 技术实现
### API调用流程
```
用户输入关键词 → handleSearch() → postStore.searchPosts() → postApi.searchPosts() → 后端搜索接口
```
### 状态管理
```
搜索状态isSearching = true, searchKeyword = "关键词"
普通状态isSearching = false, searchKeyword = null
```
### 分页逻辑
```typescript
if (postStore.isSearching && postStore.searchKeyword) {
// 使用搜索方法进行分页
postStore.searchPosts({ keyword, categoryId, pageNum });
} else {
// 使用普通方法进行分页
postStore.fetchPosts({ pageNum });
}
```
## 测试建议
1. **基本搜索测试**
- 输入关键词"12",验证是否显示相关帖子
- 确认页面不跳转,结果在当前页面显示
2. **搜索状态测试**
- 验证搜索状态提示是否正确显示
- 测试"清除搜索"功能是否正常
3. **分页测试**
- 在搜索结果中测试分页功能
- 验证分页后仍保持搜索状态
4. **边界情况测试**
- 空关键词搜索
- 无结果搜索
- 结合分类筛选的搜索
## 预期效果
用户在论坛页面搜索"12"后,应该看到:
- 页面不跳转,仍在 `http://localhost:5173/forum`
- 显示搜索状态提示:"搜索 '12' 的结果 (共 X 个帖子)"
- 下方显示匹配的帖子列表
- 分页功能正常工作
- 可以通过"清除搜索"按钮返回全部帖子列表

@ -64,7 +64,8 @@
"studentId": "20220101001",
"department": "计算机学院",
"major": "软件工程",
"grade": "2023级"
"grade": "2023级",
"code": "123456"
}
```
@ -89,7 +90,7 @@
请求参数:
```json
{
"username": "student123",
"email": "student@school.edu",
"password": "Secure@Password123"
}
```
@ -101,15 +102,11 @@
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"userInfo": {
"userId": 12345,
"username": "student123",
"nickname": "学生昵称",
"avatar": "https://example.com/avatar.jpg",
"role": 0,
"isVerified": true,
"status": 1
}
"id": 12345,
"username": "student123",
"nickname": "学生昵称",
"avatar": "https://example.com/avatar.jpg",
"role": 0
}
}
```
@ -155,13 +152,11 @@
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"userInfo": {
"userId": 12345,
"username": "student123",
"nickname": "学生昵称",
"avatar": "https://example.com/avatar.jpg",
"role": 0
}
"id": 12345,
"username": "student123",
"nickname": "学生昵称",
"avatar": "https://example.com/avatar.jpg",
"role": 0
}
}
```
@ -200,14 +195,15 @@
### 3.2 更新用户个人信息
- **URL**: `/users/profile`
- **方法**: PUT
- **描述**: 更新当前登录用户的个人资料
- **描述**: 更新当前登录用户的个人资料信息
- **认证**: 需要JWT Token
请求参数:
```json
{
"nickname": "新昵称",
"bio": "这是更新的个人简介",
"gender": 2,
"username": "newusername",
"bio": "这是一个更新的个人简介",
"gender": 1,
"department": "计算机学院",
"major": "软件工程",
"grade": "2023级"
@ -227,12 +223,13 @@
- **URL**: `/users/password`
- **方法**: PUT
- **描述**: 修改当前登录用户的密码
- **认证**: 需要JWT Token
请求参数:
```json
{
"code": "验证码",
"newPassword": "新密码"
"code": "123456",
"newPassword": "NewSecure@Password123"
}
```
@ -248,12 +245,12 @@
### 3.4 上传用户头像
- **URL**: `/users/avatar`
- **方法**: POST
- **描述**: 上传或更新用户头像
- **描述**: 上传用户头像
- **认证**: 需要JWT Token
请求参数:
```
file: [图片文件]
```
- **Content-Type**: `multipart/form-data`
- **file**: 头像文件(图片格式)
响应结果:
```json
@ -261,7 +258,7 @@ file: [图片文件]
"code": 200,
"message": "头像上传成功",
"data": {
"avatar": "https://example.com/avatars/user_123456.jpg"
"avatarUrl": "https://example.com/avatars/user_12345.jpg"
}
}
```
@ -269,12 +266,13 @@ file: [图片文件]
### 3.5 更新用户邮箱
- **URL**: `/users/email`
- **方法**: PUT
- **描述**: 更新用户邮箱地址
- **描述**: 更新当前登录用户的邮箱
- **认证**: 需要JWT Token
请求参数:
```json
{
"email": "new_email@school.edu",
"email": "newemail@school.edu",
"code": "123456"
}
```
@ -288,6 +286,57 @@ file: [图片文件]
}
```
### 3.6 获取用户统计数据
- **URL**: `/users/stats`
- **方法**: GET
- **描述**: 获取当前用户的统计数据
- **认证**: 需要JWT Token
响应结果:
```json
{
"code": 200,
"message": "success",
"data": {
"postsCount": 25,
"commentsCount": 150,
"resourcesCount": 10,
"likesReceived": 300,
"coursesCount": 8,
"schedulesCount": 15
}
}
```
### 3.7 获取用户最近帖子
- **URL**: `/users/recent-posts`
- **方法**: GET
- **描述**: 获取当前用户最近发布的帖子
- **认证**: 需要JWT Token
请求参数:
- **limit** (query, 可选): 返回的帖子数量默认为5
响应结果:
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": 123,
"title": "最新帖子标题",
"content": "帖子内容摘要...",
"categoryId": 1,
"createTime": "2024-01-15T10:30:00",
"viewsCount": 50,
"likesCount": 10,
"commentsCount": 5
}
]
}
```
## 4. 论坛功能模块
### 4.1 帖子管理
@ -298,12 +347,11 @@ file: [图片文件]
- **描述**: 获取帖子列表,支持分页和筛选
请求参数:
```
page: 1
size: 10
category: 1
sort: latest
```
- **categoryId** (query, 可选): 分类ID
- **keyword** (query, 可选): 搜索关键词
- **page** (query, 可选): 页码默认为1
- **size** (query, 可选): 每页大小默认为10
- **sort** (query, 可选): 排序方式默认为latest
响应结果:
```json
@ -366,6 +414,7 @@ sort: latest
- **URL**: `/posts`
- **方法**: POST
- **描述**: 发布新帖子
- **认证**: 需要JWT Token
请求参数:
```json
@ -391,6 +440,7 @@ sort: latest
- **URL**: `/posts/{id}`
- **方法**: PUT
- **描述**: 更新帖子
- **认证**: 需要JWT Token
请求参数:
```json
@ -414,6 +464,7 @@ sort: latest
- **URL**: `/posts/{id}`
- **方法**: DELETE
- **描述**: 删除帖子
- **认证**: 需要JWT Token
响应结果:
```json
@ -428,6 +479,7 @@ sort: latest
- **URL**: `/posts/{id}/like`
- **方法**: POST
- **描述**: 点赞或取消点赞帖子
- **认证**: 需要JWT Token
响应结果:
```json
@ -438,6 +490,45 @@ sort: latest
}
```
#### 4.1.7 获取用户的帖子列表
- **URL**: `/posts/user/{userId}`
- **方法**: GET
- **描述**: 获取指定用户发布的帖子列表
请求参数:
- **userId** (path): 用户ID
- **page** (query, 可选): 页码默认为1
- **size** (query, 可选): 每页大小默认为10
- **sort** (query, 可选): 排序方式默认为latest
响应结果:
```json
{
"code": 200,
"message": "success",
"data": {
"total": 25,
"list": [
{
"id": 1,
"title": "帖子标题",
"summary": "帖子摘要...",
"userId": 12345,
"nickname": "发布者昵称",
"avatar": "https://example.com/avatar.jpg",
"categoryId": 1,
"categoryName": "学习交流",
"viewCount": 100,
"likeCount": 20,
"commentCount": 5,
"createdAt": "2023-05-01 12:00:00"
}
],
"pages": 3
}
}
```
### 4.2 评论管理
#### 4.2.1 获取评论列表
@ -484,6 +575,7 @@ sort: latest
- **URL**: `/comments`
- **方法**: POST
- **描述**: 发表评论或回复
- **认证**: 需要JWT Token
请求参数:
```json
@ -509,6 +601,7 @@ sort: latest
- **URL**: `/comments/{id}`
- **方法**: DELETE
- **描述**: 删除评论
- **认证**: 需要JWT Token
响应结果:
```json
@ -523,6 +616,7 @@ sort: latest
- **URL**: `/comments/{id}/like`
- **方法**: POST
- **描述**: 点赞或取消点赞评论
- **认证**: 需要JWT Token
响应结果:
```json
@ -541,9 +635,7 @@ sort: latest
- **描述**: 获取分类列表
请求参数:
```
status: 1 // 可选1-启用0-禁用
```
- **status** (query, 可选): 分类状态1-启用0-禁用
响应结果:
```json
@ -595,6 +687,7 @@ status: 1 // 可选1-启用0-禁用
- **URL**: `/categories`
- **方法**: POST
- **描述**: 创建新分类(需要管理员权限)
- **认证**: 需要JWT Token
请求参数:
```json
@ -622,6 +715,7 @@ status: 1 // 可选1-启用0-禁用
- **URL**: `/categories/{id}`
- **方法**: PUT
- **描述**: 更新分类(需要管理员权限)
- **认证**: 需要JWT Token
请求参数:
```json
@ -647,6 +741,7 @@ status: 1 // 可选1-启用0-禁用
- **URL**: `/categories/{id}`
- **方法**: DELETE
- **描述**: 删除分类(需要管理员权限)
- **认证**: 需要JWT Token
响应结果:
```json
@ -663,14 +758,14 @@ status: 1 // 可选1-启用0-禁用
- **URL**: `/resources`
- **方法**: POST
- **描述**: 上传新资源
- **认证**: 需要JWT Token
请求参数:
```
file: [文件]
title: 资源标题
description: 资源描述
categoryId: 分类ID
```
- **Content-Type**: `multipart/form-data`
- **file**: 资源文件
- **title**: 资源标题
- **description**: 资源描述
- **categoryId**: 分类ID
响应结果:
```json
@ -720,13 +815,11 @@ categoryId: 分类ID
- **描述**: 获取资源列表,支持分页和筛选
请求参数:
```
category: 1 // 可选分类ID
user: 12345 // 可选用户ID
keyword: "关键词" // 可选,搜索关键词
page: 1
size: 10
```
- **category** (query, 可选): 分类ID
- **user** (query, 可选): 用户ID
- **keyword** (query, 可选): 搜索关键词
- **page** (query, 可选): 页码默认为1
- **size** (query, 可选): 每页大小默认为10
响应结果:
```json
@ -764,6 +857,7 @@ size: 10
- **URL**: `/resources/{id}`
- **方法**: PUT
- **描述**: 更新资源信息
- **认证**: 需要JWT Token
请求参数:
```json
@ -787,6 +881,7 @@ size: 10
- **URL**: `/resources/{id}`
- **方法**: DELETE
- **描述**: 删除资源
- **认证**: 需要JWT Token
响应结果:
```json
@ -819,6 +914,7 @@ size: 10
- **URL**: `/resources/{id}/like`
- **方法**: POST
- **描述**: 点赞或取消点赞资源
- **认证**: 需要JWT Token
响应结果:
```json
@ -835,10 +931,8 @@ size: 10
- **描述**: 获取指定用户上传的资源列表
请求参数:
```
page: 1
size: 10
```
- **page** (query, 可选): 页码默认为1
- **size** (query, 可选): 每页大小默认为10
响应结果:
```json
@ -876,12 +970,11 @@ size: 10
- **URL**: `/resources/my`
- **方法**: GET
- **描述**: 获取当前登录用户上传的资源列表
- **认证**: 需要JWT Token
请求参数:
```
page: 1
size: 10
```
- **page** (query, 可选): 页码默认为1
- **size** (query, 可选): 每页大小默认为10
响应结果:
```json
@ -923,6 +1016,7 @@ size: 10
- **URL**: `/courses`
- **方法**: POST
- **描述**: 创建新课程
- **认证**: 需要JWT Token
请求参数:
```json
@ -935,6 +1029,7 @@ size: 10
"endTime": "09:40:00",
"startWeek": 1,
"endWeek": 16,
"semester": "2023-1",
"color": "#4CAF50"
}
```
@ -954,6 +1049,7 @@ size: 10
- **URL**: `/courses/{id}`
- **方法**: GET
- **描述**: 获取课程详情
- **认证**: 需要JWT Token
响应结果:
```json
@ -983,6 +1079,7 @@ size: 10
- **URL**: `/courses`
- **方法**: GET
- **描述**: 获取当前用户的所有课程
- **认证**: 需要JWT Token
响应结果:
```json
@ -1017,6 +1114,7 @@ size: 10
- **URL**: `/courses/day/{dayOfWeek}`
- **方法**: GET
- **描述**: 获取当前用户在指定星期几的课程
- **认证**: 需要JWT Token
响应结果:
```json
@ -1047,10 +1145,47 @@ size: 10
}
```
#### 6.1.5 更新课程
#### 6.1.5 获取用户在指定学期的课程
- **URL**: `/courses/semester/{semester}`
- **方法**: GET
- **描述**: 获取当前用户在指定学期的课程
- **认证**: 需要JWT Token
响应结果:
```json
{
"code": 200,
"message": "success",
"data": {
"total": 5,
"list": [
{
"id": 1,
"userId": 12345,
"name": "数据结构",
"teacher": "张教授",
"location": "教学楼A-101",
"dayOfWeek": 1,
"startTime": "08:00:00",
"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"
}
]
}
}
```
#### 6.1.6 更新课程
- **URL**: `/courses/{id}`
- **方法**: PUT
- **描述**: 更新课程
- **认证**: 需要JWT Token
请求参数:
```json
@ -1076,10 +1211,11 @@ size: 10
}
```
#### 6.1.6 删除课程
#### 6.1.7 删除课程
- **URL**: `/courses/{id}`
- **方法**: DELETE
- **描述**: 删除课程
- **认证**: 需要JWT Token
响应结果:
```json
@ -1090,18 +1226,17 @@ size: 10
}
```
#### 6.1.7 检查课程时间冲突
#### 6.1.8 检查课程时间冲突
- **URL**: `/courses/check-conflict`
- **方法**: GET
- **描述**: 检查课程时间是否冲突
- **认证**: 需要JWT Token
请求参数:
```
dayOfWeek: 1
startTime: "08:00:00"
endTime: "09:40:00"
excludeCourseId: 1 // 可选排除的课程ID
```
- **dayOfWeek** (query): 星期几1-7
- **startTime** (query): 开始时间,格式:"HH:mm:ss"
- **endTime** (query): 结束时间,格式:"HH:mm:ss"
- **excludeCourseId** (query, 可选): 排除的课程ID
响应结果:
```json
@ -1121,6 +1256,7 @@ excludeCourseId: 1 // 可选排除的课程ID
- **URL**: `/schedules`
- **方法**: POST
- **描述**: 创建新日程
- **认证**: 需要JWT Token
请求参数:
```json
@ -1151,6 +1287,7 @@ excludeCourseId: 1 // 可选排除的课程ID
- **URL**: `/schedules/{id}`
- **方法**: GET
- **描述**: 获取日程详情
- **认证**: 需要JWT Token
响应结果:
```json
@ -1179,6 +1316,7 @@ excludeCourseId: 1 // 可选排除的课程ID
- **URL**: `/schedules`
- **方法**: GET
- **描述**: 获取当前用户的所有日程
- **认证**: 需要JWT Token
响应结果:
```json
@ -1212,12 +1350,11 @@ excludeCourseId: 1 // 可选排除的课程ID
- **URL**: `/schedules/range`
- **方法**: GET
- **描述**: 获取当前用户在指定时间范围内的日程
- **认证**: 需要JWT Token
请求参数:
```
startTime: "2023-05-01T00:00:00"
endTime: "2023-05-31T23:59:59"
```
- **startTime** (query): 开始时间,格式:"yyyy-MM-ddTHH:mm:ss"
- **endTime** (query): 结束时间,格式:"yyyy-MM-ddTHH:mm:ss"
响应结果:
```json
@ -1251,6 +1388,7 @@ endTime: "2023-05-31T23:59:59"
- **URL**: `/schedules/{id}`
- **方法**: PUT
- **描述**: 更新日程
- **认证**: 需要JWT Token
请求参数:
```json
@ -1279,6 +1417,7 @@ endTime: "2023-05-31T23:59:59"
- **URL**: `/schedules/{id}`
- **方法**: DELETE
- **描述**: 删除日程
- **认证**: 需要JWT Token
响应结果:
```json
@ -1293,13 +1432,12 @@ endTime: "2023-05-31T23:59:59"
- **URL**: `/schedules/check-conflict`
- **方法**: GET
- **描述**: 检查日程时间是否冲突
- **认证**: 需要JWT Token
请求参数:
```
startTime: "2023-05-10T14:00:00"
endTime: "2023-05-10T16:00:00"
excludeScheduleId: 1 // 可选排除的日程ID
```
- **startTime** (query): 开始时间,格式:"yyyy-MM-ddTHH:mm:ss"
- **endTime** (query): 结束时间,格式:"yyyy-MM-ddTHH:mm:ss"
- **excludeScheduleId** (query, 可选): 排除的日程ID
响应结果:
```json

@ -0,0 +1,9 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

@ -0,0 +1 @@
* text=auto eol=lf

@ -8,17 +8,23 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

@ -0,0 +1,243 @@
# UniLife 设计系统
## 概述
UniLife 采用现代化的紫色主题设计系统注重用户体验和视觉一致性。设计风格融合了玻璃态Glassmorphism效果、流体动画和响应式布局。
## 🎨 色彩系统
### 主色调(紫色系)
```css
--primary-50: #faf5ff; /* 最浅紫色 */
--primary-100: #f3e8ff; /* 很浅紫色 */
--primary-200: #e9d5ff; /* 浅紫色 */
--primary-300: #d8b4fe; /* 中浅紫色 */
--primary-400: #c084fc; /* 中紫色 */
--primary-500: #a855f7; /* 标准紫色(主色) */
--primary-600: #9333ea; /* 中深紫色 */
--primary-700: #7c3aed; /* 深紫色 */
--primary-800: #6b21a8; /* 很深紫色 */
--primary-900: #581c87; /* 最深紫色 */
--primary-950: #3b0764; /* 极深紫色 */
```
### 中性色系
```css
--gray-50: #f9fafb; /* 背景色 */
--gray-100: #f3f4f6; /* 浅背景 */
--gray-200: #e5e7eb; /* 边框色 */
--gray-300: #d1d5db; /* 分割线 */
--gray-400: #9ca3af; /* 占位符 */
--gray-500: #6b7280; /* 辅助文字 */
--gray-600: #4b5563; /* 次要文字 */
--gray-700: #374151; /* 主要文字 */
--gray-800: #1f2937; /* 标题 */
--gray-900: #111827; /* 黑色文字 */
```
### 语义化颜色
```css
--success: #10b981; /* 成功绿 */
--warning: #f59e0b; /* 警告橙 */
--error: #ef4444; /* 错误红 */
--info: #3b82f6; /* 信息蓝 */
```
### 渐变色
```css
--gradient-primary: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-700) 100%);
--gradient-secondary: linear-gradient(135deg, var(--primary-200) 0%, var(--primary-400) 100%);
--gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
```
## 📐 间距系统
```css
--spacing-xs: 4px; /* 8px */
--spacing-sm: 8px; /* 12px */
--spacing-md: 16px; /* 16px */
--spacing-lg: 24px; /* 24px */
--spacing-xl: 32px; /* 32px */
--spacing-2xl: 48px; /* 48px */
--spacing-3xl: 64px; /* 64px */
--spacing-4xl: 80px; /* 80px */
```
## 🔤 字体系统
### 字体族
- 主字体:系统默认字体栈
- 代码字体:'JetBrains Mono', 'Consolas', monospace
### 字体大小
```css
--text-xs: 12px; /* 小号文字 */
--text-sm: 14px; /* 次要文字 */
--text-base: 16px; /* 基础文字 */
--text-lg: 18px; /* 大号文字 */
--text-xl: 20px; /* 标题 */
--text-2xl: 24px; /* 大标题 */
--text-3xl: 30px; /* 页面标题 */
--text-4xl: 36px; /* 主标题 */
```
### 字重
```css
--font-light: 300; /* 细体 */
--font-normal: 400; /* 常规 */
--font-medium: 500; /* 中等 */
--font-semibold: 600; /* 半粗 */
--font-bold: 700; /* 粗体 */
--font-black: 900; /* 黑体 */
```
## 🎭 阴影系统
```css
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-base: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-purple: 0 20px 40px rgba(168, 85, 247, 0.4);
```
## 📏 圆角系统
```css
--radius-sm: 6px; /* 小圆角 */
--radius-base: 8px; /* 基础圆角 */
--radius-md: 12px; /* 中等圆角 */
--radius-lg: 16px; /* 大圆角 */
--radius-xl: 20px; /* 超大圆角 */
--radius-2xl: 24px; /* 特大圆角 */
--radius-full: 9999px; /* 完全圆角 */
```
## ⚡ 动画系统
### 过渡时间
```css
--transition-fast: 150ms ease-in-out;
--transition-base: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
```
### 关键帧动画
- `animate-fade-in`: 淡入效果
- `animate-fade-in-up`: 从下方淡入
- `animate-float`: 浮动效果
- `animate-glow`: 发光效果
- `animate-pulse`: 脉冲效果
## 🧱 组件规范
### 玻璃态效果Glass
```css
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
```
### 按钮组件
- **主要按钮**: 紫色渐变背景,白色文字
- **次要按钮**: 透明背景,边框,白色文字
- **文字按钮**: 无背景,仅文字
- **状态**: hover、active、disabled
### 表单组件
- **输入框**: 玻璃态背景,紫色焦点边框
- **标签**: 白色文字,适当间距
- **验证**: 错误状态使用红色,成功状态使用绿色
### 卡片组件
- **背景**: 玻璃态效果
- **阴影**: 悬停时添加紫色阴影
- **圆角**: 使用--radius-lg
- **间距**: 内边距32px
## 📱 响应式断点
```css
/* 移动设备 */
@media (max-width: 480px) { }
/* 平板设备 */
@media (max-width: 768px) { }
/* 桌面设备 */
@media (max-width: 1024px) { }
/* 大屏设备 */
@media (min-width: 1200px) { }
```
## 🎯 设计原则
### 1. 视觉层次
- 使用字体大小和颜色对比建立清晰的信息层次
- 重要内容使用紫色高亮
- 辅助信息使用较淡的颜色
### 2. 一致性
- 所有交互元素使用统一的视觉语言
- 保持间距、圆角、阴影的一致性
- 动画时长和缓动函数保持统一
### 3. 可访问性
- 确保颜色对比度符合WCAG标准
- 提供焦点状态指示
- 支持键盘导航
### 4. 现代感
- 使用玻璃态效果增强层次感
- 流畅的动画过渡
- 适当的留白和间距
## 🚀 使用指南
### 引入样式
在main.ts中引入全局样式
```typescript
import './styles/globals.css'
```
### 使用CSS变量
在组件中使用预定义的CSS变量
```css
.custom-button {
background: var(--gradient-primary);
padding: var(--spacing-md);
border-radius: var(--radius-md);
transition: var(--transition-base);
}
```
### 玻璃态组件
添加glass类名获得玻璃态效果
```html
<div class="glass">内容</div>
```
### 渐变文字
使用gradient-text类名
```html
<h1 class="gradient-text">标题</h1>
```
## 📚 组件库
当前已实现的页面组件:
- **HomeView**: 首页包含hero section、功能介绍、使用步骤
- **LoginView**: 登录页面,玻璃态表单设计
- **RegisterView**: 注册页面,多步骤表单设计
- **ForumView**: 论坛页面(基础结构)
### 下一步计划
- 完善论坛组件(帖子列表、详情页)
- 资源管理组件
- 课程表组件
- 用户个人资料组件
- 通用组件库(模态框、通知、加载状态等)

@ -0,0 +1,195 @@
# UniLife Frontend
## 🎓 项目简介
UniLife 是一个专为大学生设计的综合性生活平台,提供论坛交流、资源分享、课程管理等功能。本项目是 UniLife 的前端部分,采用现代化的技术栈和紫色主题设计。
## ✨ 特性
- 🎨 **现代化UI设计**: 紫色主题 + 玻璃态效果
- 📱 **响应式布局**: 完美适配桌面端和移动端
- 🚀 **高性能**: Vue 3 + Vite 构建,极快的开发体验
- 🔒 **类型安全**: 完整的TypeScript支持
- 🎯 **用户体验**: 流畅的动画和交互效果
- 🌈 **设计系统**: 统一的设计规范和组件库
## 🛠️ 技术栈
- **前端框架**: Vue 3.5
- **构建工具**: Vite 6.3
- **语言**: TypeScript 5.6
- **状态管理**: Pinia 2.2
- **路由**: Vue Router 4.4
- **UI组件**: Element Plus 2.9
- **HTTP客户端**: Axios 1.7
- **样式**: CSS3 + CSS变量
- **工具**: ESLint + Prettier
## 🚀 快速开始
### 环境要求
- Node.js >= 18.0.0
- npm >= 9.0.0
### 安装依赖
```bash
cd unilife-frontend
npm install
```
### 开发服务器
```bash
npm run dev
```
访问 http://localhost:5173 查看应用
### 构建生产版本
```bash
npm run build
```
### 预览生产版本
```bash
npm run preview
```
## 📁 项目结构
```
unilife-frontend/
├── public/ # 静态资源
├── src/
│ ├── api/ # API接口
│ │ ├── index.ts # Axios配置
│ │ └── auth.ts # 认证相关API
│ ├── components/ # 公共组件
│ ├── layouts/ # 布局组件
│ ├── router/ # 路由配置
│ │ └── index.ts # 主路由文件
│ ├── stores/ # 状态管理
│ │ └── user.ts # 用户状态
│ ├── styles/ # 全局样式
│ │ └── globals.css # 设计系统CSS变量
│ ├── types/ # TypeScript类型定义
│ │ └── index.ts # 全局类型
│ ├── utils/ # 工具函数
│ ├── views/ # 页面组件
│ │ ├── auth/ # 认证页面
│ │ │ ├── LoginView.vue
│ │ │ └── RegisterView.vue
│ │ ├── forum/ # 论坛页面
│ │ ├── resources/ # 资源页面
│ │ ├── schedule/ # 课程表页面
│ │ ├── profile/ # 个人资料页面
│ │ └── HomeView.vue # 首页
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── DESIGN_SYSTEM.md # 设计系统文档
└── README.md # 项目说明
```
## 🎨 设计系统
我们为 UniLife 创建了完整的设计系统,包括:
- **色彩系统**: 紫色主题色彩规范
- **间距系统**: 统一的间距标准
- **字体系统**: 层次化的字体规范
- **阴影系统**: 深度感的阴影效果
- **动画系统**: 流畅的过渡动画
- **组件规范**: 可复用的UI组件
详细信息请查看 [设计系统文档](./DESIGN_SYSTEM.md)
## 📄 页面说明
### 🏠 首页 (HomeView)
- Hero区域展示平台价值主张
- 功能特色介绍6个核心功能
- 使用步骤说明3步上手
- CTA区域引导用户注册
- 响应式设计,完美适配各种设备
### 🔐 登录页面 (LoginView)
- 玻璃态设计风格
- 用户名/邮箱登录支持
- 表单验证和错误提示
- 动画效果增强用户体验
- 记住密码功能
### ✍️ 注册页面 (RegisterView)
- 完整的用户信息收集
- 邮箱验证码功能
- 实时表单验证
- 密码强度检查
- 学生身份验证
### 💬 论坛页面 (ForumView)
- 基础结构已搭建
- 待后续功能完善
## 🔌 API集成
项目已配置完整的API客户端
- **请求拦截器**: 自动添加认证token
- **响应拦截器**: 统一错误处理
- **类型安全**: 完整的TypeScript类型定义
- **错误处理**: 用户友好的错误提示
## 🔐 状态管理
使用 Pinia 进行状态管理:
- **用户状态**: 登录状态、用户信息管理
- **持久化**: localStorage自动同步
- **类型安全**: 完整的TypeScript支持
## 🛣️ 路由配置
- **路由守卫**: 自动认证检查
- **懒加载**: 按需加载页面组件
- **重定向**: 智能路由重定向
- **类型安全**: 路由参数类型检查
## 🎯 下一步计划
### 短期目标
- [ ] 完善论坛功能(帖子列表、详情、发布)
- [ ] 实现资源分享功能
- [ ] 开发课程表管理
- [ ] 个人资料页面
### 长期目标
- [ ] 消息通知系统
- [ ] 搜索功能优化
- [ ] 移动端App开发
- [ ] PWA支持
## 🤝 贡献指南
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 📜 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
## 📞 联系我们
- 项目地址: [GitHub](https://github.com/your-username/unilife-frontend)
- 问题反馈: [Issues](https://github.com/your-username/unilife-frontend/issues)
- 文档: [Wiki](https://github.com/your-username/unilife-frontend/wiki)
---
🎓 **让大学生活更精彩!**

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save