fix:完善评论组件

This commit is contained in:
liangdong
2026-01-06 19:18:38 +08:00
parent a6035e5f5f
commit 65090d8dcf
10 changed files with 761 additions and 410 deletions

1
components.d.ts vendored
View File

@@ -33,6 +33,7 @@ declare module 'vue' {
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视界</title>
<title>服链</title>
</head>
<body>
<div id="app"></div>

View File

@@ -23,6 +23,7 @@
"@vue/tsconfig": "^0.8.1",
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"lodash-es": "^4.17.22",
"sass": "^1.97.1",
"typescript": "~5.9.3",
"unicode-emoji-json": "^0.8.0",

3
pnpm-lock.yaml generated
View File

@@ -45,6 +45,9 @@ importers:
dayjs:
specifier: ^1.11.19
version: 1.11.19
lodash-es:
specifier: ^4.17.22
version: 4.17.22
sass:
specifier: ^1.97.1
version: 1.97.1

View File

@@ -2,7 +2,7 @@
<el-avatar
:size="size"
:src="src"
:style="{ backgroundColor: !src ? bgColor : '' }"
:style="{ backgroundColor: !src ? bgColor : '','--avatar-text-size':fontSize }"
class="mj-name-avatar"
>
<span v-if="!src" class="avatar-text">{{ displayText }}</span>
@@ -22,6 +22,10 @@ const displayText = computed(() => {
return props.name ? props.name.charAt(0) : '';
});
const fontSize = computed(() => {
return `${Math.max(props.size * 0.4, 12)}px`;
});
const bgColor = computed(() => {
if (!props.name) return '#409EFF';
let hash = 0;
@@ -49,7 +53,7 @@ const bgColor = computed(() => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
.avatar-text {
color: var(--el-avatar-text-color);
font-size: 16px;
font-size: var(--avatar-text-size);
letter-spacing: -0.5px;
line-height: 1;
}

View File

@@ -0,0 +1,255 @@
$color-blue: #409eff;
$color-blue-bg: #f5f8ff;
$color-text-main: #303133;
$color-text-sub: #99a2aa;
$color-border: #e4e7ed;
$color-white: #fff;
.comment-app {
font-family: -apple-system, sans-serif;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
height: calc(100vh - 170px);
// 评论的样式
.main-publisher{
position: absolute;
bottom: 0;
left: 0;
width: 100%;
z-index: 200;
}
.input-wrapper {
flex-shrink: 0;
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 {
overflow-y: auto;
flex: 0 0 calc(100% - 120px);
.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;
}
.createTime {
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;
}
}
.createTime {
font-size: 12px;
color: $color-text-sub;
}
}
.content-body {
font-size: 14px;
color: $color-text-main;
margin-bottom: 3px;
}
}
}
// 展开收起样式
.sub-list-controls {
display: flex;
align-items: center;
margin-top: 12px;
padding-left: 46px; // 与头像对齐的偏移量
.expand-line {
width: 20px;
height: 1px;
background-color: #dcdfe6;
margin-right: 8px;
}
.el-button {
font-size: 13px;
color: $color-text-sub;
font-weight: 500;
&:hover {
color: $color-blue;
}
.el-icon {
margin-left: 4px;
transition: transform 0.3s;
}
}
}
}
: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);
}
}
}
// 评论组件骨架屏
.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;
}
}
}

View File

@@ -1,44 +1,6 @@
<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">
@@ -85,17 +47,27 @@
<!-- 详细的内容展示 -->
<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" />
<name-avatar
:name="item.employee.name"
:src="item.employee.avatar"
:size="36"
/>
<div class="node-main">
<!-- 当前用户信息展示 -->
<div class="user-info">
<span class="nickname">{{ item.nickname }}</span>
<span class="time">{{ formatTime(item.time) }}</span>
<span class="nickname">{{ item.employee.name }}</span>
<span class="createTime">{{
formatTime(item.createTime)
}}</span>
</div>
<!-- 回复内容模块 -->
<div class="content" v-html="parseMention(item.content)"></div>
<div
class="content"
v-html="parseMention(item.content, item.mentions)"
></div>
<!-- 回复内容-子集内容操作模块 -->
<div class="actions">
<el-button link @click="openReply(item, item)">
@@ -105,89 +77,86 @@
link
class="delete-btn"
@click="deleteMainComment(item)"
v-if="item.canDelete"
v-if="currentUser.id === item?.employee.id"
>
<el-icon><Delete /></el-icon>&nbsp;删除
</el-button>
</div>
<!-- 回复内容展示二级-子集评论内容 -->
<div v-if="item.children?.length" class="sub-container">
<div
v-if="item.children?.length || item.localReplies?.length"
class="sub-container"
>
<!-- 临时数据 -->
<div
v-for="reply in item.children"
:key="reply.id"
v-for="replies in [
...(item.localReplies || []),
...(item.showAllReplies
? item.children
: item.children.slice(0, 1)),
]"
:key="replies.id"
class="sub-node"
>
<name-avatar
:name="reply.nickname"
:src="reply.avatar"
:name="replies.employee.name"
:src="replies.employee.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>
<span class="nickname">{{
replies.employee.name
}}</span>
<!-- TODO:这个地方判断 不是c-b这种回复就不展示 -->
<template v-if="replies.reply">
<span class="reply-text">回复</span>
<span class="target-name"
>@{{ replies.reply.name }}</span
>
</template>
</div>
<span class="time">{{ formatTime(reply.time) }}</span>
<span class="createTime">{{
formatTime(replies.createTime)
}}</span>
</div>
<div class="content-body" v-html="parseMention(reply.content)"></div>
<div
class="content-body"
v-html="parseMention(replies.content, replies.mentions)"
></div>
<!-- 回复 删除功能 -->
<div class="actions">
<el-button link @click="openReply(item, item)">
<el-button link @click="openReply(replies, item)">
回复
</el-button>
<!-- 删除功能-只有自己评论的可以删除 -->
<el-button
link
class="delete-btn"
v-if="reply.canDelete"
@click="deleteReply(reply, item)"
v-if="currentUser.id === replies?.employee.id"
@click="deleteReply(replies, 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 v-if="item.children.length > 1" class="sub-list-controls">
<div class="expand-line"></div>
<el-button link @click="handleExpand(item)">
<template v-if="!item.showAllReplies">
展开 {{ item.childrenCount }} 条回复
<el-icon><ArrowDown /></el-icon>
</template>
<template v-else>
收起
<el-icon><ArrowUp /></el-icon>
</template>
</el-button>
</div>
</div>
</div>
@@ -195,6 +164,49 @@
</div>
</template>
</div>
<!-- 评论模块 -->
<div class="main-publisher">
<div class="input-wrapper">
<mentionEditor
v-model="mainInput"
:users="userList"
@select="onUserMentioned"
>
<el-input
v-model="mainInput"
type="textarea"
:rows="2"
: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="handleSendComment('main')"
>
<el-icon :size="20" :title="t('comment.send')"
><Promotion
/></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
@@ -213,33 +225,69 @@ const { t } = useI18n();
// 当前用户信息
const currentUser = computed(() => {
return {
nickname: userStore.userInfo.username,
name: userStore.userInfo.username,
avatar: userStore.userInfo.avatar,
id: 1,
};
});
// 评论业务逻辑
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,
createTime: "2023-10-27T14:30:00",
mentions: [{ userId: 2, nickname: "冯娜", start: 2, end: 6 }],
employee: {
id: 1,
name: "李星倩",
avatar: "",
},
childrenCount: 3,
children: [
{
id: 101,
nickname: "冯娜",
replyTo: "李星倩",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
content: "收到,数据已入库,我马上看下。",
time: 1767604936684,
createTime: 1767604936684,
mentions: [{ userId: 2, nickname: "冯娜", start: 11, end: 15 }],
employee: {
id: 2,
name: "冯娜",
avatar: "",
},
},
{
id: 102,
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
content: "收到,数据已入库,我马上看下。",
createTime: 1767604936684,
mentions: [{ userId: 2, nickname: "冯娜", start: 11, end: 15 }],
replyEmployee: {
id: 1,
name: "冯娜",
},
employee: {
id: 2,
name: "zhanghan",
avatar: "",
},
},
{
id: 103,
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
content: "收到,数据已入库,我马上看下。",
createTime: 1767604936684,
mentions: [{ userId: 2, nickname: "冯娜", start: 11, end: 15 }],
employee: {
id: 3,
name: "王五",
avatar: "",
},
},
],
},
@@ -247,9 +295,27 @@ const commentData = ref([
// 回复
const openReply = (target, group) => {
activeReply.groupId = group.id;
activeReply.targetName = target.nickname;
replyInput.value = "";
// 1. 设置回复的目标关系
activeReply.groupId = group.id; // 根评论ID
activeReply.parentId = target.id; // 直接父级ID
activeReply.targetName = target.employee.name;
// 2. 沿用 @ 逻辑:自动在输入框插入 @某人
const mentionStr = `@${target.employee.name} `;
// 如果输入框里已经有内容了,在前面追加,否则直接赋值
if (!mainInput.value.includes(mentionStr)) {
mainInput.value = mentionStr + mainInput.value;
}
// 3. 聚焦到输入框
nextTick(() => {
const textarea = document.querySelector('.fixed-publisher textarea');
textarea?.focus();
// 飞书细节:光标移到最后
textarea.selectionStart = textarea.value.length;
});
};
// 删除回复-删除评论
@@ -274,73 +340,162 @@ const onSelectEmoji = (emoji, type) => {
console.log("emoji", emoji, type);
if (type === "main") {
mainInput.value += emoji;
} else {
replyInput.value += emoji;
}
};
// 取消评论
const cancelReply = () => {
activeReply.parentId = null;
activeReply.targetName = "";
activeReply.groupId = null;
mainInput.value = '';
};
// @圈人的操作
const userList = [
{ id: 1, nickname: "李星倩" },
{ id: 2, nickname: "冯娜" },
{ id: 3, nickname: "张三" }
{ id: 3, nickname: "张三" },
];
// TODO:@ 获取选中的用户信息
const onUserMentioned = (user) => {
console.log('Mentioned:', user.nickname);
console.log("Mentioned:", user.nickname);
};
const submitMainComment = () => {
if (!mainInput.value.trim()) return ElMessage.warning("内容不能为空");
/**
* 统一发送评论/回复的方法
* @param {string} type - 'main' (主评论) 或 'reply' (回复)
*/
const handleSendComment = async () => {
const type = activeReply.parentId ? 'reply' : 'main';
const isReply = type === "reply";
let rawText = mainInput.value;
const formattedContent = mainInput.value.replace(
/@([^\s@]+)/g,
'<span class="mention-highlight">@$1</span>'
);
if (!rawText.trim()) return ElMessage.warning("内容不能为空");
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();
const prefix = `@${activeReply.targetName} `;
// 如果用户没有删掉开头的 @人名,则把它剔除
if (rawText.startsWith(prefix)) {
rawText = rawText.slice(prefix.length).trimStart();
}
// 构造 Payload
const mentionList = [];
const regex = /@([^\s@]+)(?=\s|$)/g;
let match;
while ((match = regex.exec(rawText)) !== null) {
const nickname = match[1];
const user = userList.find((u) => u.nickname === nickname);
if (user) {
mentionList.push({
id: user.id,
name: user.name,
start: match.index,
end: match.index + match[0].length,
});
}
}
// 组装接口请求的参数
const params = {
content: rawText,
mentionList: mentionList,
// 如果是回复,带上关联 ID
reply: {
id: isReply ? activeReply.groupId : null,
name: isReply ? activeReply.groupId : null,
},
};
try {
// const res = await api.postComment(params);
console.log("发送请求, 参数为:", params);
// 前端 UI 更新
updateUIAfterSend(type, params);
// 清空输入框
cancelReply();
ElMessage.success(isReply ? "回复成功" : "评论成功");
} catch (error) {
console.log("error", error);
ElMessage.error("发送失败");
}
};
// 更新当前的UI层
const updateUIAfterSend = (type, params) => {
const newComment = {
id: Date.now(), // 这个地方需要替换为后端返回的真实id 用作后续的删除
employee: {
...currentUser.value,
},
reply: params.reply,
content: params.content,
mentions: params.mentionList, // 这里直接存入带下标的数组
createTime: new Date().valueOf(),
children: [],
};
if (type === "main") {
// 评论
commentData.value.unshift(newComment);
} else {
//回复某人的数据渲染
const targetGroup = commentData.value.find((i) => i.id === params.reply.id);
if (targetGroup) {
// 记录回复对象的名字以便显示 "@某某"
newComment.replyTo = activeReply.targetName;
// targetGroup.children.unshift(newComment);
if (!targetGroup.localReplies) targetGroup.localReplies = [];
targetGroup.localReplies.unshift(newComment);
}
}
};
// TODO:展开更多-收起
const handleExpand = async (item) => {
try {
// 后端获取最终的数据信息
// const res = await xxxxxx()
const res = [];
const combined = [...(item.localReplies || []), ...res];
item.children = combined.filter(
(v, i, a) => a.findIndex((t) => t.id === v.id) === i
);
item.localReplies = [];
item.showAllReplies = true;
} catch (error) {
console.log("error", error);
}
};
/**
* @param {string} text - 原始文本
* @param {Array} atUsers - 这条评论真正圈到的人员列表
*/
const parseMention = (text, atUsers = userList) => {
if (!text) return "";
if (!atUsers || atUsers.length === 0) return text;
// 只循环这条评论里【真正圈到】的人
const sortedMentions = [...atUsers].sort((a, b) => b.start - a.start);
let result = text;
sortedMentions.forEach((m) => {
const prefix = result.slice(0, m.start);
const suffix = result.slice(m.end);
const mentionText = result.slice(m.start, m.end);
const hasAt = mentionText.trim().startsWith("@");
const displayName = hasAt ? mentionText : `@${mentionText}`;
// 插入高亮标签
const highlight = `<span class="mention-highlight" data-user-id="${m.userId}">${displayName} </span>`;
result = prefix + highlight + suffix;
});
return result;
};
// 初始化
@@ -352,228 +507,5 @@ onMounted(() => {
</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;
}
}
}
@use "./index.scss" as *;
</style>

View File

@@ -10,6 +10,10 @@
class="mention-popover"
:style="{ top: popoverPos.top + 'px', left: popoverPos.left + 'px' }"
>
<div v-if="loading" class="mention-loading">
<el-icon class="is-loading"><Loading /></el-icon>
</div>
<template v-else>
<div class="mention-list" v-if="filteredList.length">
<div
v-for="(user, index) in filteredList"
@@ -18,19 +22,23 @@
@mousedown.prevent="handleSelect(user)"
@mouseenter="selectIndex = index"
>
<name-avatar :name="user.nickname" :size="20" />
<name-avatar :name="user.nickname" :size="30" />
<span class="name">{{ user.nickname }}</span>
</div>
</div>
<div v-else class="mention-empty">未找到人员</div>
<div v-else class="mention-empty">
<el-empty :image-size="40" description="未匹配到人员" />
</div>
</template>
</div>
</transition>
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue';
import nameAvatar from '@/components/nameAvatar/index.vue';
import { debounce } from 'lodash-es';
const props = defineProps({
modelValue: String,
users: { type: Array, default: () => [] }
@@ -46,10 +54,10 @@ const selectIndex = ref(0);
const popoverPos = ref({ top: 0, left: 0 });
let targetInput = null; // 存储原生 textarea 引用
const filteredList = computed(() => {
return props.users.filter(u => u.nickname.includes(searchKey.value));
});
// 搜索查询模块
const filteredList = ref([]);
const loading = ref(false);
let abortController = null;
// 计算像素坐标的核心逻辑
const computeCaretPosition = () => {
if (!targetInput || !mirrorRef.value) return;
@@ -98,15 +106,48 @@ const computeCaretPosition = () => {
});
};
// 定义圈人的搜索方法
const fetchRemoteUsers = debounce(async (keyword) => {
if(!popoverPos.value) return;
// 正在进行的请求,直接取消它
if (abortController) abortController.abort();
abortController = new AbortController();
loading.value = true;
try {
// const res = await xxxx
// filteredList.value = res;
// 模拟数据返回
const res = await new Promise((resolve,reject)=>{
const timeout = setTimeout(() => {
const results = props.users.filter(u =>
u.nickname.toLowerCase().includes(keyword.toLowerCase())
);
resolve(results);
}, 300);
abortController.signal.addEventListener('abort', () => {
clearTimeout(timeout);
reject(new Error('aborted'));
});
})
filteredList.value = res;
selectIndex.value = 0; //每次搜索默认选中第一个
} catch (error) {
console.log('loading users error',error);
} finally{
loading.value = false;
}
}, 500);
// handleInput 函数 搜索
const handleInput = (e) => {
const el = e.target;
const val = el.value;
const pos = el.selectionStart; // 当前光标位置
// 1. 获取光标之前的文本
const textBefore = val.slice(0, pos);
// 2. 找到光标前最近的一个 @
const lastAtPos = textBefore.lastIndexOf("@");
if (lastAtPos !== -1) {
@@ -124,6 +165,8 @@ const handleInput = (e) => {
searchKey.value = contentBetween;
showPopover.value = true;
computeCaretPosition(); // 重新计算位置
fetchRemoteUsers(contentBetween); //触发当前搜索
return;
}
}
@@ -136,21 +179,20 @@ const handleKeyDown = (e) => {
// 兼容删除 删除整个块而不是单独的文字
if (e.key === 'Backspace') {
const el = e.target;
const pos = el.selectionStart; // 获取光标当前位置
const pos = el.selectionStart;
const val = el.value;
// 1. 获取光标之前的文本
const textBefore = val.slice(0, pos);
// 2. 正则匹配:光标前是否紧跟 "@姓名 " (注意:这里匹配的是带空格的完整块)
// 这里的正则要匹配:以@开头,中间没有空格或@,最后以一个空格结尾
// 2. 正则匹配:光标前是否紧跟 "@姓名 "
const mentionMatch = textBefore.match(/@([^\s@]+)\s$/);
if (mentionMatch) {
// 匹配到了,说明光标刚好在某个 "@姓名 " 的末尾
const wholeMatch = mentionMatch[0]; // 例如 "@李星倩 "
const wholeMatch = mentionMatch[0];
e.preventDefault(); // 阻止浏览器默认的删除一个字符的行为
e.preventDefault();
// 3. 计算新值:删掉整个匹配到的块
const startPos = pos - wholeMatch.length;
@@ -188,13 +230,76 @@ const handleSelect = (user) => {
const textBefore = val.slice(0, pos);
const lastAtPos = textBefore.lastIndexOf("@");
const newValue = val.slice(0, lastAtPos) + `@${user.nickname} ` + val.slice(pos);
const insertName = `@${user.nickname} `; // 注意结尾有空格
const newValue = val.slice(0, lastAtPos) + insertName + val.slice(pos);
emit('update:modelValue', newValue);
emit('select', user);
// 传给父组件一个包含位置的对象
emit('select', {
...user,
start: lastAtPos,
end: lastAtPos + insertName.length
});
showPopover.value = false;
nextTick(() => targetInput.focus());
};
// 禁止用户移入到当前选中的@块中
const checkSelection = (el) => {
// 如果用户当前选中了一段范围selectionStart !== selectionEnd说明是在做选择操作
// 这种情况下不进行纠偏,否则用户无法选中并删除块
if (el.selectionStart !== el.selectionEnd) return;
const pos = el.selectionStart;
const val = el.value;
const regex = /@([^\s@]+)\s/g;
// 回复的时候锁死第一个@用户
const firstMentionMatch = val.match(/^@([^\s@]+)\s/);
if (firstMentionMatch) {
const mentionEndPos = firstMentionMatch[0].length;
if (pos < mentionEndPos) {
el.setSelectionRange(mentionEndPos, mentionEndPos);
return;
}
}
// 匹配所有格式为 "@姓名 " 的位置
let match;
while ((match = regex.exec(val)) !== null) {
const start = match.index;
const end = match.index + match[0].length;
// 如果当前光标落在 [start + 1, end - 1] 之间(即 @符号之后,空格之前)
if (pos > start && pos < end) {
// 计算中点位置,判断光标离哪头近就弹到哪头
const mid = (start + end) / 2;
const targetPos = pos < mid ? start : end;
// 强制设置光标位置
el.setSelectionRange(targetPos, targetPos);
break;
}
}
};
const syncMirrorStyle = () => {
if (!targetInput || !mirrorRef.value) return;
const style = window.getComputedStyle(targetInput);
const mirror = mirrorRef.value;
const propList = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'paddingTop', 'paddingLeft', 'paddingRight', 'paddingBottom', 'borderWidth', 'boxSizing', 'letterSpacing', 'wordBreak', 'whiteSpace', 'wordWrap'];
propList.forEach(prop => mirror.style[prop] = style[prop]);
mirror.style.width = targetInput.clientWidth + 'px'; // 使用 clientWidth 排除滚动条影响
mirror.style.height = targetInput.clientHeight + 'px';
};
let resizeObserver = null;
onMounted(() => {
targetInput = containerRef.value.querySelector('textarea');
if (targetInput) {
@@ -203,6 +308,27 @@ onMounted(() => {
targetInput.addEventListener('scroll', () => {
if (showPopover.value) computeCaretPosition();
});
targetInput.addEventListener('mouseup', () => checkSelection(targetInput));
targetInput.addEventListener('keyup', (e) => {
// 只有按左右方向键时才需要校验,输入字符时 handleInput 会处理
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
checkSelection(targetInput);
}
});
resizeObserver = new ResizeObserver(() => {
syncMirrorStyle(); // 抽取出来的样式同步函数
if (showPopover.value) computeCaretPosition();
});
resizeObserver.observe(targetInput);
}
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
</script>
@@ -233,6 +359,17 @@ onMounted(() => {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border: 1px solid #e4e7ed;
min-width: 160px;
transition: top 0.1s ease-out, left 0.1s ease-out;
overflow: hidden;
transform: translateY(-100%);
margin-top: -26px;
&.el-zoom-in-top-enter-active {
transform-origin: center bottom;
}
.mention-loading{
text-align: center;
padding: 20px 0;
}
}
.mention-list {
@@ -246,11 +383,11 @@ onMounted(() => {
gap: 10px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s;
transition: background-color 0.15s ease, color 0.15s ease;
&.active {
background-color: #f0f7ff;
color: #409eff;
background-color: var(--el-color-primary-light-9, #ecf5ff);
color: var(--el-color-primary, #409eff);
}
.name {

View File

@@ -9,9 +9,8 @@
<div class="info-text">
<div class="title-row">
<span class="main-title">集团1</span>
<el-tag size="small" effect="plain" class="title-tag"
>集团</el-tag
>
<!-- TODO:这块不要展示tag -->
<!-- <el-tag size="small" effect="plain" class="title-tag">集团</el-tag> -->
</div>
<div class="sub-id">ID: 3</div>
</div>

View File

@@ -1,12 +1,31 @@
<template>
<div class="">
<Comment />
<div class="personnel">
<el-drawer
v-model="drawer"
size="80%"
title="测试评论"
:close-on-click-modal="false"
:close-on-press-escape="false"
destroy-on-close
class="standard-ui-back-drawer"
>
<div style="padding: 20px">
<Comment />
</div>
<template #footer>
<el-button @click="drawer = false"> </el-button>
<el-button type="primary" @click="drawer = false"> </el-button>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import Comment from "@/modules/Comment/index.vue";
import { reactive, ref, onMounted } from "vue";
defineOptions({ name: "Personnel" });
const drawer = ref(true);
</script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.personnel {
height: 100%;
}
</style>