fix:完善评论组件
This commit is contained in:
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -33,6 +33,7 @@ declare module 'vue' {
|
|||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>智视界</title>
|
<title>智服链</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
"lodash-es": "^4.17.22",
|
||||||
"sass": "^1.97.1",
|
"sass": "^1.97.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"unicode-emoji-json": "^0.8.0",
|
"unicode-emoji-json": "^0.8.0",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -45,6 +45,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.19
|
specifier: ^1.11.19
|
||||||
version: 1.11.19
|
version: 1.11.19
|
||||||
|
lodash-es:
|
||||||
|
specifier: ^4.17.22
|
||||||
|
version: 4.17.22
|
||||||
sass:
|
sass:
|
||||||
specifier: ^1.97.1
|
specifier: ^1.97.1
|
||||||
version: 1.97.1
|
version: 1.97.1
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<el-avatar
|
<el-avatar
|
||||||
:size="size"
|
:size="size"
|
||||||
:src="src"
|
:src="src"
|
||||||
:style="{ backgroundColor: !src ? bgColor : '' }"
|
:style="{ backgroundColor: !src ? bgColor : '','--avatar-text-size':fontSize }"
|
||||||
class="mj-name-avatar"
|
class="mj-name-avatar"
|
||||||
>
|
>
|
||||||
<span v-if="!src" class="avatar-text">{{ displayText }}</span>
|
<span v-if="!src" class="avatar-text">{{ displayText }}</span>
|
||||||
@@ -22,6 +22,10 @@ const displayText = computed(() => {
|
|||||||
return props.name ? props.name.charAt(0) : '';
|
return props.name ? props.name.charAt(0) : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fontSize = computed(() => {
|
||||||
|
return `${Math.max(props.size * 0.4, 12)}px`;
|
||||||
|
});
|
||||||
|
|
||||||
const bgColor = computed(() => {
|
const bgColor = computed(() => {
|
||||||
if (!props.name) return '#409EFF';
|
if (!props.name) return '#409EFF';
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -49,7 +53,7 @@ const bgColor = computed(() => {
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
.avatar-text {
|
.avatar-text {
|
||||||
color: var(--el-avatar-text-color);
|
color: var(--el-avatar-text-color);
|
||||||
font-size: 16px;
|
font-size: var(--avatar-text-size);
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
255
src/modules/Comment/index.scss
Normal file
255
src/modules/Comment/index.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="comment-app">
|
<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 class="comment-list">
|
||||||
<!-- 骨架屏 -->
|
<!-- 骨架屏 -->
|
||||||
<div v-if="loading">
|
<div v-if="loading">
|
||||||
@@ -85,17 +47,27 @@
|
|||||||
<!-- 详细的内容展示 -->
|
<!-- 详细的内容展示 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-for="item in commentData" :key="item.id" class="comment-group">
|
<div v-for="item in commentData" :key="item.id" class="comment-group">
|
||||||
|
<!-- 这个地方需要添加 查看更多-收起 -->
|
||||||
<div class="parent-node">
|
<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="node-main">
|
||||||
<!-- 当前用户信息展示 -->
|
<!-- 当前用户信息展示 -->
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="nickname">{{ item.nickname }}</span>
|
<span class="nickname">{{ item.employee.name }}</span>
|
||||||
<span class="time">{{ formatTime(item.time) }}</span>
|
<span class="createTime">{{
|
||||||
|
formatTime(item.createTime)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 回复内容模块 -->
|
<!-- 回复内容模块 -->
|
||||||
<div class="content" v-html="parseMention(item.content)"></div>
|
<div
|
||||||
|
class="content"
|
||||||
|
v-html="parseMention(item.content, item.mentions)"
|
||||||
|
></div>
|
||||||
<!-- 回复内容-子集内容操作模块 -->
|
<!-- 回复内容-子集内容操作模块 -->
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button link @click="openReply(item, item)">
|
<el-button link @click="openReply(item, item)">
|
||||||
@@ -105,89 +77,86 @@
|
|||||||
link
|
link
|
||||||
class="delete-btn"
|
class="delete-btn"
|
||||||
@click="deleteMainComment(item)"
|
@click="deleteMainComment(item)"
|
||||||
v-if="item.canDelete"
|
v-if="currentUser.id === item?.employee.id"
|
||||||
>
|
>
|
||||||
<el-icon><Delete /></el-icon> 删除
|
<el-icon><Delete /></el-icon> 删除
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 回复内容展示(二级-子集评论内容) -->
|
<!-- 回复内容展示(二级-子集评论内容) -->
|
||||||
<div v-if="item.children?.length" class="sub-container">
|
<div
|
||||||
|
v-if="item.children?.length || item.localReplies?.length"
|
||||||
|
class="sub-container"
|
||||||
|
>
|
||||||
|
<!-- 临时数据 -->
|
||||||
<div
|
<div
|
||||||
v-for="reply in item.children"
|
v-for="replies in [
|
||||||
:key="reply.id"
|
...(item.localReplies || []),
|
||||||
|
...(item.showAllReplies
|
||||||
|
? item.children
|
||||||
|
: item.children.slice(0, 1)),
|
||||||
|
]"
|
||||||
|
:key="replies.id"
|
||||||
class="sub-node"
|
class="sub-node"
|
||||||
>
|
>
|
||||||
<name-avatar
|
<name-avatar
|
||||||
:name="reply.nickname"
|
:name="replies.employee.name"
|
||||||
:src="reply.avatar"
|
:src="replies.employee.avatar"
|
||||||
:size="36"
|
:size="36"
|
||||||
/>
|
/>
|
||||||
<div class="sub-main">
|
<div class="sub-main">
|
||||||
<div class="sub-header">
|
<div class="sub-header">
|
||||||
<div class="sub-user-info">
|
<div class="sub-user-info">
|
||||||
<span class="nickname">{{ reply.nickname }}</span>
|
<span class="nickname">{{
|
||||||
<span class="reply-text">回复</span>
|
replies.employee.name
|
||||||
<span class="target-name">@{{ reply.replyTo }}</span>
|
}}</span>
|
||||||
|
<!-- TODO:这个地方判断 不是c-b这种回复就不展示 -->
|
||||||
|
<template v-if="replies.reply">
|
||||||
|
<span class="reply-text">回复</span>
|
||||||
|
<span class="target-name"
|
||||||
|
>@{{ replies.reply.name }}</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<span class="time">{{ formatTime(reply.time) }}</span>
|
<span class="createTime">{{
|
||||||
|
formatTime(replies.createTime)
|
||||||
|
}}</span>
|
||||||
</div>
|
</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">
|
<div class="actions">
|
||||||
<el-button link @click="openReply(item, item)">
|
<el-button link @click="openReply(replies, item)">
|
||||||
回复
|
回复
|
||||||
</el-button>
|
</el-button>
|
||||||
<!-- 删除功能-只有自己评论的可以删除 -->
|
<!-- 删除功能-只有自己评论的可以删除 -->
|
||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
class="delete-btn"
|
class="delete-btn"
|
||||||
v-if="reply.canDelete"
|
v-if="currentUser.id === replies?.employee.id"
|
||||||
@click="deleteReply(reply, item)"
|
@click="deleteReply(replies, item)"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统一回复输入框 -->
|
<!-- 展开更多和收起 -->
|
||||||
<div
|
<div v-if="item.children.length > 1" class="sub-list-controls">
|
||||||
v-if="activeReply.groupId === item.id"
|
<div class="expand-line"></div>
|
||||||
class="inline-publisher"
|
<el-button link @click="handleExpand(item)">
|
||||||
>
|
<template v-if="!item.showAllReplies">
|
||||||
<div class="input-wrapper">
|
展开 {{ item.childrenCount }} 条回复
|
||||||
<mentionEditor v-model="replyInput" :users="userList" @select="onUserMentioned">
|
<el-icon><ArrowDown /></el-icon>
|
||||||
<el-input
|
</template>
|
||||||
v-model="replyInput"
|
<template v-else>
|
||||||
:placeholder="`${t('comment.reply')} @${
|
收起
|
||||||
activeReply.targetName
|
<el-icon><ArrowUp /></el-icon>
|
||||||
}...`"
|
</template>
|
||||||
type="textarea"
|
</el-button>
|
||||||
: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>
|
</div>
|
||||||
@@ -195,6 +164,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -213,33 +225,69 @@ const { t } = useI18n();
|
|||||||
// 当前用户信息
|
// 当前用户信息
|
||||||
const currentUser = computed(() => {
|
const currentUser = computed(() => {
|
||||||
return {
|
return {
|
||||||
nickname: userStore.userInfo.username,
|
name: userStore.userInfo.username,
|
||||||
avatar: userStore.userInfo.avatar,
|
avatar: userStore.userInfo.avatar,
|
||||||
|
id: 1,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 评论业务逻辑
|
// 评论业务逻辑
|
||||||
const activeReply = reactive({ parentId: null, targetName: "" });
|
const activeReply = reactive({ parentId: null, targetName: "" });
|
||||||
const mainInput = ref("");
|
const mainInput = ref("");
|
||||||
const replyInput = ref("");
|
|
||||||
const loading = ref(true); //当前骨架屏显示
|
const loading = ref(true); //当前骨架屏显示
|
||||||
// 评论数据
|
// 评论数据
|
||||||
const commentData = ref([
|
const commentData = ref([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
nickname: "李星倩",
|
|
||||||
avatar: "",
|
|
||||||
content: "已完成ROI测算,请审核。",
|
content: "已完成ROI测算,请审核。",
|
||||||
time: "2023-10-27T14:30:00",
|
createTime: "2023-10-27T14:30:00",
|
||||||
canDelete: true,
|
mentions: [{ userId: 2, nickname: "冯娜", start: 2, end: 6 }],
|
||||||
|
employee: {
|
||||||
|
id: 1,
|
||||||
|
name: "李星倩",
|
||||||
|
avatar: "",
|
||||||
|
},
|
||||||
|
childrenCount: 3,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 101,
|
id: 101,
|
||||||
nickname: "冯娜",
|
|
||||||
replyTo: "李星倩",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
||||||
content: "收到,数据已入库,我马上看下。",
|
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) => {
|
const openReply = (target, group) => {
|
||||||
activeReply.groupId = group.id;
|
|
||||||
activeReply.targetName = target.nickname;
|
// 1. 设置回复的目标关系
|
||||||
replyInput.value = "";
|
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);
|
console.log("emoji", emoji, type);
|
||||||
if (type === "main") {
|
if (type === "main") {
|
||||||
mainInput.value += emoji;
|
mainInput.value += emoji;
|
||||||
} else {
|
|
||||||
replyInput.value += emoji;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 取消评论
|
// 取消评论
|
||||||
const cancelReply = () => {
|
const cancelReply = () => {
|
||||||
|
activeReply.parentId = null;
|
||||||
|
activeReply.targetName = "";
|
||||||
activeReply.groupId = null;
|
activeReply.groupId = null;
|
||||||
|
mainInput.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// @圈人的操作
|
// @圈人的操作
|
||||||
const userList = [
|
const userList = [
|
||||||
{ id: 1, nickname: "李星倩" },
|
{ id: 1, nickname: "李星倩" },
|
||||||
{ id: 2, nickname: "冯娜" },
|
{ id: 2, nickname: "冯娜" },
|
||||||
{ id: 3, nickname: "张三" }
|
{ id: 3, nickname: "张三" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// TODO:@ 获取选中的用户信息
|
||||||
const onUserMentioned = (user) => {
|
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(
|
if (!rawText.trim()) return ElMessage.warning("内容不能为空");
|
||||||
/@([^\s@]+)/g,
|
|
||||||
'<span class="mention-highlight">@$1</span>'
|
|
||||||
);
|
|
||||||
|
|
||||||
commentData.value.unshift({
|
const prefix = `@${activeReply.targetName} `;
|
||||||
id: Date.now(),
|
// 如果用户没有删掉开头的 @人名,则把它剔除
|
||||||
...currentUser.value,
|
if (rawText.startsWith(prefix)) {
|
||||||
content: formattedContent,
|
rawText = rawText.slice(prefix.length).trimStart();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构造 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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
$color-blue: #409eff;
|
@use "./index.scss" as *;
|
||||||
$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>
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
class="mention-popover"
|
class="mention-popover"
|
||||||
:style="{ top: popoverPos.top + 'px', left: popoverPos.left + 'px' }"
|
: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 class="mention-list" v-if="filteredList.length">
|
||||||
<div
|
<div
|
||||||
v-for="(user, index) in filteredList"
|
v-for="(user, index) in filteredList"
|
||||||
@@ -18,19 +22,23 @@
|
|||||||
@mousedown.prevent="handleSelect(user)"
|
@mousedown.prevent="handleSelect(user)"
|
||||||
@mouseenter="selectIndex = index"
|
@mouseenter="selectIndex = index"
|
||||||
>
|
>
|
||||||
<name-avatar :name="user.nickname" :size="20" />
|
<name-avatar :name="user.nickname" :size="30" />
|
||||||
<span class="name">{{ user.nickname }}</span>
|
<span class="name">{{ user.nickname }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="mention-empty">未找到人员</div>
|
<div v-else class="mention-empty">
|
||||||
|
<el-empty :image-size="40" description="未匹配到人员" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, nextTick, watch } from 'vue';
|
import { ref, computed, onMounted, nextTick, watch } from 'vue';
|
||||||
|
import nameAvatar from '@/components/nameAvatar/index.vue';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: String,
|
modelValue: String,
|
||||||
users: { type: Array, default: () => [] }
|
users: { type: Array, default: () => [] }
|
||||||
@@ -46,10 +54,10 @@ const selectIndex = ref(0);
|
|||||||
const popoverPos = ref({ top: 0, left: 0 });
|
const popoverPos = ref({ top: 0, left: 0 });
|
||||||
let targetInput = null; // 存储原生 textarea 引用
|
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 = () => {
|
const computeCaretPosition = () => {
|
||||||
if (!targetInput || !mirrorRef.value) return;
|
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 handleInput = (e) => {
|
||||||
const el = e.target;
|
const el = e.target;
|
||||||
const val = el.value;
|
const val = el.value;
|
||||||
const pos = el.selectionStart; // 当前光标位置
|
const pos = el.selectionStart; // 当前光标位置
|
||||||
|
|
||||||
// 1. 获取光标之前的文本
|
|
||||||
const textBefore = val.slice(0, pos);
|
const textBefore = val.slice(0, pos);
|
||||||
|
|
||||||
// 2. 找到光标前最近的一个 @
|
|
||||||
const lastAtPos = textBefore.lastIndexOf("@");
|
const lastAtPos = textBefore.lastIndexOf("@");
|
||||||
|
|
||||||
if (lastAtPos !== -1) {
|
if (lastAtPos !== -1) {
|
||||||
@@ -124,6 +165,8 @@ const handleInput = (e) => {
|
|||||||
searchKey.value = contentBetween;
|
searchKey.value = contentBetween;
|
||||||
showPopover.value = true;
|
showPopover.value = true;
|
||||||
computeCaretPosition(); // 重新计算位置
|
computeCaretPosition(); // 重新计算位置
|
||||||
|
|
||||||
|
fetchRemoteUsers(contentBetween); //触发当前搜索
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,21 +179,20 @@ const handleKeyDown = (e) => {
|
|||||||
// 兼容删除 删除整个块而不是单独的文字
|
// 兼容删除 删除整个块而不是单独的文字
|
||||||
if (e.key === 'Backspace') {
|
if (e.key === 'Backspace') {
|
||||||
const el = e.target;
|
const el = e.target;
|
||||||
const pos = el.selectionStart; // 获取光标当前位置
|
const pos = el.selectionStart;
|
||||||
const val = el.value;
|
const val = el.value;
|
||||||
|
|
||||||
// 1. 获取光标之前的文本
|
// 1. 获取光标之前的文本
|
||||||
const textBefore = val.slice(0, pos);
|
const textBefore = val.slice(0, pos);
|
||||||
|
|
||||||
// 2. 正则匹配:光标前是否紧跟 "@姓名 " (注意:这里匹配的是带空格的完整块)
|
// 2. 正则匹配:光标前是否紧跟 "@姓名 "
|
||||||
// 这里的正则要匹配:以@开头,中间没有空格或@,最后以一个空格结尾
|
|
||||||
const mentionMatch = textBefore.match(/@([^\s@]+)\s$/);
|
const mentionMatch = textBefore.match(/@([^\s@]+)\s$/);
|
||||||
|
|
||||||
if (mentionMatch) {
|
if (mentionMatch) {
|
||||||
// 匹配到了,说明光标刚好在某个 "@姓名 " 的末尾
|
// 匹配到了,说明光标刚好在某个 "@姓名 " 的末尾
|
||||||
const wholeMatch = mentionMatch[0]; // 例如 "@李星倩 "
|
const wholeMatch = mentionMatch[0];
|
||||||
|
|
||||||
e.preventDefault(); // 阻止浏览器默认的删除一个字符的行为
|
e.preventDefault();
|
||||||
|
|
||||||
// 3. 计算新值:删掉整个匹配到的块
|
// 3. 计算新值:删掉整个匹配到的块
|
||||||
const startPos = pos - wholeMatch.length;
|
const startPos = pos - wholeMatch.length;
|
||||||
@@ -188,13 +230,76 @@ const handleSelect = (user) => {
|
|||||||
const textBefore = val.slice(0, pos);
|
const textBefore = val.slice(0, pos);
|
||||||
const lastAtPos = textBefore.lastIndexOf("@");
|
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('update:modelValue', newValue);
|
||||||
emit('select', user);
|
|
||||||
|
// 传给父组件一个包含位置的对象
|
||||||
|
emit('select', {
|
||||||
|
...user,
|
||||||
|
start: lastAtPos,
|
||||||
|
end: lastAtPos + insertName.length
|
||||||
|
});
|
||||||
|
|
||||||
showPopover.value = false;
|
showPopover.value = false;
|
||||||
nextTick(() => targetInput.focus());
|
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(() => {
|
onMounted(() => {
|
||||||
targetInput = containerRef.value.querySelector('textarea');
|
targetInput = containerRef.value.querySelector('textarea');
|
||||||
if (targetInput) {
|
if (targetInput) {
|
||||||
@@ -203,6 +308,27 @@ onMounted(() => {
|
|||||||
targetInput.addEventListener('scroll', () => {
|
targetInput.addEventListener('scroll', () => {
|
||||||
if (showPopover.value) computeCaretPosition();
|
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>
|
</script>
|
||||||
@@ -233,6 +359,17 @@ onMounted(() => {
|
|||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
border: 1px solid #e4e7ed;
|
border: 1px solid #e4e7ed;
|
||||||
min-width: 160px;
|
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 {
|
.mention-list {
|
||||||
@@ -246,11 +383,11 @@ onMounted(() => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: #f0f7ff;
|
background-color: var(--el-color-primary-light-9, #ecf5ff);
|
||||||
color: #409eff;
|
color: var(--el-color-primary, #409eff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
|
|||||||
@@ -9,9 +9,8 @@
|
|||||||
<div class="info-text">
|
<div class="info-text">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<span class="main-title">集团1</span>
|
<span class="main-title">集团1</span>
|
||||||
<el-tag size="small" effect="plain" class="title-tag"
|
<!-- TODO:这块不要展示tag -->
|
||||||
>集团</el-tag
|
<!-- <el-tag size="small" effect="plain" class="title-tag">集团</el-tag> -->
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sub-id">ID: 3</div>
|
<div class="sub-id">ID: 3</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div class="personnel">
|
||||||
<Comment />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Comment from "@/modules/Comment/index.vue";
|
import Comment from "@/modules/Comment/index.vue";
|
||||||
import { reactive, ref, onMounted } from "vue";
|
|
||||||
|
|
||||||
defineOptions({ name: "Personnel" });
|
defineOptions({ name: "Personnel" });
|
||||||
|
const drawer = ref(true);
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped>
|
||||||
|
.personnel {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user