点赞和评论

czq
2991692032 2 weeks ago
parent 9d56e0550d
commit e13486812c

@ -0,0 +1,82 @@
# 论坛功能完善记录
## 已完成的功能
### 1. 帖子点赞功能
- ✅ 在帖子详情页面添加了点赞/取消点赞按钮
- ✅ 在帖子列表页面为每个帖子添加了点赞按钮
- ✅ 实时更新点赞数量和状态
- ✅ 未登录用户点击点赞会提示登录
- ✅ 在postStore中实现了likePost方法
### 2. 评论系统
- ✅ 创建了评论API接口 (`/src/api/comment.ts`)
- ✅ 创建了完整的评论组件 (`/src/components/CommentSection.vue`)
- ✅ 支持发表评论和回复评论
- ✅ 支持评论的点赞和取消点赞
- ✅ 支持删除自己的评论
- ✅ 评论时间的友好显示(几分钟前、几小时前等)
- ✅ 未登录用户会提示登录
### 3. 用户体验优化
- ✅ 添加了加载状态和错误处理
- ✅ 优化了UI布局和样式
- ✅ 支持未登录用户浏览但限制互动功能
- ✅ 完善了错误提示和成功提示
## 技术实现
### API接口
- `POST /posts/{id}/like` - 点赞/取消点赞帖子
- `GET /comments/post/{postId}` - 获取帖子评论列表
- `POST /comments` - 发表评论
- `DELETE /comments/{id}` - 删除评论
- `POST /comments/{id}/like` - 点赞/取消点赞评论
### 组件结构
```
PostListView.vue - 帖子列表页面
├── 点赞按钮
└── 帖子卡片交互
PostDetailView.vue - 帖子详情页面
├── 点赞按钮
└── CommentSection.vue - 评论区组件
├── 评论表单
├── 评论列表
├── 回复功能
└── 评论点赞功能
```
### 状态管理
- 在PostStore中添加了`likePost`方法
- 实现了帖子点赞状态的同步更新
- 在评论组件中管理评论数据和状态
## 功能特点
### 点赞系统
- 实时更新点赞数量
- 视觉反馈(已点赞的按钮变为主色调)
- 防重复点击loading状态
- 同步更新列表和详情页状态
### 评论系统
- 支持多层嵌套回复
- 评论和回复的独立点赞
- 用户权限控制(只能删除自己的评论)
- 富文本内容支持
- 实时评论数量更新
### 安全和权限
- 所有交互功能都需要登录
- 后端API有用户身份验证
- 前端有相应的权限检查和提示
## 待优化项目
- [ ] 添加评论分页功能
- [ ] 实现评论的编辑功能
- [ ] 添加评论的举报功能
- [ ] 优化评论的实时更新
- [ ] 添加富文本编辑器支持图片等
- [ ] 实现评论的@功能

@ -0,0 +1,61 @@
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`);
}
};

@ -2,10 +2,19 @@ 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
scheduleApi,
commentApi
};
export * from './user';
export * from './post';
export * from './comment';
export * from './resource';
export * from './schedule';
export * from './search';

@ -0,0 +1,64 @@
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')
}

@ -0,0 +1,521 @@
<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>

@ -78,6 +78,13 @@ const routes: Array<RouteRecordRaw> = [
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 }
}
]
},

@ -88,7 +88,7 @@ export const usePostStore = defineStore('post', {
this.currentPost = response.data;
} else {
// Construct a more informative error or use a default
const errorMessage = response?.message || (response?.data?.toString() ? `错误: ${response.data.toString()}` : '获取帖子详情失败');
const errorMessage = response?.data?.toString() ? `错误: ${response.data.toString()}` : '获取帖子详情失败';
console.error('Failed to fetch post detail:', response);
throw new Error(errorMessage);
}
@ -96,15 +96,16 @@ export const usePostStore = defineStore('post', {
// 检查是否是未登录错误
if (error.response && error.response.status === 401) {
this.error = '您需要登录后才能查看帖子详情';
ElMessage.warning(this.error);
ElMessage.warning('您需要登录后才能查看帖子详情');
// 将用户重定向到登录页面,并记录需要返回的帖子详情页面
const currentPath = `/post/${id}`;
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
return;
} else {
// 处理其他错误
this.error = error.message || '加载帖子详情时发生未知错误';
ElMessage.error(this.error);
const errorMsg = error.message || '加载帖子详情时发生未知错误';
this.error = errorMsg;
ElMessage.error(errorMsg);
}
} finally {
this.loading = false;
@ -140,6 +141,47 @@ export const usePostStore = defineStore('post', {
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 || '操作失败,请稍后重试');
}
}
}
}
});

@ -170,11 +170,11 @@ export const useUserStore = defineStore('user', () => {
}) => {
try {
loading.value = true;
const params: UpdatePasswordParams = {};
const params: Partial<UpdatePasswordParams> = {};
if (data.newPassword) params.newPassword = data.newPassword;
if (data.code) params.code = data.code;
const res = await userApi.updatePassword(params);
const res = await userApi.updatePassword(params as UpdatePasswordParams);
if (res.code === 200) {
ElMessage.success('密码修改成功');

@ -0,0 +1,391 @@
<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="all">全部</el-radio-button>
<el-radio-button label="post">帖子</el-radio-button>
<el-radio-button label="resource">资源</el-radio-button>
<el-radio-button label="user">用户</el-radio-button>
</el-radio-group>
<el-select v-model="sortBy" placeholder="排序方式" @change="handleSearch" style="width: 120px; margin-left: 10px;">
<el-option label="相关性" value="relevance" />
<el-option label="时间" value="time" />
<el-option label="热门度" value="popularity" />
</el-select>
</div>
</div>
<!-- 搜索结果 -->
<div class="search-content" v-loading="loading">
<!-- 搜索统计 -->
<div class="search-stats" v-if="searchResult">
<span>找到 {{ searchResult.total }} 个结果</span>
<span class="search-time">耗时 {{ searchResult.searchTime }}ms</span>
</div>
<!-- 搜索结果列表 -->
<div class="search-results" v-if="searchResult && searchResult.items.length > 0">
<div
v-for="item in searchResult.items"
:key="`${item.type}-${item.id}`"
class="search-item"
@click="handleItemClick(item)"
>
<div class="item-header">
<el-tag :type="getTypeTagType(item.type)" size="small">
{{ getTypeText(item.type) }}
</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.author }}</span>
</div>
<div class="meta-info">
<span v-if="item.categoryName" 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 }}
</span>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-else-if="searchResult && searchResult.items.length === 0"
description="没有找到相关内容"
/>
<!-- 分页 -->
<div class="pagination" v-if="searchResult && searchResult.total > 0">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="searchResult.total"
layout="prev, pager, next, jumper"
@current-change="handlePageChange"
/>
</div>
</div>
<!-- 热门搜索词 -->
<div class="hot-keywords" v-if="!searchResult">
<h3>热门搜索</h3>
<div class="keyword-tags">
<el-tag
v-for="keyword in hotKeywords"
:key="keyword"
@click="handleHotKeywordClick(keyword)"
style="margin: 5px; cursor: pointer;"
>
{{ keyword }}
</el-tag>
</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 } from '@element-plus/icons-vue'
import { search, searchPosts, searchResources, searchUsers, getHotKeywords } from '@/api/search'
import type { SearchParams, SearchResult, SearchItem } from '@/api/search'
const route = useRoute()
const router = useRouter()
//
const loading = ref(false)
const searchKeyword = ref('')
const searchType = ref<'all' | 'post' | 'resource' | 'user'>('all')
const sortBy = ref<'time' | 'relevance' | 'popularity'>('relevance')
const currentPage = ref(1)
const pageSize = ref(10)
//
const searchResult = ref<SearchResult>()
const hotKeywords = ref<string[]>([])
//
onMounted(async () => {
// URL
const keyword = route.query.keyword as string
if (keyword) {
searchKeyword.value = keyword
handleSearch()
}
//
await loadHotKeywords()
})
//
watch(() => route.query.keyword, (newKeyword) => {
if (newKeyword) {
searchKeyword.value = newKeyword as string
handleSearch()
}
})
//
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
ElMessage.warning('请输入搜索关键词')
return
}
loading.value = true
currentPage.value = 1
try {
const params: SearchParams = {
keyword: searchKeyword.value,
type: searchType.value,
sortBy: sortBy.value,
page: currentPage.value,
size: pageSize.value
}
let result
if (searchType.value === 'post') {
result = await searchPosts(params)
} else if (searchType.value === 'resource') {
result = await searchResources(params)
} else if (searchType.value === 'user') {
result = await searchUsers(params)
} else {
result = await search(params)
}
searchResult.value = result
// 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 handleItemClick = (item: SearchItem) => {
if (item.type === 'post') {
router.push(`/post/${item.id}`)
} else if (item.type === 'resource') {
router.push(`/resource/${item.id}`)
} else if (item.type === 'user') {
// TODO:
ElMessage.info('用户主页功能待开发')
}
}
//
const handleHotKeywordClick = (keyword: string) => {
searchKeyword.value = keyword
handleSearch()
}
//
const loadHotKeywords = async () => {
try {
const result = await getHotKeywords()
hotKeywords.value = result
} catch (error) {
console.error('获取热门搜索词失败:', error)
}
}
//
const getTypeTagType = (type: string) => {
switch (type) {
case 'post': return 'primary'
case 'resource': return 'success'
case 'user': return 'warning'
default: return 'info'
}
}
//
const getTypeText = (type: string) => {
switch (type) {
case 'post': return '帖子'
case 'resource': return '资源'
case 'user': return '用户'
default: return '未知'
}
}
//
const formatTime = (time: string) => {
return new Date(time).toLocaleDateString()
}
</script>
<style scoped>
.search-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.search-header {
margin-bottom: 20px;
}
.search-input-container {
margin-bottom: 15px;
}
.search-filters {
display: flex;
align-items: center;
gap: 10px;
}
.search-stats {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
color: #666;
font-size: 14px;
}
.search-time {
color: #999;
}
.search-results {
margin-bottom: 20px;
}
.search-item {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
cursor: pointer;
transition: all 0.3s;
}
.search-item:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
.item-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.item-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.item-content {
margin-bottom: 15px;
}
.item-summary {
color: #606266;
line-height: 1.6;
margin: 0;
}
.item-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #909399;
}
.author-info {
display: flex;
align-items: center;
gap: 8px;
}
.author-name {
color: #409eff;
}
.meta-info {
display: flex;
align-items: center;
gap: 15px;
}
.category {
background: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
.stats {
display: flex;
align-items: center;
gap: 5px;
}
.pagination {
display: flex;
justify-content: center;
margin-top: 30px;
}
.hot-keywords {
margin-top: 40px;
}
.hot-keywords h3 {
margin-bottom: 15px;
color: #303133;
}
</style>

@ -28,36 +28,54 @@
<div class="post-body" v-html="postStore.currentPost.content"></div>
<el-divider />
<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>
<el-tag v-if="postStore.currentPost.isLiked" type="success" effect="light"></el-tag>
<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>
<!-- Placeholder for comments section -->
<!-- 评论区组件 -->
<el-card class="comments-section" v-if="postStore.currentPost">
<template #header>
<h3>评论区</h3>
</template>
<el-empty description="暂无评论,敬请期待!"></el-empty>
<!-- Actual comments list and form will go here later -->
<CommentSection :post-id="postStore.currentPost.id" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, watch } from 'vue';
import { onMounted, watch, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { usePostStore } from '@/stores/postStore';
import { ElMessage, ElIcon, ElButton, ElCard, ElSkeleton, ElAlert, ElDivider, ElTag, ElEmpty } from 'element-plus';
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 '';
@ -69,6 +87,26 @@ 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)) {
@ -138,13 +176,19 @@ h1 {
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);
margin-top: 10px;
}
.post-stats span {
@ -153,7 +197,12 @@ h1 {
gap: 5px;
}
.post-actions {
display: flex;
gap: 10px;
}
.comments-section {
margin-top: 30px;
margin-top: 30px;
}
</style>

@ -3,6 +3,21 @@
<el-card class="filter-card" shadow="never">
<div class="filter-controls">
<div class="filter-left">
<el-input
v-model="searchKeyword"
placeholder="搜索帖子标题或内容..."
@keyup.enter="handleSearch"
@clear="clearSearch"
clearable
style="width: 300px;"
>
<template #append>
<el-button @click="handleSearch" :loading="searchLoading">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-select
v-model="selectedCategoryComputed"
placeholder="选择分类"
@ -10,7 +25,7 @@
@clear="clearCategorySelection"
style="width: 240px;"
>
<el-option label="全部分类" :value="null"></el-option>
<el-option label="全部分类" value=""></el-option>
<el-option
v-for="category in postStore.categories"
:key="category.id"
@ -64,6 +79,22 @@
<span><el-icon><ChatDotRound /></el-icon> {{ post.commentCount }}</span>
<span><el-icon><Clock /></el-icon> {{ formatDate(post.createdAt) }}</span>
</div>
<div class="post-actions">
<el-button
v-if="userStore.isLoggedIn"
size="small"
:type="post.isLiked ? 'primary' : ''"
:loading="likingPostId === post.id"
@click.stop="toggleLike(post)"
>
<el-icon><Pointer /></el-icon>
{{ post.isLiked ? '已点赞' : '点赞' }}
</el-button>
<el-button v-else size="small" @click.stop="goLogin">
<el-icon><Pointer /></el-icon>
点赞
</el-button>
</div>
</el-card>
</el-col>
</el-row>
@ -84,28 +115,59 @@
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue';
import { onMounted, computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { usePostStore } from '@/stores/postStore';
import { useUserStore } from '@/stores';
import { ElMessage, ElIcon, ElCard, ElSkeleton, ElAlert, ElRow, ElCol, ElDivider, ElPagination, ElEmpty, ElSelect, ElOption, ElButton } from 'element-plus';
import { User, FolderOpened, View, Pointer, ChatDotRound, Clock, Edit } from '@element-plus/icons-vue';
import { ElMessage, ElIcon, ElCard, ElSkeleton, ElAlert, ElRow, ElCol, ElDivider, ElPagination, ElEmpty, ElSelect, ElOption, ElButton, ElInput } from 'element-plus';
import { User, FolderOpened, View, Pointer, ChatDotRound, Clock, Edit, Search } from '@element-plus/icons-vue';
const router = useRouter();
const postStore = usePostStore();
const userStore = useUserStore();
//
const searchKeyword = ref('');
const searchLoading = ref(false);
//
const likingPostId = ref<number | null>(null);
// Computed property to two-way bind el-select with store's selectedCategoryId
// and trigger store action on change.
const selectedCategoryComputed = computed({
get: () => postStore.selectedCategoryId,
get: () => postStore.selectedCategoryId ?? "",
set: (value) => {
// storeselectCategory
console.log('选择分类:', value);
postStore.selectCategory(value);
postStore.selectCategory(value === "" ? null : value);
}
});
//
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
ElMessage.warning('请输入搜索关键词');
return;
}
searchLoading.value = true;
try {
// 使
router.push(`/search?keyword=${encodeURIComponent(searchKeyword.value)}&type=post`);
} catch (error) {
console.error('搜索失败:', error);
ElMessage.error('搜索失败,请稍后重试');
} finally {
searchLoading.value = false;
}
};
//
const clearSearch = () => {
searchKeyword.value = '';
};
const clearCategorySelection = () => {
//
postStore.selectCategory(null);
@ -136,6 +198,29 @@ const createNewPost = () => {
}
};
// /
const toggleLike = async (post: any) => {
likingPostId.value = post.id;
try {
await postStore.likePost(post.id);
} finally {
likingPostId.value = null;
}
};
//
const goLogin = () => {
if (userStore.isLoggedIn) {
router.push({ name: 'CreatePost' });
} else {
ElMessage.warning('请先登录');
router.push({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
});
}
};
const handleCurrentChange = (page: number) => {
postStore.fetchPosts({ pageNum: page });
};
@ -232,6 +317,12 @@ onMounted(async () => {
gap: 5px;
}
.post-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.pagination-container {
display: flex;
justify-content: center;

@ -151,7 +151,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, FormInstance } from 'element-plus';
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus';
import { resourceApi } from '@/api';
import { useUserStore } from '@/stores';
import { Document, Picture, Reading, Grid, Promotion, Folder, Files, Upload, Download, Star, Search } from '@element-plus/icons-vue';
@ -203,12 +203,23 @@ onMounted(async () => {
const fetchResources = async () => {
loading.value = true;
try {
const params = {
const params: any = {
page: currentPage.value,
size: pageSize.value,
...filters
size: pageSize.value
};
// categoryIdnull
if (filters.categoryId !== null) {
params.category = filters.categoryId; // category
}
// keyword
if (filters.keyword && filters.keyword.trim()) {
params.keyword = filters.keyword.trim();
}
console.log('请求参数:', params); //
const res = await resourceApi.getResources(params);
if (res.code === 200) {
resources.value = res.data.list;

@ -4,36 +4,47 @@ UniLife 是一款面向学生的在线论坛,致力于提升校园生活体验
## 功能特性
### 1.用户系统
+ 账号注册/登录(支持智慧珞珈认证
### 1.用户系统
+ 账号注册/登录(支持邮箱验证码登录
+ 个人资料管理
+ JWT认证机制
### 2.论坛功能
### 2.论坛功能
+ 主题发布/评论/点赞
+ 话题分类(学习、校园生活、兴趣交流等)
+ 接入校园新闻(自动抓取/人工编辑)
### 3.学习资源共享
+ 共享文档(学生自主填写各课程作业信息)
+ 资料上传/下载权限管理
### 4.课程表 & 个人行程安排
+ 课程表导入API 接入学校系统或手动录入)
+ 个人日程管理(整合课程表,支持自定义编辑)
### 5.AI 辅助学习
+ 学习计划制定(结合课程安排和个人目标)
+ 任务提醒(结合课程表,提供智能通知)
+ 部分课程智能辅助
### 6.查询功能
+ 课程信息查询
+ 资料搜索
+ 论坛帖子搜索
### 7.积分系统
+ 贡献积分(发帖、上传资源等)
+ 兑换功能(待定,可用于解锁高级功能等)
+ 嵌套评论系统
### 3.学习资源共享 ✅
+ 资源上传/下载
+ 资源分类管理
+ 文件存储支持阿里云OSS
### 4.课程表 & 个人行程安排 ✅
+ 课程信息管理
+ 个人日程管理
+ 课程冲突检测
+ 日程提醒功能
### 5.搜索功能 ✅
+ 综合搜索(帖子/资源/用户)
+ 分类搜索和过滤
+ 搜索建议
+ 热门搜索词
### 6.AI 辅助学习 🚧
+ 学习计划制定(开发中)
+ 智能任务提醒(开发中)
+ AI问答助手开发中
### 7.积分系统 🚧
+ 贡献积分系统(规划中)
+ 成就系统(规划中)
+ 积分排行榜(规划中)
### 8.实时通知 🚧
+ WebSocket实时推送规划中
+ 消息中心(规划中)
+ 私信功能(规划中)
## 小组成员:
王雨菲 蔡子钦 钟宏烨 贾瀚翔 胡天琦 刘宇航

@ -1,386 +0,0 @@
# UniLife开发进度与计划
## 目录
- [一、当前开发进度](#一当前开发进度)
- [1.1 已完成功能](#11-已完成功能)
- [1.2 本次开发内容](#12-本次开发内容)
- [二、下一步开发计划](#二下一步开发计划)
- [2.1 课程表与日程管理模块](#21-课程表与日程管理模块)
- [2.2 搜索功能模块](#22-搜索功能模块)
- [2.3 前端页面实现](#23-前端页面实现)
- [2.4 AI辅助学习模块](#24-ai辅助学习模块)
- [2.5 积分系统模块](#25-积分系统模块)
- [三、开发阶段规划](#三开发阶段规划)
- [3.1 阶段一:基础功能完善](#31-阶段一基础功能完善)
- [3.2 阶段二:核心功能开发](#32-阶段二核心功能开发)
- [3.3 阶段三:高级功能开发](#33-阶段三高级功能开发)
- [3.4 阶段四:优化与测试](#34-阶段四优化与测试)
- [四、技术难点与解决方案](#四技术难点与解决方案)
- [4.1 评论嵌套结构](#41-评论嵌套结构)
- [4.2 点赞功能实现](#42-点赞功能实现)
- [4.3 文件上传与存储](#43-文件上传与存储)
- [4.4 课程表与日程管理](#44-课程表与日程管理)
- [4.5 搜索功能实现](#45-搜索功能实现)
- [五、后续优化建议](#五后续优化建议)
- [5.1 性能优化](#51-性能优化)
- [5.2 安全性优化](#52-安全性优化)
- [5.3 用户体验优化](#53-用户体验优化)
- [5.4 代码质量优化](#54-代码质量优化)
## 一、当前开发进度
### 1.1 已完成功能
#### 前端
- ✅ 基础环境搭建Vue 3 + TypeScript + Vite
- ✅ 基础组件库集成Element Plus
- ✅ HTTP请求封装Axios
- ✅ 页面原型设计
#### 后端
- ✅ 用户认证模块
- 用户注册
- 用户登录(密码登录)
- 邮箱验证码获取
- 邮箱验证码登录
- JWT认证与拦截器
- ✅ 用户信息管理模块
- 获取用户个人信息
- 更新用户个人信息
- 修改用户密码
- 上传用户头像
- 更新用户邮箱
- ✅ 论坛功能模块
- 帖子发布、获取、更新、删除API
- 帖子点赞功能
- 评论发布、获取、删除API
- 评论点赞功能
- 分类管理API
- ✅ 学习资源共享模块
- 资源上传、获取、更新、删除API
- 资源下载功能
- 资源点赞功能
- 资源分类查询
- 用户资源管理
- ✅ 项目文档
- 接口文档
- 开发进度文档
- 开发计划文档
- ✅ 课程表与日程管理模块
- 课程创建、获取、更新、删除API
- 日程创建、获取、更新、删除API
- 课程冲突检测功能
- 日程提醒功能(邮件通知)
### 1.2 本次开发内容
在本次开发中,我们完成了以下工作:
#### 1.2.1 学习资源共享模块实现
1. 设计并实现了资源相关的实体类、DTO和VO
- Resource实体类定义资源的基本属性和关系
- CreateResourceDTO用于创建和更新资源的数据传输对象
- ResourceVO用于返回给前端的资源视图对象包含额外信息
2. 设计并实现了ResourceMapper接口和XML映射文件
- 实现了资源的CRUD操作
- 支持按分类、用户和关键词查询
- 实现了资源点赞和下载计数功能
3. 设计并实现了ResourceService接口和实现类
- 实现了资源上传逻辑,包括文件存储
- 实现了资源下载逻辑
- 实现了资源分类和搜索逻辑
- 实现了权限控制和安全检查
4. 设计并实现了ResourceController提供资源相关的RESTful API
- 资源上传API
- 资源下载API
- 资源列表和详情API
- 资源搜索API
- 用户资源管理API
#### 1.2.2 文档完善与更新
1. 创建了完整的API接口文档
- 详细记录了所有已实现API的请求参数和响应格式
- 按模块分类组织,便于查阅
- 包含了认证、用户信息、论坛功能和资源共享等模块
2. 更新了开发进度文档
- 记录了已完成的功能模块
- 规划了下一步的开发计划
- 分析了技术难点和解决方案
- 提出了后续优化建议
#### 1.2.3 课程表与日程管理模块实现
1. 设计并实现了课程和日程相关的实体类、DTO和VO
- Course实体类定义课程的基本属性和关系
- Schedule实体类定义日程的基本属性和关系
- CreateCourseDTO和CourseVO用于课程的创建、更新和展示
- CreateScheduleDTO和ScheduleVO用于日程的创建、更新和展示
2. 设计并实现了CourseMapper和ScheduleMapper接口及XML映射文件
- 实现了课程和日程的CRUD操作
- 支持按用户、时间和日期查询
- 实现了课程和日程的冲突检测功能
3. 设计并实现了CourseService和ScheduleService接口及实现类
- 实现了课程管理逻辑,包括创建、查询、更新和删除
- 实现了日程管理逻辑,包括创建、查询、更新和删除
- 实现了课程冲突检测逻辑
- 实现了日程提醒功能,包括邮件通知
4. 设计并实现了CourseController和ScheduleController
- 课程管理API包括创建、获取、更新和删除课程
- 日程管理API包括创建、获取、更新和删除日程
- 课程冲突检测API
- 日程提醒处理API
## 二、下一步开发计划
### 2.1 搜索功能模块
**优先级**:中
**任务**
1. 设计并实现搜索相关的DTO和VO
- SearchDTO
- SearchResultVO
2. 设计并实现SearchService接口和实现类
- 全文搜索逻辑
- 分类搜索逻辑
- 搜索结果排序
3. 设计并实现SearchController
- 全局搜索API
- 分类搜索API
- 搜索建议API
4. 实现全文搜索功能
- 帖子内容搜索
- 资源内容搜索
- 用户信息搜索
5. 实现分类搜索功能
- 按分类筛选
- 按标签筛选
- 高级筛选条件
### 2.3 前端页面实现
**优先级**:高
**任务**
1. 实现用户认证相关页面
- 登录页面
- 注册页面
- 找回密码页面
2. 实现用户信息管理相关页面
- 个人主页
- 个人设置页面
- 头像上传组件
3. 实现论坛功能相关页面
- 帖子列表页面
- 帖子详情页面
- 帖子发布/编辑页面
- 评论组件
4. 实现资源共享相关页面
- 资源列表页面
- 资源详情页面
- 资源上传页面
5. 实现课程表与日程相关页面
- 课程表页面
- 日程管理页面
- 日程添加/编辑页面
### 2.4 AI辅助学习模块
**优先级**:低
**任务**
1. 调研并选择合适的AI服务
2. 完成学习计划制定API
3. 完成智能提醒API
4. 实现前端AI助手页面
### 2.5 积分系统模块
**优先级**:低
**任务**
1. 完成积分规则设计
2. 完成积分获取、消费API
3. 完成积分排行榜API
4. 实现前端积分展示功能
5. 实现前端积分排行榜页面
## 三、开发阶段规划
### 3.1 阶段一基础功能完善2周
#### 用户信息管理模块(已完成)
- 完成用户个人信息获取API
- 完成用户个人信息更新API
- 完成用户密码修改API
- 完成用户头像上传API
- 完成用户邮箱更新API
#### 论坛功能模块(基础部分)(已完成)
- 完成帖子表、评论表、分类表的数据库设计
- 完成帖子发布、获取、更新、删除API
- 完成评论发布、获取API
### 3.2 阶段二核心功能开发3周
#### 论坛功能模块(高级部分)(已完成)
- 完成点赞、收藏功能API
- 完成帖子分类管理API
- 完成帖子热门排序算法
#### 学习资源共享模块(已完成)
- 完成资源表的数据库设计
- 完成资源上传、下载、分类API
- 完成资源权限管理API
#### 课程表与日程管理模块(已完成)
- 完成课程表、日程表的数据库设计
- 完成课程创建、获取、更新、删除API
- 完成日程创建、获取、更新、删除API
- 完成课程冲突检测功能
- 完成日程提醒功能(邮件通知)
### 3.3 阶段三高级功能开发3周
#### 搜索功能模块(待开发)
- 完成全文搜索功能API
- 完成分类搜索功能API
- 实现前端搜索结果页面
- 实现前端搜索建议功能
#### 积分系统模块(待开发)
- 完成积分规则设计
- 完成积分获取、消费API
- 完成积分排行榜API
- 实现前端积分展示功能
- 实现前端积分排行榜页面
#### AI辅助学习模块待开发
- 调研并选择合适的AI服务
- 完成学习计划制定API
- 完成智能提醒API
- 实现前端AI助手页面
### 3.4 阶段四优化与测试2周
#### 性能优化(待开发)
- 前端性能优化(代码分割、懒加载等)
- 后端性能优化(缓存、索引等)
- 数据库性能优化
#### 测试与修复(待开发)
- 单元测试
- 集成测试
- 用户界面测试
- Bug修复
## 四、技术难点与解决方案
### 4.1 评论嵌套结构
**难点**:评论可能有多级嵌套回复,需要合理设计数据结构和查询方式。
**解决方案**
1. 在数据库中使用parent_id字段记录父评论ID
2. 查询时先获取一级评论,再获取每个一级评论的回复
3. 使用递归方式构建评论树结构
### 4.2 点赞功能实现
**难点**:需要记录用户点赞状态,并保证点赞/取消点赞操作的原子性。
**解决方案**
1. 创建专门的点赞表记录用户点赞关系
2. 使用唯一索引确保一个用户只能点赞一次
3. 使用事务确保点赞操作的原子性
### 4.3 文件上传与存储
**难点**:处理大文件上传,确保存储安全,提供高效的下载体验。
**解决方案**
1. 实现分片上传,将大文件分成多个小块上传,减轻服务器压力
2. 使用对象存储服务如阿里云OSS、七牛云等存储文件提高可靠性和访问速度
3. 设置文件类型和大小限制,防止恶意文件上传
4. 实现文件MD5校验避免重复上传相同文件
5. 使用CDN加速文件下载
### 4.4 课程表与日程管理
**难点**:课程表数据结构设计,日程冲突检测,提醒功能实现。
**解决方案**
1. 设计灵活的课程表数据结构,支持周期性课程和特殊课程
2. 使用算法检测日程冲突,提供冲突解决建议
3. 使用定时任务实现日程提醒功能
4. 提供多种提醒方式(站内通知、邮件等)
5. 实现日程导入/导出功能,支持与其他日历系统同步
### 4.5 搜索功能实现
**难点**:实现高效的全文搜索,处理大量数据的搜索性能问题。
**解决方案**
1. 使用Elasticsearch或MySQL全文索引实现搜索功能
2. 对搜索结果进行缓存,减少重复搜索的开销
3. 实现搜索结果分页和懒加载,提高用户体验
4. 使用分词技术提高中文搜索的准确性
5. 实现搜索结果排序和过滤功能
## 五、后续优化建议
### 5.1 性能优化
1. 添加缓存机制,减少数据库查询
- 使用Redis缓存热门数据
- 实现本地缓存减轻数据库压力
2. 优化SQL查询添加必要的索引
- 分析慢查询日志
- 优化复杂查询
3. 实现分页查询,减少数据传输量
4. 使用异步处理耗时操作
- 文件处理
- 邮件发送
- 数据统计
### 5.2 安全性优化
1. 完善权限控制系统
- 基于角色的访问控制
- 资源级别的权限控制
2. 加强输入验证和过滤
- 防止SQL注入
- 防止XSS攻击
3. 实现敏感操作的二次验证
4. 加强密码安全性
- 密码强度检测
- 定期修改密码提醒
### 5.3 用户体验优化
1. 实现实时通知功能
- WebSocket实现即时消息
- 消息推送机制
2. 优化页面加载速度
- 资源懒加载
- 组件按需加载
3. 添加更多交互反馈
- 操作成功/失败提示
- 加载状态指示
4. 实现个性化推荐
- 基于用户行为的内容推荐
- 热门内容推荐
### 5.4 代码质量优化
1. 增加单元测试和集成测试
- 提高测试覆盖率
- 自动化测试流程
2. 规范代码风格
- 使用代码格式化工具
- 制定统一的命名规范
3. 完善异常处理机制
- 全局异常处理
- 详细的错误日志
4. 重构复杂代码
- 提取公共方法
- 优化代码结构

@ -0,0 +1,332 @@
# UniLife技术要点与开发指南
## 📋 目录
- [一、项目架构概览](#一项目架构概览)
- [二、开发环境配置](#二开发环境配置)
- [三、核心技术要点](#三核心技术要点)
- [四、已实现功能详解](#四已实现功能详解)
- [五、待开发功能规划](#五待开发功能规划)
- [六、开发规范与最佳实践](#六开发规范与最佳实践)
- [七、常见问题与解决方案](#七常见问题与解决方案)
---
## 一、项目架构概览
### 🏗️ 整体架构
```
UniLife项目
├── 前端 (Vue 3 + TypeScript)
│ ├── 用户界面层
│ ├── 业务逻辑层
│ └── 数据访问层
├── 后端 (Spring Boot 3)
│ ├── 控制器层 (Controller)
│ ├── 服务层 (Service)
│ ├── 数据访问层 (Mapper)
│ └── 实体层 (Entity/DTO/VO)
└── 数据库 (MySQL + Redis)
├── 主数据库 (MySQL)
└── 缓存 (Redis)
```
### 🔄 数据流向
```
用户 → 前端页面 → API请求 → 后端控制器 → 服务层 → 数据库 → 返回结果
```
---
## 二、开发环境配置
### 🔧 后端环境要求
- **JDK**: 17或以上
- **Maven**: 3.6+
- **MySQL**: 8.0+
- **Redis**: 6.0+
- **IDE**: IntelliJ IDEA 推荐
### 🎨 前端环境要求
- **Node.js**: 18.0+
- **npm**: 8.0+ 或 pnpm 8.0+
- **IDE**: VS Code 推荐
### 📦 依赖安装
#### 后端依赖
```bash
cd unilife-server
mvn clean install
```
#### 前端依赖
```bash
cd Front/vue-unilife
npm install
# 或
pnpm install
```
---
## 三、核心技术要点
### 🔐 认证与安全
- **JWT认证机制**用户登录后颁发JWT Token
- **拦截器配置**自动验证Token有效性
- **权限控制**:基于角色的访问控制
```java
// JWT配置示例
@Component
public class JWTInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// Token验证逻辑
}
}
```
### 📊 数据库设计
- **主键策略**统一使用BIGINT自增主键
- **时间字段**created_at, updated_at 自动维护
- **软删除**使用status字段标记删除状态
- **外键关系**:合理设计表间关系
### 🔄 API设计规范
- **RESTful风格**GET/POST/PUT/DELETE语义化
- **统一响应格式**Result<T>包装返回数据
- **分页查询**使用PageHelper插件
- **异常处理**:全局异常处理器
```java
// 统一响应格式
public class Result<T> {
private Integer code;
private String message;
private T data;
}
```
### 🎯 前端架构设计
- **组件化开发**:可复用组件设计
- **路由管理**:嵌套路由与权限控制
- **状态管理**Pinia集中状态管理
- **API封装**统一HTTP请求处理
---
## 四、已实现功能详解
### 👤 用户认证系统
- **注册流程**:邮箱验证 → 用户信息填写 → 账号创建
- **登录方式**:用户名密码登录 + 邮箱验证码登录
- **信息管理**:个人资料修改、密码修改、头像上传
### 💬 论坛功能模块
- **帖子管理**:发布、编辑、删除、查看
- **评论系统**:嵌套回复、点赞功能
- **分类系统**:多级分类管理
### 📚 资源共享模块
- **文件上传**:支持多种文件格式
- **存储方案**:本地存储 + 阿里云OSS
- **下载统计**:下载次数自动统计
### 📅 课程表与日程
- **课程管理**:添加、编辑、删除课程
- **日程管理**:个人日程安排
- **冲突检测**:时间冲突自动检测
- **邮件提醒**:日程提醒邮件发送
### 🔍 搜索功能
- **综合搜索**:跨模块内容搜索
- **分类搜索**:按类型筛选结果
- **搜索建议**:智能关键词建议
- **热门搜索**:热门关键词统计
---
## 五、待开发功能规划
### 🤖 AI辅助学习模块
#### 技术选型建议
- **AI服务**:百度文心一言、阿里通义千问
- **集成方式**HTTP API调用
- **功能设计**
- 学习计划生成
- 智能问答
- 学习进度分析
#### 数据库设计
```sql
-- 学习计划表
CREATE TABLE study_plans (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT,
ai_generated TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- AI对话记录
CREATE TABLE ai_conversations (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### 🎯 积分系统模块
#### 积分规则设计
```yaml
积分获取规则:
发布帖子: +10分
发布评论: +5分
上传资源: +20分
资源被下载: +2分
帖子被点赞: +1分
日常签到: +5分
完成学习计划: +15分
积分消费规则:
下载高级资源: -10分
使用AI问答: -5分
置顶帖子: -50分
```
#### 数据库设计
```sql
-- 积分记录表
CREATE TABLE points_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
points INT NOT NULL,
type VARCHAR(50) NOT NULL,
description VARCHAR(255),
related_id BIGINT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 成就表
CREATE TABLE achievements (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(255),
condition_type VARCHAR(50),
condition_value INT,
points_reward INT DEFAULT 0
);
```
### 🔔 实时通知系统
#### 技术方案
- **WebSocket**:实时双向通信
- **消息队列**RabbitMQ处理大量消息
- **通知类型**:系统通知、点赞通知、评论通知、私信
#### 实现步骤
1. 添加WebSocket依赖
2. 配置WebSocket端点
3. 实现消息推送逻辑
4. 前端WebSocket客户端连接
---
## 六、开发规范与最佳实践
### 📝 代码规范
#### 后端规范
- **命名规范**:驼峰命名法
- **注释规范**重要方法必须添加JavaDoc
- **异常处理**:统一异常处理机制
- **日志规范**使用SLF4J记录关键操作
#### 前端规范
- **组件命名**PascalCase
- **变量命名**camelCase
- **文件命名**kebab-case
- **代码格式**使用Prettier统一格式
### 🔧 开发流程
1. **需求分析** → 确定功能需求
2. **技术设计** → 设计技术方案
3. **数据库设计** → 设计表结构
4. **API设计** → 定义接口规范
5. **编码实现** → 实现具体功能
6. **测试验证** → 功能测试
7. **文档更新** → 更新相关文档
### 📊 性能优化建议
- **数据库优化**:合理使用索引
- **缓存机制**Redis缓存热点数据
- **懒加载**:前端组件按需加载
- **图片优化**图片压缩和CDN加速
---
## 七、常见问题与解决方案
### 🐛 常见问题
#### 1. 跨域问题
**问题**前端调用后端API出现跨域错误
**解决方案**
```java
@CrossOrigin(origins = "http://localhost:5173")
@RestController
public class UserController {
// 控制器方法
}
```
#### 2. JWT Token过期
**问题**用户Token过期后无法访问
**解决方案**
- 实现Token自动刷新机制
- 前端拦截401响应引导用户重新登录
#### 3. 文件上传失败
**问题**:大文件上传超时或失败
**解决方案**
- 增加文件上传大小限制配置
- 实现分片上传功能
- 添加上传进度显示
#### 4. 数据库连接异常
**问题**:数据库连接池耗尽
**解决方案**
- 优化数据库连接池配置
- 检查慢SQL查询
- 合理使用事务
### 🔄 调试技巧
- **后端调试**使用IDE断点调试
- **前端调试**:浏览器开发者工具
- **API测试**使用Postman或Knife4j
- **日志分析**:查看应用日志定位问题
---
## 📞 技术支持
如果在开发过程中遇到问题,可以:
1. 查阅项目文档
2. 查看相关技术官方文档
3. 在项目群中讨论
4. 提交Issue到项目仓库
---
*最后更新2024年12月*
*维护团队UniLife开发组*

@ -0,0 +1,204 @@
# UniLife项目开发计划与进度更新
## 📊 项目当前状态分析2024年12月
### ✅ 已完成功能模块
#### 🔧 后端功能(基本完整)
1. **用户认证系统** ✅
- 用户注册/登录(密码+邮箱验证码)
- JWT认证机制
- 用户信息管理
2. **论坛功能模块** ✅
- 帖子CRUD操作
- 评论系统(支持嵌套回复)
- 点赞功能
- 分类管理
3. **学习资源共享模块** ✅
- 资源上传/下载
- 资源分类管理
- 资源点赞功能
- 文件存储支持阿里云OSS
4. **课程表与日程管理模块** ✅
- 课程信息管理
- 个人日程管理
- 课程冲突检测
- 日程提醒功能
5. **搜索功能模块** 🆕 ✅(新增)
- 综合搜索(帖子/资源/用户)
- 分类搜索
- 搜索建议
- 热门搜索词
#### 🎨 前端功能(部分完成)
1. **基础架构** ✅
- Vue 3 + TypeScript + Vite
- Element Plus组件库
- 路由系统
- API封装
2. **页面实现** ✅
- 登录/注册页面
- 论坛相关页面(帖子列表/详情/发布)
- 资源管理页面
- 课程表和日程页面
- 个人中心页面
- 搜索页面(新增)
### ❌ 待完成功能模块
#### 🤖 AI辅助学习模块高优先级
- 学习计划制定
- 智能任务提醒
- 学习进度跟踪
- AI问答助手
#### 🎯 积分系统模块(中优先级)
- 积分获取规则
- 积分消费机制
- 积分排行榜
- 成就系统
#### 🔔 实时通知系统(中优先级)
- WebSocket实时推送
- 系统通知
- 私信功能
- 通知中心
#### 🔧 系统优化(低优先级)
- 性能优化
- 安全性增强
- 用户体验改进
- 代码质量提升
---
## 🎯 详细开发计划
### 🚀 阶段一AI辅助学习模块预计2-3周
#### 📋 任务分解
**1. 技术选型与集成**
- [ ] 调研AI服务供应商百度文心、阿里通义、OpenAI等
- [ ] 选择合适的AI API服务
- [ ] 配置API密钥和接口
**2. 后端开发**
- [ ] 创建AI相关实体类和DTO
- [ ] 实现AIService服务层
- [ ] 创建AIController控制器
- [ ] 集成外部AI服务
**3. 前端开发**
- [ ] 创建AI助手页面
- [ ] 实现学习计划制定界面
- [ ] 开发AI聊天功能
- [ ] 学习进度可视化
### 💎 阶段二积分系统模块预计1-2周
#### 📋 任务分解
**1. 积分规则设计**
- [ ] 定义积分获取规则
- [ ] 积分消费机制设计
**2. 后端开发**
- [ ] 创建积分相关实体类
- [ ] 实现PointsService服务层
- [ ] 创建积分事件监听器
- [ ] 积分统计和排行榜功能
**3. 前端开发**
- [ ] 积分展示组件
- [ ] 积分排行榜页面
- [ ] 成就系统界面
- [ ] 积分历史记录
### 📢 阶段三实时通知系统预计1-2周
#### 📋 任务分解
**1. WebSocket集成**
- [ ] 添加WebSocket依赖
- [ ] 配置WebSocket连接
- [ ] 实现消息推送机制
**2. 后端开发**
- [ ] 通知实体类和DTO
- [ ] NotificationService服务层
- [ ] WebSocket消息处理器
**3. 前端开发**
- [ ] WebSocket客户端连接
- [ ] 通知中心界面
- [ ] 实时消息提示
### 🔧 阶段四系统优化与完善预计1周
#### 📋 任务分解
**1. 性能优化**
- [ ] 数据库查询优化
- [ ] 缓存机制完善
- [ ] 前端打包优化
**2. 安全性增强**
- [ ] 输入验证加强
- [ ] 攻击防护
- [ ] 权限控制完善
**3. 用户体验改进**
- [ ] 响应式设计优化
- [ ] 加载状态优化
- [ ] 错误处理改进
---
## 📚 技术栈总结
### 🔧 后端技术栈
- **框架**Spring Boot 3.4.3
- **数据库**MySQL 8.0
- **缓存**Redis
- **ORM**MyBatis 3.0.4
- **认证**JWT
- **文档**Knife4jOpenAPI3
### 🎨 前端技术栈
- **框架**Vue 3.5.13
- **语言**TypeScript
- **构建工具**Vite 6.2.0
- **UI框架**Element Plus 2.9.7
- **路由**Vue Router 4.5.0
- **状态管理**Pinia 3.0.2
---
## 📈 进度里程碑
| 阶段 | 预计完成时间 | 主要交付物 |
|------|-------------|------------|
| 搜索功能 | ✅ 已完成 | 搜索API + 搜索页面 |
| AI辅助学习 | 2-3周后 | AI助手功能 + 学习计划 |
| 积分系统 | 4-5周后 | 积分规则 + 排行榜 |
| 实时通知 | 6-7周后 | 通知中心 + 私信功能 |
| 系统优化 | 8周后 | 性能优化 + 安全加固 |
---
## 📋 下一步行动项
1. **立即开始**AI辅助学习模块的技术调研
2. **本周完成**:搜索功能的测试和优化
3. **下周开始**AI服务API的申请和配置
4. **持续进行**:文档更新和代码规范维护
---
*最后更新2024年12月*

@ -117,16 +117,9 @@ unilife-server/
- ✅ 用户认证模块(注册、登录、验证码)
- ✅ 用户信息管理模块(个人信息、密码修改、头像上传)
- ✅ 论坛功能模块(帖子、评论、点赞、分类)
- ✅ 学习资源共享模块(资源上传、下载、点赞、分类)
- ✅ 项目文档(接口文档、开发进度、开发计划)
- ✅ 学习资源共享模块(资源上传、下载、点赞、分类)- ✅ 课程表与日程管理模块(课程管理、日程管理、冲突检测、提醒功能)- ✅ 搜索功能模块(综合搜索、分类搜索、搜索建议、热门关键词)- ✅ 项目文档(接口文档、开发进度、开发计划)
### 2.2 待实现功能
- ❌ 课程表与日程管理模块
- ❌ 搜索功能模块
- ❌ 前端页面实现
- ❌ AI辅助学习模块
- ❌ 积分系统模块
### 2.2 待实现功能- ✅ 课程表与日程管理模块(已完成)- ✅ 搜索功能模块(已完成)- ✅ 前端页面实现(已完成)- ❌ AI辅助学习模块- ❌ 积分系统模块- ❌ 实时通知系统
## 三、开发计划

@ -0,0 +1,122 @@
package com.unilife.controller;
import com.unilife.common.result.Result;
import com.unilife.model.dto.SearchDTO;
import com.unilife.model.vo.SearchResultVO;
import com.unilife.service.SearchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
*
*/
@RestController
@RequestMapping("/search")
@RequiredArgsConstructor
@Tag(name = "搜索管理", description = "搜索相关接口")
public class SearchController {
private final SearchService searchService;
@GetMapping
@Operation(summary = "综合搜索", description = "根据关键词搜索帖子、资源和用户")
public Result<SearchResultVO> search(
@Parameter(description = "搜索关键词") @RequestParam String keyword,
@Parameter(description = "搜索类型") @RequestParam(defaultValue = "all") String type,
@Parameter(description = "分类ID") @RequestParam(required = false) Long categoryId,
@Parameter(description = "排序方式") @RequestParam(defaultValue = "relevance") String sortBy,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer size) {
SearchDTO searchDTO = new SearchDTO();
searchDTO.setKeyword(keyword);
searchDTO.setType(type);
searchDTO.setCategoryId(categoryId);
searchDTO.setSortBy(sortBy);
searchDTO.setPage(page);
searchDTO.setSize(size);
SearchResultVO result = searchService.search(searchDTO);
return Result.success(result);
}
@GetMapping("/posts")
@Operation(summary = "搜索帖子", description = "搜索论坛帖子")
public Result<SearchResultVO> searchPosts(
@Parameter(description = "搜索关键词") @RequestParam String keyword,
@Parameter(description = "分类ID") @RequestParam(required = false) Long categoryId,
@Parameter(description = "排序方式") @RequestParam(defaultValue = "relevance") String sortBy,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer size) {
SearchDTO searchDTO = new SearchDTO();
searchDTO.setKeyword(keyword);
searchDTO.setType("post");
searchDTO.setCategoryId(categoryId);
searchDTO.setSortBy(sortBy);
searchDTO.setPage(page);
searchDTO.setSize(size);
SearchResultVO result = searchService.searchPosts(searchDTO);
return Result.success(result);
}
@GetMapping("/resources")
@Operation(summary = "搜索资源", description = "搜索学习资源")
public Result<SearchResultVO> searchResources(
@Parameter(description = "搜索关键词") @RequestParam String keyword,
@Parameter(description = "分类ID") @RequestParam(required = false) Long categoryId,
@Parameter(description = "排序方式") @RequestParam(defaultValue = "relevance") String sortBy,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer size) {
SearchDTO searchDTO = new SearchDTO();
searchDTO.setKeyword(keyword);
searchDTO.setType("resource");
searchDTO.setCategoryId(categoryId);
searchDTO.setSortBy(sortBy);
searchDTO.setPage(page);
searchDTO.setSize(size);
SearchResultVO result = searchService.searchResources(searchDTO);
return Result.success(result);
}
@GetMapping("/users")
@Operation(summary = "搜索用户", description = "搜索用户")
public Result<SearchResultVO> searchUsers(
@Parameter(description = "搜索关键词") @RequestParam String keyword,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer size) {
SearchDTO searchDTO = new SearchDTO();
searchDTO.setKeyword(keyword);
searchDTO.setType("user");
searchDTO.setPage(page);
searchDTO.setSize(size);
SearchResultVO result = searchService.searchUsers(searchDTO);
return Result.success(result);
}
@GetMapping("/suggestions")
@Operation(summary = "获取搜索建议", description = "根据关键词获取搜索建议")
public Result<List<String>> getSuggestions(
@Parameter(description = "关键词") @RequestParam String keyword) {
List<String> suggestions = searchService.getSuggestions(keyword);
return Result.success(suggestions);
}
@GetMapping("/hot-keywords")
@Operation(summary = "获取热门搜索词", description = "获取热门搜索关键词列表")
public Result<List<String>> getHotKeywords() {
List<String> hotKeywords = searchService.getHotKeywords();
return Result.success(hotKeywords);
}
}

@ -94,4 +94,15 @@ public interface PostMapper {
* @return
*/
Integer getCountByUserId(@Param("userId") Long userId);
/**
*
* @param keyword
* @param categoryId IDnull
* @param sortBy
* @return
*/
List<Post> searchPosts(@Param("keyword") String keyword,
@Param("categoryId") Long categoryId,
@Param("sortBy") String sortBy);
}

@ -89,4 +89,15 @@ public interface ResourceMapper {
* @return
*/
Integer getCountByCategoryId(Long categoryId);
/**
*
* @param keyword
* @param categoryId IDnull
* @param sortBy
* @return
*/
List<Resource> searchResources(@Param("keyword") String keyword,
@Param("categoryId") Long categoryId,
@Param("sortBy") String sortBy);
}

@ -5,6 +5,7 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List;
@Mapper
public interface UserMapper {
@ -22,4 +23,11 @@ public interface UserMapper {
void updatePassword(@Param("id") Long id, @Param("newPassword") String newPassword);
void updateAvatar(@Param("id") Long id, @Param("avatar") String avatarUrl);
void updateEmail(@Param("id") Long id, @Param("email") String email);
/**
*
* @param keyword
* @return
*/
List<User> searchUsers(@Param("keyword") String keyword);
}

@ -0,0 +1,40 @@
package com.unilife.model.dto;
import lombok.Data;
/**
* DTO
*/
@Data
public class SearchDTO {
/**
*
*/
private String keyword;
/**
* all-, post-, resource-, user-
*/
private String type = "all";
/**
* ID
*/
private Long categoryId;
/**
* time-, relevance-, popularity-
*/
private String sortBy = "relevance";
/**
*
*/
private Integer page = 1;
/**
*
*/
private Integer size = 10;
}

@ -0,0 +1,111 @@
package com.unilife.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* VO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SearchResultVO {
/**
*
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class SearchItem {
/**
* ID
*/
private Long id;
/**
*
*/
private String title;
/**
*
*/
private String summary;
/**
* post-, resource-, user-
*/
private String type;
/**
* /
*/
private String author;
/**
*
*/
private String avatar;
/**
*
*/
private String categoryName;
/**
*
*/
private LocalDateTime createdAt;
/**
*
*/
private Integer likeCount;
/**
* /
*/
private Integer viewCount;
/**
*
*/
private List<String> highlights;
}
/**
*
*/
private List<SearchItem> items;
/**
*
*/
private Long total;
/**
*
*/
private Integer page;
/**
*
*/
private Integer size;
/**
*
*/
private String keyword;
/**
*
*/
private Long searchTime;
}

@ -0,0 +1,53 @@
package com.unilife.service;
import com.unilife.model.dto.SearchDTO;
import com.unilife.model.vo.SearchResultVO;
import java.util.List;
/**
*
*/
public interface SearchService {
/**
*
* @param searchDTO
* @return
*/
SearchResultVO search(SearchDTO searchDTO);
/**
*
* @param searchDTO
* @return
*/
SearchResultVO searchPosts(SearchDTO searchDTO);
/**
*
* @param searchDTO
* @return
*/
SearchResultVO searchResources(SearchDTO searchDTO);
/**
*
* @param searchDTO
* @return
*/
SearchResultVO searchUsers(SearchDTO searchDTO);
/**
*
* @param keyword
* @return
*/
List<String> getSuggestions(String keyword);
/**
*
* @return
*/
List<String> getHotKeywords();
}

@ -0,0 +1,287 @@
package com.unilife.service.impl;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.unilife.mapper.PostMapper;
import com.unilife.mapper.ResourceMapper;
import com.unilife.mapper.UserMapper;
import com.unilife.model.dto.SearchDTO;
import com.unilife.model.entity.Post;
import com.unilife.model.entity.Resource;
import com.unilife.model.entity.User;
import com.unilife.model.vo.SearchResultVO;
import com.unilife.service.SearchService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
*
*/
@Service
@RequiredArgsConstructor
public class SearchServiceImpl implements SearchService {
private final PostMapper postMapper;
private final ResourceMapper resourceMapper;
private final UserMapper userMapper;
private final StringRedisTemplate redisTemplate;
private static final String HOT_KEYWORDS_KEY = "search:hot_keywords";
private static final String SEARCH_HISTORY_KEY = "search:history:";
@Override
public SearchResultVO search(SearchDTO searchDTO) {
long startTime = System.currentTimeMillis();
// 记录搜索关键词
recordSearchKeyword(searchDTO.getKeyword());
List<SearchResultVO.SearchItem> allItems = new ArrayList<>();
// 搜索帖子
if ("all".equals(searchDTO.getType()) || "post".equals(searchDTO.getType())) {
SearchResultVO postResults = searchPosts(searchDTO);
allItems.addAll(postResults.getItems());
}
// 搜索资源
if ("all".equals(searchDTO.getType()) || "resource".equals(searchDTO.getType())) {
SearchResultVO resourceResults = searchResources(searchDTO);
allItems.addAll(resourceResults.getItems());
}
// 搜索用户
if ("all".equals(searchDTO.getType()) || "user".equals(searchDTO.getType())) {
SearchResultVO userResults = searchUsers(searchDTO);
allItems.addAll(userResults.getItems());
}
// 排序和分页
allItems = sortAndPage(allItems, searchDTO);
long endTime = System.currentTimeMillis();
SearchResultVO result = new SearchResultVO();
result.setItems(allItems);
result.setTotal((long) allItems.size());
result.setPage(searchDTO.getPage());
result.setSize(searchDTO.getSize());
result.setKeyword(searchDTO.getKeyword());
result.setSearchTime(endTime - startTime);
return result;
}
@Override
public SearchResultVO searchPosts(SearchDTO searchDTO) {
PageHelper.startPage(searchDTO.getPage(), searchDTO.getSize());
List<Post> posts = postMapper.searchPosts(
searchDTO.getKeyword(),
searchDTO.getCategoryId(),
searchDTO.getSortBy()
);
PageInfo<Post> pageInfo = new PageInfo<>(posts);
List<SearchResultVO.SearchItem> items = posts.stream()
.map(this::convertPostToSearchItem)
.collect(Collectors.toList());
SearchResultVO result = new SearchResultVO();
result.setItems(items);
result.setTotal(pageInfo.getTotal());
result.setPage(searchDTO.getPage());
result.setSize(searchDTO.getSize());
result.setKeyword(searchDTO.getKeyword());
return result;
}
@Override
public SearchResultVO searchResources(SearchDTO searchDTO) {
PageHelper.startPage(searchDTO.getPage(), searchDTO.getSize());
List<Resource> resources = resourceMapper.searchResources(
searchDTO.getKeyword(),
searchDTO.getCategoryId(),
searchDTO.getSortBy()
);
PageInfo<Resource> pageInfo = new PageInfo<>(resources);
List<SearchResultVO.SearchItem> items = resources.stream()
.map(this::convertResourceToSearchItem)
.collect(Collectors.toList());
SearchResultVO result = new SearchResultVO();
result.setItems(items);
result.setTotal(pageInfo.getTotal());
result.setPage(searchDTO.getPage());
result.setSize(searchDTO.getSize());
result.setKeyword(searchDTO.getKeyword());
return result;
}
@Override
public SearchResultVO searchUsers(SearchDTO searchDTO) {
PageHelper.startPage(searchDTO.getPage(), searchDTO.getSize());
List<User> users = userMapper.searchUsers(searchDTO.getKeyword());
PageInfo<User> pageInfo = new PageInfo<>(users);
List<SearchResultVO.SearchItem> items = users.stream()
.map(this::convertUserToSearchItem)
.collect(Collectors.toList());
SearchResultVO result = new SearchResultVO();
result.setItems(items);
result.setTotal(pageInfo.getTotal());
result.setPage(searchDTO.getPage());
result.setSize(searchDTO.getSize());
result.setKeyword(searchDTO.getKeyword());
return result;
}
@Override
public List<String> getSuggestions(String keyword) {
if (!StringUtils.hasText(keyword) || keyword.length() < 2) {
return new ArrayList<>();
}
// 从热门搜索词中匹配
Set<String> hotKeywords = redisTemplate.opsForZSet()
.reverseRange(HOT_KEYWORDS_KEY, 0, 19);
if (hotKeywords != null) {
return hotKeywords.stream()
.filter(k -> k.contains(keyword))
.limit(5)
.collect(Collectors.toList());
}
return new ArrayList<>();
}
@Override
public List<String> getHotKeywords() {
Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet()
.reverseRangeWithScores(HOT_KEYWORDS_KEY, 0, 9);
if (tuples != null) {
return tuples.stream()
.map(ZSetOperations.TypedTuple::getValue)
.collect(Collectors.toList());
}
return new ArrayList<>();
}
private void recordSearchKeyword(String keyword) {
if (StringUtils.hasText(keyword)) {
// 增加关键词热度
redisTemplate.opsForZSet().incrementScore(HOT_KEYWORDS_KEY, keyword, 1);
// 设置过期时间
redisTemplate.expire(HOT_KEYWORDS_KEY, 30, TimeUnit.DAYS);
}
}
private List<SearchResultVO.SearchItem> sortAndPage(List<SearchResultVO.SearchItem> items, SearchDTO searchDTO) {
// 根据排序方式排序
switch (searchDTO.getSortBy()) {
case "time":
items.sort((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt()));
break;
case "popularity":
items.sort((a, b) -> Integer.compare(b.getLikeCount(), a.getLikeCount()));
break;
default: // relevance
// 相关性排序可以基于关键词匹配度等
break;
}
// 简单分页
int start = (searchDTO.getPage() - 1) * searchDTO.getSize();
int end = Math.min(start + searchDTO.getSize(), items.size());
if (start >= items.size()) {
return new ArrayList<>();
}
return items.subList(start, end);
}
private SearchResultVO.SearchItem convertPostToSearchItem(Post post) {
SearchResultVO.SearchItem item = new SearchResultVO.SearchItem();
item.setId(post.getId());
item.setTitle(post.getTitle());
item.setSummary(getSummary(post.getContent()));
item.setType("post");
// 通过userId查询用户信息
User user = userMapper.getUserById(post.getUserId());
item.setAuthor(user != null ? user.getNickname() : "未知用户");
item.setAvatar(user != null ? user.getAvatar() : null);
// 分类名称暂时设为空需要通过categoryId查询
item.setCategoryName("未知分类");
item.setCreatedAt(post.getCreatedAt());
item.setLikeCount(post.getLikeCount());
item.setViewCount(post.getViewCount());
return item;
}
private SearchResultVO.SearchItem convertResourceToSearchItem(Resource resource) {
SearchResultVO.SearchItem item = new SearchResultVO.SearchItem();
item.setId(resource.getId());
item.setTitle(resource.getTitle());
item.setSummary(resource.getDescription());
item.setType("resource");
// 通过userId查询用户信息
User user = userMapper.getUserById(resource.getUserId());
item.setAuthor(user != null ? user.getNickname() : "未知用户");
item.setAvatar(user != null ? user.getAvatar() : null);
// 分类名称暂时设为空需要通过categoryId查询
item.setCategoryName("未知分类");
item.setCreatedAt(resource.getCreatedAt());
item.setLikeCount(resource.getLikeCount());
item.setViewCount(resource.getDownloadCount());
return item;
}
private SearchResultVO.SearchItem convertUserToSearchItem(User user) {
SearchResultVO.SearchItem item = new SearchResultVO.SearchItem();
item.setId(user.getId());
item.setTitle(user.getNickname());
item.setSummary(user.getBio());
item.setType("user");
item.setAuthor(user.getUsername());
item.setAvatar(user.getAvatar());
item.setCategoryName(user.getDepartment());
item.setCreatedAt(user.getCreatedAt());
item.setLikeCount(user.getPoints());
item.setViewCount(0);
return item;
}
private String getSummary(String content) {
if (content == null) return "";
if (content.length() <= 100) return content;
return content.substring(0, 100) + "...";
}
}

@ -133,4 +133,26 @@
FROM posts
WHERE user_id = #{userId} AND status != 0
</select>
<select id="searchPosts" resultType="com.unilife.model.entity.Post">
SELECT * FROM posts
WHERE status != 0
<if test="keyword != null and keyword != ''">
AND (title LIKE CONCAT('%', #{keyword}, '%') OR content LIKE CONCAT('%', #{keyword}, '%'))
</if>
<if test="categoryId != null">
AND category_id = #{categoryId}
</if>
<choose>
<when test="sortBy == 'time'">
ORDER BY created_at DESC
</when>
<when test="sortBy == 'popularity'">
ORDER BY like_count DESC, view_count DESC
</when>
<otherwise>
ORDER BY created_at DESC
</otherwise>
</choose>
</select>
</mapper>

@ -143,4 +143,20 @@
updated_at = NOW()
WHERE id = #{id}
</update>
<select id="searchUsers" resultType="com.unilife.model.entity.User">
SELECT id, username, email, nickname, avatar, bio, gender,
student_id as studentId, department, major, grade, points,
role, status, is_verified as isVerified,
created_at as createdAt, updated_at as updatedAt
FROM users
WHERE status != 0
<if test="keyword != null and keyword != ''">
AND (username LIKE CONCAT('%', #{keyword}, '%')
OR nickname LIKE CONCAT('%', #{keyword}, '%')
OR department LIKE CONCAT('%', #{keyword}, '%')
OR major LIKE CONCAT('%', #{keyword}, '%'))
</if>
ORDER BY created_at DESC
</select>
</mapper>

Loading…
Cancel
Save