feat:新增Comment评论组件

This commit is contained in:
liangdong
2026-01-04 22:01:26 +08:00
parent 2978a31791
commit bae034d6eb
4 changed files with 352 additions and 26 deletions

1
components.d.ts vendored
View File

@@ -12,6 +12,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
CardItem: typeof import('./src/components/cardItem/index.vue')['default']
Comment: typeof import('./src/components/comment/index.vue')['default']
CommonFilter: typeof import('./src/components/commonFilter/index.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']

View File

@@ -0,0 +1,329 @@
<template>
<div class="comment-app">
<section class="main-publisher">
<el-input
v-model="mainInput"
type="textarea"
:rows="3"
placeholder="发一条友善的评论吧..."
resize="none"
/>
<div class="pub-footer">
<div class="tools">
<el-button link class="tool-item">
<el-icon><Picture /></el-icon> 插入图片 (预留)
</el-button>
<el-button link class="tool-item">
<span class="emoji-icon">😀</span> 表情 (预留)
</el-button>
</div>
<el-button type="primary" round @click="submitMainComment"
>发布评论</el-button
>
</div>
</section>
<div class="comment-list">
<div v-for="item in commentData" :key="item.id" class="comment-group">
<div class="parent-node">
<el-avatar :size="42" :src="item.avatar" />
<div class="node-main">
<div class="user-info">
<span class="nickname">{{ item.nickname }}</span>
<el-tag v-if="item.isOwner" size="small">作者</el-tag>
</div>
<div class="content">{{ item.content }}</div>
<div class="node-footer">
<span class="time">{{ item.time }}</span>
<div class="actions">
<el-button link @click="openReply(item, item)">回复</el-button>
<el-button link>点赞</el-button>
</div>
</div>
<div v-if="item.children?.length" class="sub-container">
<div
v-for="reply in item.children"
:key="reply.id"
class="sub-node"
>
<el-avatar :size="24" :src="reply.avatar" />
<div class="sub-main">
<div class="sub-text">
<span class="sub-nickname">{{ reply.nickname }}</span>
<template v-if="reply.replyTo !== item.nickname">
<span class="reply-label">回复</span>
<span class="target-name">@{{ reply.replyTo }}</span>
</template>
<span class="sep"></span>
<span class="content-body">{{ reply.content }}</span>
</div>
<div class="node-footer">
<span class="time">{{ reply.time }}</span>
<el-button link @click="openReply(reply, item)"
>回复</el-button
>
</div>
</div>
</div>
</div>
<div
v-if="activeReply.groupId === item.id"
class="inline-publisher"
>
<el-input
v-model="replyInput"
:placeholder="`回复 @${activeReply.targetName}...`"
type="textarea"
autosize
/>
<div class="pub-footer">
<div class="tools">
<el-button link size="small">😀 表情</el-button>
</div>
<div class="btns">
<el-button size="small" @click="cancelReply">取消</el-button>
<el-button
size="small"
type="primary"
round
@click="submitReply"
>发布</el-button
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from "vue";
import { ElMessage } from "element-plus";
import { Picture } from "@element-plus/icons-vue";
// 1. 模拟当前登录用户
const currentUser = {
nickname: "前端练习生",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Lucky",
};
// 2. 响应式数据
const mainInput = ref("");
const replyInput = ref("");
const commentData = ref([
{
id: 1,
nickname: "张三",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1",
content: "扁平化递归不仅性能好,在手机端显示也非常整齐!",
time: "2小时前",
isOwner: true,
children: [
{
id: 101,
nickname: "李四",
replyTo: "张三",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
content: "确实,无限缩进到后面根本没法看。",
time: "1小时前",
},
],
},
]);
// 记录当前正在回复的状态
const activeReply = reactive({
groupId: null, // 所属的一级评论ID
targetName: "", // 正在回复的具体人名
targetId: null, // 正在回复的评论ID
});
// 3. 逻辑方法
const submitMainComment = () => {
if (!mainInput.value.trim()) return ElMessage.warning("内容不能为空");
const newComment = {
id: Date.now(),
...currentUser,
content: mainInput.value,
time: "刚刚",
children: [],
};
commentData.value.unshift(newComment);
mainInput.value = "";
ElMessage.success("发表成功");
};
const openReply = (target, group) => {
activeReply.groupId = group.id;
activeReply.targetName = target.nickname;
activeReply.targetId = target.id;
replyInput.value = "";
};
const cancelReply = () => {
activeReply.groupId = null;
};
const submitReply = () => {
if (!replyInput.value.trim()) return ElMessage.warning("回复内容不能为空");
const targetGroup = commentData.value.find(
(item) => item.id === activeReply.groupId
);
if (targetGroup) {
const newReply = {
id: Date.now(),
...currentUser,
replyTo: activeReply.targetName,
content: replyInput.value,
time: "刚刚",
};
targetGroup.children.push(newReply);
ElMessage.success("回复成功");
cancelReply();
}
};
</script>
<style scoped lang="scss">
$color-primary: #409eff;
$color-bg-sub: #f5f7fa;
$color-text-main: #303133;
$color-text-sub: #909399;
$border-color: #ebeef5;
.comment-app {
max-width: 760px;
margin: 40px auto;
padding: 0 20px;
// 发布区域公共样式
.main-publisher,
.inline-publisher {
border: 1px solid $border-color;
border-radius: 8px;
padding: 12px;
background: #fff;
.pub-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
.tool-item {
color: $color-text-sub;
font-size: 14px;
.emoji-icon {
font-size: 16px;
margin-right: 4px;
}
}
}
}
.inline-publisher {
margin-top: 16px;
background-color: #fff;
border: 1px solid #dcdfe6;
}
.comment-list {
margin-top: 32px;
.comment-group {
margin-bottom: 28px;
.parent-node {
display: flex;
gap: 14px;
.node-main {
flex: 1;
.user-info {
display: flex;
align-items: center;
gap: 8px;
.nickname {
font-weight: 600;
font-size: 14px;
color: $color-text-main;
}
}
.content {
margin: 8px 0;
font-size: 15px;
line-height: 1.6;
color: $color-text-main;
}
}
}
}
}
// 底部信息和动作
.node-footer {
display: flex;
align-items: center;
gap: 15px;
font-size: 13px;
color: $color-text-sub;
.actions {
display: flex;
gap: 10px;
.el-button {
font-size: 13px;
color: $color-text-sub;
&:hover {
color: $color-primary;
}
}
}
}
// 扁平化子评论容器
.sub-container {
background-color: $color-bg-sub;
border-radius: 6px;
padding: 4px 12px 12px;
margin-top: 12px;
.sub-node {
display: flex;
gap: 10px;
margin-top: 12px;
.sub-main {
flex: 1;
.sub-text {
font-size: 14px;
line-height: 1.5;
.sub-nickname {
font-weight: 600;
color: #555;
}
.reply-label {
margin: 0 4px;
color: $color-text-sub;
font-size: 13px;
}
.target-name {
color: $color-primary;
margin-right: 2px;
}
.content-body {
color: $color-text-main;
}
}
}
}
}
}
</style>

View File

@@ -91,12 +91,12 @@
pagination
height="calc(100vh - 186px)"
:immediate="false"
row-key="id"
lazy
:load="load"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:request-api="fetchData"
>
<!-- 名称点击 -->
<template #labelName="{ row }">
<el-button link type="primary" @click="onLevelNext">{{ row.label }}</el-button>
</template>
<!-- 状态插槽 -->
<template #status="{ row }">
<div
@@ -178,6 +178,7 @@ const columns = [
prop: "label",
label: "字典名称",
align: "center",
slot: "labelName",
},
{
prop: "value",
@@ -209,6 +210,11 @@ const columns = [
{ prop: "actions", label: "操作", align: "right", width: "300" },
];
// 点击获取二级菜单数据
const onLevelNext = () =>{
console.log('next')
}
// 请求数据信息
const fetchData = async (params) => {
try {
@@ -217,14 +223,6 @@ const fetchData = async (params) => {
keyword: searchQuery.value,
...filterForm,
});
if (response.records) {
response.records = response.records.map((item) => ({
...item,
children: item.children || [],
hasChildren: true, // 设置为true加载子集数据信息
}));
}
return response;
} catch (error) {
console.log("getTableData Error", error);
@@ -251,8 +249,8 @@ const load = async (
resolve: (data: User[]) => void
) => {
try {
const resp = await getNextDictMenu(parentId.value,row.id);
console.log("获取当前返回的二级数据信息==>:",resp);
const resp = await getNextDictMenu(parentId.value, row.id);
console.log("获取当前返回的二级数据信息==>:", resp);
resolve([]);
} catch (error) {
resolve([]);
@@ -270,11 +268,10 @@ const onConfirmSuccess = () => {
tableRef.value && tableRef.value.refresh();
};
// 关闭popover 重置数据信息
const onCloseFilter = () =>{
filterForm.status = "";
}
const onCloseFilter = () => {
filterForm.status = "";
};
// 筛选重置
const onReset = () => {

View File

@@ -1,13 +1,12 @@
<template>
<div class="">
</div>
<div class="">
<Comment />
</div>
</template>
<script setup lang="ts">
import {reactive,ref,onMounted} from "vue"
import Comment from "@/components/comment/index.vue";
import { reactive, ref, onMounted } from "vue";
defineOptions({})
defineOptions({ name: "Personnel" });
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>