580 lines
16 KiB
Vue
580 lines
16 KiB
Vue
<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> 回复
|
||
</el-button>
|
||
<el-button
|
||
link
|
||
class="delete-btn"
|
||
@click="deleteMainComment(item)"
|
||
v-if="item.canDelete"
|
||
>
|
||
<el-icon><Delete /></el-icon> 删除
|
||
</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>
|