feat: 增加博客评论模块

This commit is contained in:
2025-06-20 17:36:35 +08:00
parent e5ff5608a8
commit b9c18375f5
12 changed files with 500 additions and 6 deletions

View File

@@ -32,6 +32,7 @@
"v-calendar": "^3.1.2",
"vue": "3.5.12",
"vue3-common": "git+https://gitee.com/Cxx0822/vue3-common",
"vue3-emoji-picker": "^1.1.8",
"vue3-eslint": "git+https://gitee.com/Cxx0822/vue3-eslint"
},
"devDependencies": {

20
pnpm-lock.yaml generated
View File

@@ -80,6 +80,9 @@ importers:
vue3-common:
specifier: git+https://gitee.com/Cxx0822/vue3-common
version: git+https://gitee.com/Cxx0822/vue3-common#ba7c8a0b1a20a714f0bf09b10d9486b5fd518ca0(axios@1.9.0)(crypto-js@4.2.0)(echarts@5.6.0)(element-plus@2.9.7(vue@3.5.12(typescript@5.8.3)))(font-awesome@4.7.0)(js-cookie@3.0.5)(path-browserify@1.0.1)(pinia@2.3.1(typescript@5.8.3)(vue@3.5.12(typescript@5.8.3)))(qs@6.14.0)(vue-router@4.5.1(vue@3.5.12(typescript@5.8.3)))(vue@3.5.12(typescript@5.8.3))
vue3-emoji-picker:
specifier: ^1.1.8
version: 1.1.8(typescript@5.8.3)
vue3-eslint:
specifier: git+https://gitee.com/Cxx0822/vue3-eslint
version: git+https://gitee.com/Cxx0822/vue3-eslint#849a4e0e783d3ecc57035fb2f92950998b6b2e41(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.8.3))(eslint@7.32.0)(typescript@5.8.3))(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.8.3))(eslint-config-standard@16.0.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.8.3))(eslint@7.32.0))(eslint-plugin-node@11.1.0(eslint@7.32.0))(eslint-plugin-promise@5.2.0(eslint@7.32.0))(eslint@7.32.0))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.8.3))(eslint@7.32.0))(eslint-plugin-node@11.1.0(eslint@7.32.0))(eslint-plugin-promise@5.2.0(eslint@7.32.0))(eslint-plugin-vue@8.7.1(eslint@7.32.0))(eslint@7.32.0)
@@ -2064,6 +2067,9 @@ packages:
htmlparser2@3.10.1:
resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==}
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
ignore@4.0.6:
resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==}
engines: {node: '>= 4'}
@@ -3301,6 +3307,10 @@ packages:
vue: ^3.5.12
vue-router: ^4.2.2
vue3-emoji-picker@1.1.8:
resolution: {integrity: sha512-k9tVHeQEBVLzVCLYAkFaI1nib3FJFQwdPhWD5khJkhks3ktg3g12z5wPGOSDpIuSLNtelRGvq1qdmZuJu5khfA==}
engines: {node: '>=16.0.0'}
vue3-eslint@git+https://gitee.com/Cxx0822/vue3-eslint#849a4e0e783d3ecc57035fb2f92950998b6b2e41:
resolution: {commit: 849a4e0e783d3ecc57035fb2f92950998b6b2e41, repo: https://gitee.com/Cxx0822/vue3-eslint, type: git}
version: 1.0.0
@@ -5684,6 +5694,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
idb@7.1.1: {}
ignore@4.0.6: {}
ignore@5.3.2: {}
@@ -7027,6 +7039,14 @@ snapshots:
vue: 3.5.12(typescript@5.8.3)
vue-router: 4.5.1(vue@3.5.12(typescript@5.8.3))
vue3-emoji-picker@1.1.8(typescript@5.8.3):
dependencies:
'@popperjs/core': 2.11.8
idb: 7.1.1
vue: 3.5.12(typescript@5.8.3)
transitivePeerDependencies:
- typescript
vue3-eslint@git+https://gitee.com/Cxx0822/vue3-eslint#849a4e0e783d3ecc57035fb2f92950998b6b2e41(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.8.3))(eslint@7.32.0)(typescript@5.8.3))(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.8.3))(eslint-config-standard@16.0.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.8.3))(eslint@7.32.0))(eslint-plugin-node@11.1.0(eslint@7.32.0))(eslint-plugin-promise@5.2.0(eslint@7.32.0))(eslint@7.32.0))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.8.3))(eslint@7.32.0))(eslint-plugin-node@11.1.0(eslint@7.32.0))(eslint-plugin-promise@5.2.0(eslint@7.32.0))(eslint-plugin-vue@8.7.1(eslint@7.32.0))(eslint@7.32.0):
dependencies:
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.8.3))(eslint@7.32.0)(typescript@5.8.3)

View File

@@ -0,0 +1,41 @@
<template>
<div class="blog-comment">
<div class="comment-total">
<span> {{ blogStore.blogNestedCommentList.length }} 条评论</span>
</div>
<div class="comment-list">
<blog-comment-item v-for="(item, index) in blogStore.blogNestedCommentList" :key="index"
:comment="item.comment" :replyList="item.replies"/>
</div>
</div>
</template>
<script setup lang="ts">
import BlogCommentItem from '@/components/Blog/Comment/BlogCommentItem.vue'
import { onMounted } from 'vue'
import { useBlogStore } from '@/store/blog'
import { flattenToTwoLevel } from '@/utils/blog/blogUtil'
const blogStore = useBlogStore()
onMounted(() => {
blogStore.queryBlogComment(blogStore.currentBlogId)
blogStore.blogNestedCommentList = flattenToTwoLevel(blogStore.blogCommentList)
})
</script>
<style lang="scss">
.blog-comment {
width: 100%;
padding: 10px;
background-color: #FFFFFF;
border: 1px solid #EEEEEE;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="blog-comment-edit">
<div class="comment-title">
<el-input v-model="blogComment.name"
placeholder="请输入Github用户名"/>
<el-input v-model="blogComment.website"
placeholder="请输入个人博客网站"/>
</div>
<el-input v-model="blogComment.content"
placeholder="有什么想和我说的呢" maxlength="500"
:rows="7" show-word-limit type="textarea"
/>
<div class="comment-button">
<el-popover ref="emojiPopoverRef" placement="top" :width="300" trigger="hover">
<template #default>
<div class="emoji-panel">
<EmojiPicker :native="true" @select="onSelectEmoji" />
</div>
</template>
<template #reference>
<el-button circle>
<i class="fa-regular fa-face-smile"></i>
</el-button>
</template>
</el-popover>
<el-button type="primary"
:disabled="blogComment.content.length === 0"
class="send-button">发送</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import EmojiPicker from 'vue3-emoji-picker'
import 'vue3-emoji-picker/css'
import { reactive, ref } from 'vue'
const emojiPopoverRef = ref()
const blogComment = reactive({
name: '',
website: '',
content: ''
})
const onSelectEmoji = (emoji) => {
if (blogComment.content.length <= 498) {
blogComment.content += emoji.i
}
emojiPopoverRef.value.hide()
}
</script>
<style lang="scss">
.blog-comment-edit {
width: 100%;
padding: 10px;
background-color: #FFFFFF;
border: 1px solid #EEEEEE;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 10px;
.comment-title {
display: flex;
justify-content: space-between;
gap: 10px;
}
.comment-button {
width: 100%;
display: flex;
.send-button {
margin-left: auto;
}
}
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div class="blog-comment-card">
<el-avatar :size="50" :src="`https://github.com/${props.comment.name}.png`" alt="未加载"/>
<div class="blog-comment-content">
<div class="basic-info">
<span>{{ props.comment.name }}</span>
<span class="content">Chrome 137.0.0.0</span>
<span class="content">macOS 10.15.7</span>
</div>
<div class="sub-info">
<span class="content">{{ props.comment.createTime }}</span>
<el-button v-if="!blogComment.isShowEdit"
type="primary" text @click="replyClick">回复</el-button>
<el-button v-else type="primary"
text @click="cancelClick">取消</el-button>
</div>
<p>{{ props.comment.content }}</p>
<blog-comment-edit v-if="blogComment.isShowEdit"/>
<div class="reply-list">
<div class="blog-comment-card" v-for="(item, index) in props.replyList" :key="index">
<el-avatar :size="50" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" />
<div class="blog-comment-content">
<div class="basic-info">
<span>{{ item.name }}</span>
<span class="content">Chrome 137.0.0.0</span>
<span class="content">macOS 10.15.7</span>
</div>
<div class="sub-info">
<span class="content">{{ item.createTime }}</span>
</div>
<p>{{ item.content }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import BlogCommentEdit from '@/components/Blog/Comment/BlogCommentEdit.vue'
import 'vue3-emoji-picker/css'
import { defineProps, PropType, reactive } from 'vue'
import { IBlogComment } from '@/types/blog'
const props = defineProps({
comment: {
type: Object as PropType<IBlogComment>
},
replyList: {
type: Object as PropType<IBlogComment[]>
}
})
const blogComment = reactive({
isShowEdit: false
})
const replyClick = () => {
blogComment.isShowEdit = true
}
const cancelClick = () => {
blogComment.isShowEdit = false
}
</script>
<style lang="scss">
.blog-comment-card {
width: 100%;
padding: 10px;
display: grid;
grid-template-columns: 60px 1fr;
gap: 5px;
.blog-comment-content {
display: flex;
flex-direction: column;
gap: 5px;
.basic-info {
display: flex;
align-items: center;
gap: 10px;
font-size: small;
}
.sub-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.content {
font-size: small;
color: #666666;
}
}
}
</style>

View File

@@ -28,11 +28,17 @@
<div class="blog-ending">
<span>-------------本文结束感谢您的阅读给个五星好评吧~~-------------</span>
</div>
<blog-comment-edit />
<blog-comment />
</div>
</template>
<script setup lang="ts">
import BlogLabel from '@/components/Blog/Content/BlogLabel.vue'
import BlogCommentEdit from '@/components/Blog/Comment/BlogCommentEdit.vue'
import BlogComment from '@/components/Blog/Comment/BlogComment.vue'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useBlogStore } from '@/store/blog.ts'

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import {
IBlog, IBlogCategory,
IBlogLatest, IBlogStats, INewBlog, IQueryBlog
IBlog, IBlogCategory, IBlogComment,
IBlogLatest, IBlogNestedComment, IBlogStats, INewBlog, IQueryBlog
} from '@/types/blog'
import {
addBlogApi, deleteBlogApi, queryAdminBlogApi, queryAdminBlogByIdApi, queryAdminBlogCategoryApi,
@@ -15,6 +15,8 @@ import { ImagePathEnum } from '@/types'
export const useBlogStore = defineStore('blog', {
state: () => ({
blogList: [] as IBlog[],
blogCommentList: [] as IBlogComment[],
blogNestedCommentList: [] as IBlogNestedComment[],
currentBlogId: 0,
currentBlog: {
id: 0,
@@ -79,6 +81,190 @@ export const useBlogStore = defineStore('blog', {
async queryAdminBlogById(id: number) {
this.currentBlog = await queryAdminBlogByIdApi(id)
},
async queryBlogComment(blogId: number) {
this.blogCommentList = [
{
id: 1,
blogId,
name: 'Cxx0822',
website: 'https://zhangsan.com',
content: '这篇文章写得非常好,对我理解前端框架很有帮助,期待更多类似内容!',
ipAddress: '114.114.114.114',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
parentId: 0,
isApproved: true,
createTime: '2025-06-15 08:30:22'
},
{
id: 2,
blogId,
name: '李四',
website: '',
content: '请问文中提到的性能优化方案在实际项目中效果如何?有没有遇到什么坑?',
ipAddress: '127.0.0.1',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13.2; rv:109.0) Gecko/20100101 Firefox/114.0',
parentId: 0,
isApproved: true,
createTime: '2025-06-15 14:15:47'
},
{
id: 3,
blogId,
name: '作者',
website: 'https://blog.example.com',
content: '回复 @李四:实际应用中效果显著,但需要注意兼容性问题,特别是旧版本浏览器的支持情况。',
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0',
parentId: 2,
isApproved: true,
createTime: '2025-06-16 09:22:10'
},
{
id: 4,
blogId,
name: '王五',
website: 'https://wangwu.dev',
content: '能否分享一下文章中提到的代码示例?想学习具体实现方式。',
ipAddress: '203.0.113.5',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15',
parentId: 0,
isApproved: true,
createTime: '2025-06-16 16:38:55'
},
{
id: 5,
blogId,
name: '赵六',
website: '',
content: '前端性能优化确实是个重要课题,我在项目中也遇到过类似问题,感谢分享经验!',
ipAddress: '103.235.46.133',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
parentId: 0,
isApproved: true,
createTime: '2025-06-17 10:12:30'
},
{
id: 6,
blogId,
name: '林七',
website: 'https://linqi.com',
content: '这个框架的状态管理方案看起来很新颖和Redux相比有什么优势吗',
ipAddress: '172.16.31.10',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
parentId: 0,
isApproved: true,
createTime: '2025-06-10 15:45:18'
},
{
id: 7,
blogId,
name: '作者',
website: 'https://blog.example.com',
content: '回复 @林七最大的优势是更简洁的API和更好的TypeScript支持尤其适合小型到中型项目。',
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0',
parentId: 6,
isApproved: true,
createTime: '2025-06-11 09:10:22'
},
{
id: 8,
blogId,
name: '孙八',
website: 'https://sunba.dev',
content: '已经按照文章中的步骤实践了效果不错不过在SSR场景下遇到了一些问题...',
ipAddress: '192.168.3.11',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13.2; rv:109.0) Gecko/20100101 Firefox/114.0',
parentId: 0,
isApproved: true,
createTime: '2025-06-12 11:22:35'
},
{
id: 9,
blogId,
name: '周九',
website: '',
content: '请问文章中的示例代码是否支持IE11我们项目还在使用旧浏览器。',
ipAddress: '172.16.58.3',
userAgent: 'Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko',
parentId: 0,
isApproved: false, // 未审核
createTime: '2025-06-08 08:08:08'
},
{
id: 10,
blogId,
name: '吴十',
website: 'https://wushi.net',
content: '前端工程化真的很重要,之前项目因为没有规范导致维护困难,感谢分享最佳实践!',
ipAddress: '192.168.127.12',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
parentId: 0,
isApproved: true,
createTime: '2025-06-09 14:30:45'
},
{
id: 11,
blogId,
name: '郑十一',
website: '',
content: '回复 @周九需要添加polyfill具体可以参考这篇文档https://example.com/ie-support',
ipAddress: '172.16.17.32',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
parentId: 9,
isApproved: false, // 未审核
createTime: '2025-06-08 09:15:22'
},
{
id: 12,
blogId,
name: '钱十二',
website: 'https://qianshier.com',
content: '这个UI组件库的设计风格我很喜欢请问有移动端适配方案吗',
ipAddress: '10.10.10.10',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15',
parentId: 0,
isApproved: true,
createTime: '2025-06-05 16:20:10'
},
{
id: 13,
blogId,
name: '作者',
website: 'https://blog.example.com',
content: '回复 @钱十二:有的,组件库内置了响应式设计,支持移动端自适应布局。',
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0',
parentId: 12,
isApproved: true,
createTime: '2025-06-06 10:05:33'
},
{
id: 14,
blogId,
name: '冯十三',
website: 'https://fengshisan.dev',
content: '文档很详细但是在Tree Shaking时遇到了问题请问如何配置',
ipAddress: '192.168.3.11',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13.2; rv:109.0) Gecko/20100101 Firefox/114.0',
parentId: 0,
isApproved: true,
createTime: '2025-06-07 09:45:15'
},
{
id: 15,
blogId,
name: '陈十四',
website: '',
content: '性能测试部分的数据很有参考价值,能否分享一下测试环境和工具?',
ipAddress: '172.16.31.10',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
parentId: 0,
isApproved: true,
createTime: '2025-06-04 14:12:28'
}
]
},
async queryBlogCategory() {
this.blogCategoryList = await queryBlogCategoryApi()
},

View File

@@ -46,6 +46,8 @@
background-color: #F8F8F8;
padding: 1vh 1vw;
border-left: 3px solid #FD2C17;
font-size: blog.$small-font-size;
color: #666666;
display: flex;
flex-direction: column;

View File

@@ -176,7 +176,11 @@ $sub-title-font-size: 1rem;
align-items: center;
gap: 5px;
color: blog.$normal-text-color;
font-size: blog.$normal-font-size;
font-size: blog.$small-font-size;
.name {
font-size: blog.$small-font-size;
}
}
.item:hover {
@@ -245,8 +249,8 @@ $sub-title-font-size: 1rem;
span {
word-break: break-all;
white-space: normal;
font-size: blog.$normal-font-size;
color: #000000;
font-size: blog.$smaller-font-size;
color: #666666;
}
}

View File

@@ -17,6 +17,7 @@ $blog-title-font-size: 1.6rem;
$large-font-size: 1.3rem;
$normal-font-size: 1rem;
$small-font-size: 0.9rem;
$smaller-font-size: 0.8rem;
$border-color: #EEEEEE;
$border-radius: 15px;

View File

@@ -64,3 +64,21 @@ export interface IBlogVisit {
title: string;
visitTime: string;
}
export interface IBlogComment {
id: number;
blogId: number;
name: string;
website: string;
content: string;
ipAddress: string;
userAgent: string;
parentId: number;
isApproved: boolean;
createTime: string;
}
export interface IBlogNestedComment {
comment: IBlogComment;
replies: IBlogComment[];
}

View File

@@ -1,5 +1,5 @@
import { ElMessage } from 'element-plus'
import { IBasicBlog, IBlog } from '@/types/blog.ts'
import { IBasicBlog, IBlog, IBlogComment, IBlogNestedComment } from '@/types/blog.ts'
import dayjs from 'dayjs'
/**
@@ -172,3 +172,24 @@ export const getBasicBlog = (blog: IBlog): IBasicBlog => {
}
}
// 扁平化数据转两层嵌套结构
export function flattenToTwoLevel(comments: IBlogComment[]): IBlogNestedComment[] {
// 创建评论映射表,快速查找评论
const commentMap = new Map<number, IBlogComment>()
comments.forEach((comment) => commentMap.set(comment.id, comment))
// 结果数组
const result: IBlogNestedComment[] = []
// 遍历所有评论,构建两层结构
comments.forEach((comment) => {
if (comment.parentId === 0) {
// 顶层评论
const replies = comments.filter((reply) => reply.parentId === comment.id)
result.push({ comment, replies })
}
})
return result
}