@ -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>
|
@ -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"
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 9.9 KiB |
Before Width: | Height: | Size: 1.5 KiB |
@ -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,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,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,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
|
||||
);
|
||||
}
|
||||
};
|
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,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,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,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,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'),
|
||||
}
|
||||
}
|
||||
})
|
Before Width: | Height: | Size: 73 KiB |
Before Width: | Height: | Size: 224 KiB |
Before Width: | Height: | Size: 207 KiB |
Before Width: | Height: | Size: 148 KiB |
Before Width: | Height: | Size: 181 KiB |
Before Width: | Height: | Size: 104 KiB |
Before Width: | Height: | Size: 102 KiB |
Before Width: | Height: | Size: 104 KiB |
Before Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 125 KiB |
Before Width: | Height: | Size: 99 KiB |
Before Width: | Height: | Size: 117 KiB |
Before Width: | Height: | Size: 103 KiB |
Before Width: | Height: | Size: 97 KiB |
Before Width: | Height: | Size: 77 KiB |
Before Width: | Height: | Size: 179 KiB |
Before Width: | Height: | Size: 74 KiB |
Before Width: | Height: | Size: 122 KiB |
Before Width: | Height: | Size: 128 KiB |
Before Width: | Height: | Size: 104 KiB |
Before Width: | Height: | Size: 419 KiB |
@ -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
|
@ -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"
|
||||
]
|
||||
}
|