Files
mversion-ui/src/modules/Comment/index.vue
2026-01-05 21:25:45 +08:00

580 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="comment-app">
<section class="main-publisher">
<div class="input-wrapper">
<mentionEditor v-model="mainInput" :users="userList" @select="onUserMentioned">
<el-input
v-model="mainInput"
type="textarea"
:rows="4"
:placeholder="t('comment.placeholder')"
resize="none"
/>
</mentionEditor>
<div class="input-tools">
<div class="left-icons">
<!-- 提到-后续迭代 -->
<!-- <el-button link title="提到">@</el-button> -->
<!-- 图片功能-后续迭代 -->
<!-- <el-button link title="图片"><el-icon><Picture /></el-icon></el-button> -->
<!-- 附件功能-后续迭代 -->
<!-- <el-button link title="附件"><el-icon><Paperclip /></el-icon></el-button> -->
<!-- 表情功能 -->
<emoji-picker @select="(e) => onSelectEmoji(e, 'main')" />
</div>
<el-divider direction="vertical" />
<el-button
class="send-btn"
type="primary"
link
@click="submitMainComment"
>
<el-icon :size="20" :title="t('comment.send')"
><Promotion
/></el-icon>
</el-button>
</div>
</div>
</section>
<div class="comment-list">
<!-- 骨架屏 -->
<div v-if="loading">
<div v-for="n in 2" :key="'skeleton-' + n" class="comment-group">
<el-skeleton :rows="1" :animated="true">
<template #template>
<div class="skeleton-comment-parent-node">
<el-skeleton-item variant="circle" class="skeleton-avatar" />
<div class="skeleton-node-main">
<div class="skeleton-user-info">
<el-skeleton-item
variant="p"
style="width: 80px; height: 18px; margin-right: 10px"
/>
<el-skeleton-item
variant="p"
style="width: 60px; height: 14px"
/>
</div>
<el-skeleton-item
variant="p"
style="width: 100%; height: 16px; margin: 8px 0"
/>
<el-skeleton-item
variant="p"
style="width: 70%; height: 16px; margin-bottom: 12px"
/>
<div class="skeleton-actions">
<el-skeleton-item
variant="button"
style="width: 40px; height: 24px; margin-right: 8px"
/>
<el-skeleton-item
variant="button"
style="width: 40px; height: 24px"
/>
</div>
</div>
</div>
</template>
</el-skeleton>
</div>
</div>
<!-- 详细的内容展示 -->
<template v-else>
<div v-for="item in commentData" :key="item.id" class="comment-group">
<div class="parent-node">
<name-avatar :name="item.nickname" :src="item.avatar" :size="36" />
<div class="node-main">
<!-- 当前用户信息展示 -->
<div class="user-info">
<span class="nickname">{{ item.nickname }}</span>
<span class="time">{{ formatTime(item.time) }}</span>
</div>
<!-- 回复内容模块 -->
<div class="content" v-html="parseMention(item.content)"></div>
<!-- 回复内容-子集内容操作模块 -->
<div class="actions">
<el-button link @click="openReply(item, item)">
<el-icon><ChatDotSquare /></el-icon>&nbsp;回复
</el-button>
<el-button
link
class="delete-btn"
@click="deleteMainComment(item)"
v-if="item.canDelete"
>
<el-icon><Delete /></el-icon>&nbsp;删除
</el-button>
</div>
<!-- 回复内容展示二级-子集评论内容 -->
<div v-if="item.children?.length" class="sub-container">
<div
v-for="reply in item.children"
:key="reply.id"
class="sub-node"
>
<name-avatar
:name="reply.nickname"
:src="reply.avatar"
:size="36"
/>
<div class="sub-main">
<div class="sub-header">
<div class="sub-user-info">
<span class="nickname">{{ reply.nickname }}</span>
<span class="reply-text">回复</span>
<span class="target-name">@{{ reply.replyTo }}</span>
</div>
<span class="time">{{ formatTime(reply.time) }}</span>
</div>
<div class="content-body" v-html="parseMention(reply.content)"></div>
<!-- 回复 删除功能 -->
<div class="actions">
<el-button link @click="openReply(item, item)">
回复
</el-button>
<!-- 删除功能-只有自己评论的可以删除 -->
<el-button
link
class="delete-btn"
v-if="reply.canDelete"
@click="deleteReply(reply, item)"
>
删除
</el-button>
</div>
</div>
</div>
</div>
<!-- 统一回复输入框 -->
<div
v-if="activeReply.groupId === item.id"
class="inline-publisher"
>
<div class="input-wrapper">
<mentionEditor v-model="replyInput" :users="userList" @select="onUserMentioned">
<el-input
v-model="replyInput"
:placeholder="`${t('comment.reply')} @${
activeReply.targetName
}...`"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
resize="none"
/>
</mentionEditor>
<div class="input-tools">
<div class="left-icons">
<!-- 表情功能 -->
<emoji-picker
@select="(e) => onSelectEmoji(e, 'reply')"
/>
</div>
<el-divider direction="vertical" />
<el-button
class="send-btn"
type="primary"
link
@click="submitReply"
>
<el-icon :size="20" :title="t('comment.send')"
><Promotion
/></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from "vue";
import { ElMessage } from "element-plus";
import EmojiPicker from "./EmojiPicker.vue";
import NameAvatar from "@/components/nameAvatar/index.vue";
import { useI18n } from "vue-i18n";
import { useUserStore } from "@/store";
import { useRelativeTime } from "@/hooks/useRelativeTime";
import mentionEditor from "./mentionEditor.vue";
const { formatTime } = useRelativeTime();
const userStore = useUserStore();
const { t } = useI18n();
// 当前用户信息
const currentUser = computed(() => {
return {
nickname: userStore.userInfo.username,
avatar: userStore.userInfo.avatar,
};
});
// 评论业务逻辑
const activeReply = reactive({ parentId: null, targetName: "" });
const mainInput = ref("");
const replyInput = ref("");
const loading = ref(true); //当前骨架屏显示
// 评论数据
const commentData = ref([
{
id: 1,
nickname: "李星倩",
avatar: "",
content: "已完成ROI测算请审核。",
time: "2023-10-27T14:30:00",
canDelete: true,
children: [
{
id: 101,
nickname: "冯娜",
replyTo: "李星倩",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
content: "收到,数据已入库,我马上看下。",
time: 1767604936684,
},
],
},
]);
// 回复
const openReply = (target, group) => {
activeReply.groupId = group.id;
activeReply.targetName = target.nickname;
replyInput.value = "";
};
// 删除回复-删除评论
const deleteReply = (target, group) => {
const index = group.children.findIndex((item) => item.id === target.id);
group.children.splice(index, 1);
};
// 删除主评论-以及所有的子评论
const deleteMainComment = (target) => {
const index = commentData.value.findIndex((item) => item.id === target.id);
if (index !== -1) {
commentData.value.splice(index, 1);
if (activeReply.groupId === target.id) {
cancelReply();
}
}
};
// emoji输入框选择
const onSelectEmoji = (emoji, type) => {
console.log("emoji", emoji, type);
if (type === "main") {
mainInput.value += emoji;
} else {
replyInput.value += emoji;
}
};
// 取消评论
const cancelReply = () => {
activeReply.groupId = null;
};
// @圈人的操作
const userList = [
{ id: 1, nickname: "李星倩" },
{ id: 2, nickname: "冯娜" },
{ id: 3, nickname: "张三" }
];
const onUserMentioned = (user) => {
console.log('Mentioned:', user.nickname);
};
const submitMainComment = () => {
if (!mainInput.value.trim()) return ElMessage.warning("内容不能为空");
const formattedContent = mainInput.value.replace(
/@([^\s@]+)/g,
'<span class="mention-highlight">@$1</span>'
);
commentData.value.unshift({
id: Date.now(),
...currentUser.value,
content: formattedContent,
time: new Date().valueOf(),
canDelete: true,
children: [],
});
mainInput.value = "";
};
// 通用的内容转换函数
const parseMention = (text) => {
if (!text) return "";
return text.replace(
/@([^\s@]+)/g,
'<span class="mention-highlight">@$1</span>'
);
};
const submitReply = () => {
const targetGroup = commentData.value.find(i => i.id === activeReply.groupId);
if (targetGroup) {
const formattedReply = replyInput.value.replace(
/@([^\s@]+)/g,
'<span class="mention-highlight">@$1</span>'
);
targetGroup.children.push({
id: Date.now(),
...currentUser.value,
replyTo: activeReply.targetName,
content: formattedReply, // 使用处理后的 HTML
time: new Date().valueOf(),
});
cancelReply();
}
};
// 初始化
onMounted(() => {
setTimeout(() => {
loading.value = false;
}, 500);
});
</script>
<style scoped lang="scss">
$color-blue: #409eff;
$color-blue-bg: #f5f8ff;
$color-text-main: #303133;
$color-text-sub: #99a2aa;
$color-border: #e4e7ed;
$color-white: #fff;
.comment-app {
max-width: 800px;
margin: 20px auto;
font-family: -apple-system, sans-serif;
// 1. 输入框样式
.input-wrapper {
border: 1px solid $color-border;
border-radius: 12px;
padding: 12px;
position: relative;
transition: border-color 0.2s;
background-color: $color-white;
&:focus-within {
border-color: $color-blue;
}
:deep(.el-textarea__inner) {
border: none;
padding: 0;
box-shadow: none;
font-size: 15px;
}
.input-tools {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 8px;
.left-icons {
display: flex;
gap: 5px;
.el-button {
color: $color-text-sub;
font-size: 18px;
padding: 4px;
}
.el-button + .el-button {
margin-left: 0;
}
}
.send-btn {
margin-left: 8px;
color: $color-blue;
}
}
}
// 2. 评论列表样式
.comment-list {
margin-top: 30px;
.comment-group {
margin-bottom: 30px;
.parent-node {
display: flex;
gap: 12px;
.user-avatar {
background-color: $color-blue;
color: #fff;
font-weight: bold;
}
.node-main {
flex: 1;
.user-info {
display: flex;
align-items: center;
gap: 10px;
.nickname {
font-weight: bold;
color: $color-text-main;
}
.time {
font-size: 13px;
color: $color-text-sub;
}
}
.content {
margin: 8px 0;
font-size: 15px;
color: $color-text-main;
}
.actions {
display: flex;
margin-bottom: 12px;
.el-button {
font-size: 13px;
color: $color-text-sub;
display: flex;
align-items: center;
gap: 4px;
&:hover {
color: $color-blue;
}
}
.delete-btn:hover {
color: #f56c6c;
}
}
}
}
}
}
// 3. 子评论卡片样式
.sub-container {
background-color: $color-blue-bg;
border-radius: 8px;
border-left: 3px solid #d9e5ff;
padding: 16px;
margin-top: 10px;
.sub-node {
display: flex;
gap: 10px;
margin-bottom: 15px;
&:last-child {
margin-bottom: 0;
}
.sub-main {
flex: 1;
.sub-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.sub-user-info {
font-size: 14px;
.nickname {
font-weight: bold;
}
.reply-text {
margin: 0 6px;
color: $color-text-sub;
}
.target-name {
color: $color-blue;
font-weight: 500;
}
}
.time {
font-size: 12px;
color: $color-text-sub;
}
}
.content-body {
font-size: 14px;
color: $color-text-main;
margin-bottom: 3px;
}
}
}
}
.inline-publisher {
margin-top: 15px;
.inline-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
}
/* 在 style 标签内添加 */
:deep(.mention-highlight) {
background-color: rgba(64, 158, 255, 0.1); /* 浅蓝色背景 */
color: #409eff; /* 蓝色文字 */
padding: 2px 4px;
border-radius: 4px;
font-weight: 500;
margin: 0 2px;
cursor: pointer;
display: inline-block;
&:hover {
background-color: rgba(64, 158, 255, 0.2);
text-decoration: underline;
}
}
}
// 评论组件骨架屏
.skeleton-comment-parent-node {
display: flex;
gap: 12px;
width: 100%;
.skeleton-avatar {
width: 36px;
height: 36px;
flex-shrink: 0;
}
.skeleton-node-main {
flex: 1;
.skeleton-user-info {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.skeleton-actions {
display: flex;
margin-bottom: 12px;
}
}
}
</style>