parent
9d56e0550d
commit
e13486812c
@ -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`);
|
||||
}
|
||||
};
|
@ -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>
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
Binary file not shown.
Loading…
Reference in new issue