fix:完善评论组件逻辑
This commit is contained in:
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -42,6 +42,7 @@ declare module 'vue' {
|
|||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElLink: typeof import('element-plus/es')['ElLink']
|
ElLink: typeof import('element-plus/es')['ElLink']
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
|
ElMention: typeof import('element-plus/es')['ElMention']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
|||||||
36
src/api/modules/Comment/index.ts
Normal file
36
src/api/modules/Comment/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import request from '@/request';
|
||||||
|
|
||||||
|
|
||||||
|
type commentProps = {
|
||||||
|
moduleId:string;
|
||||||
|
nodeId:string;
|
||||||
|
instanceId:string;
|
||||||
|
commentId:string;
|
||||||
|
pageNo:number;
|
||||||
|
pageSize:number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface addCommentProps {
|
||||||
|
moduleId:string;
|
||||||
|
nodeId:string;
|
||||||
|
instanceId:string;
|
||||||
|
content:string;
|
||||||
|
mentions:Record<string,any>[]
|
||||||
|
[key:string]:any;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 获取评论
|
||||||
|
export const getComment = (params: commentProps) => {
|
||||||
|
return request.get('/comment/getComment', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加评论-回复评论
|
||||||
|
export const addReplyComment = (data: addCommentProps) => {
|
||||||
|
return request.post('/comment/addComment', data)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除评论
|
||||||
|
export const deleteComment = (id: string) => {
|
||||||
|
return request.delete(`/comment/deleteComment/${id}`);
|
||||||
|
}
|
||||||
58
src/api/stage/organization/index.ts
Normal file
58
src/api/stage/organization/index.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import request from '@/request';
|
||||||
|
|
||||||
|
type paramsProps = {
|
||||||
|
name:string;
|
||||||
|
domain:string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type wxWorkProps = {
|
||||||
|
corpId:string;
|
||||||
|
agentId:number;
|
||||||
|
secret:string;
|
||||||
|
token:string;
|
||||||
|
encodingAesKey:string;
|
||||||
|
}
|
||||||
|
interface addDataProps{
|
||||||
|
name:string;
|
||||||
|
wxWork:wxWorkProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 查询企业
|
||||||
|
export const getEnterprise = (params: paramsProps) =>{
|
||||||
|
return request.get('/auth/v1/backend/enterprise', params);
|
||||||
|
}
|
||||||
|
// 添加企业
|
||||||
|
export const addEnterprise = (data: addDataProps) => {
|
||||||
|
return request.post('/auth/v1/backend/enterprise', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用企业
|
||||||
|
export const enableEnterprise = (id:string) => {
|
||||||
|
return request.post(`/auth/v1/backend/enterprise/${id}/enable`);
|
||||||
|
}
|
||||||
|
// 禁用企业
|
||||||
|
export const disableEnterprise = (id:string) => {
|
||||||
|
return request.post(`/auth/v1/backend/enterprise/${id}/disable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载企业机构
|
||||||
|
export const getEnterpriseOrg = (parentId:number) => {
|
||||||
|
return request.get(`/auth/v1/backend/enterprise/institution/${parentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载员工详情
|
||||||
|
export const getEnterpriseUser = (employeeId:number) => {
|
||||||
|
return request.get(`/auth/v1/backend/enterprise/employee/${employeeId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 企业详情
|
||||||
|
export const getEnterpriseDetail = () => {
|
||||||
|
return request.get(`/auth/v1/backend/enterprise/detail`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载部门详情
|
||||||
|
export const getEnterpriseOrgDetail = (departmentId:string) => {
|
||||||
|
return request.get(`/auth/v1/backend/enterprise/department/${departmentId}`);
|
||||||
|
}
|
||||||
@@ -11,8 +11,7 @@ $color-white: #fff;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
height: calc(100vh - 170px);
|
|
||||||
|
|
||||||
// 评论的样式
|
// 评论的样式
|
||||||
.main-publisher{
|
.main-publisher{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="comment-app">
|
<div class="comment-app">
|
||||||
<!-- 评论列表 -->
|
<!-- 评论列表 -->
|
||||||
<div class="comment-list">
|
<div class="comment-list">
|
||||||
<el-scrollbar @end-reached="loadMore">
|
<el-scrollbar>
|
||||||
<!-- 骨架屏 -->
|
<!-- 骨架屏 -->
|
||||||
<div v-if="loading">
|
<div v-if="loading">
|
||||||
<div v-for="n in 2" :key="'skeleton-' + n" class="comment-group">
|
<div v-for="n in 2" :key="'skeleton-' + n" class="comment-group">
|
||||||
@@ -182,29 +182,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="loadingMore" class="comment-loading-status">
|
|
||||||
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
|
|
||||||
</div>
|
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 评论模块 -->
|
<!-- 评论模块 -->
|
||||||
<div class="main-publisher">
|
<div class="main-publisher">
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<mentionEditor
|
<el-mention
|
||||||
v-model="mainInput"
|
|
||||||
:users="userList"
|
|
||||||
@select="onUserMentioned"
|
|
||||||
>
|
|
||||||
<el-input
|
|
||||||
v-model="mainInput"
|
v-model="mainInput"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="2"
|
:options="filteredUsers"
|
||||||
:placeholder="t('comment.placeholder')"
|
:loading="searching"
|
||||||
|
@search="search"
|
||||||
|
@select="onUserSelect"
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
:props="{ value: 'name', label: 'name' }"
|
||||||
|
whole
|
||||||
resize="none"
|
resize="none"
|
||||||
|
:popper-options="{
|
||||||
|
placement: 'top-start',
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: 'offset',
|
||||||
|
options: {
|
||||||
|
offset: [0, 6], // [横向偏移, 纵向偏移]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
popper-class="mj-comment-mention"
|
||||||
|
:placeholder="t('comment.placeholder')"
|
||||||
|
:rows="2"
|
||||||
|
>
|
||||||
|
<template #label="{ item }">
|
||||||
|
<div class="mention-item">
|
||||||
|
<name-avatar
|
||||||
|
:name="item.name"
|
||||||
|
:size="24"
|
||||||
|
style="margin-right: 10px"
|
||||||
/>
|
/>
|
||||||
</mentionEditor>
|
<span class="mention-name">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-mention>
|
||||||
<div class="input-tools">
|
<div class="input-tools">
|
||||||
<div class="left-icons">
|
<div class="left-icons">
|
||||||
<!-- 提到-后续迭代 -->
|
<!-- 提到-后续迭代 -->
|
||||||
@@ -222,7 +242,7 @@
|
|||||||
class="send-btn"
|
class="send-btn"
|
||||||
type="primary"
|
type="primary"
|
||||||
link
|
link
|
||||||
@click="handleSendComment('main')"
|
@click="handleSendComment"
|
||||||
>
|
>
|
||||||
<el-icon :size="20" :title="t('comment.send')"
|
<el-icon :size="20" :title="t('comment.send')"
|
||||||
><Promotion
|
><Promotion
|
||||||
@@ -234,7 +254,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from "vue";
|
import { ref, reactive } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import EmojiPicker from "./EmojiPicker.vue";
|
import EmojiPicker from "./EmojiPicker.vue";
|
||||||
@@ -242,7 +262,8 @@ import NameAvatar from "@/components/nameAvatar/index.vue";
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useUserStore } from "@/store";
|
import { useUserStore } from "@/store";
|
||||||
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
||||||
import mentionEditor from "./mentionEditor.vue";
|
import { useUserSearch } from "./useUserSearch";
|
||||||
|
import { parseMention } from "./utils";
|
||||||
const { formatTime } = useRelativeTime();
|
const { formatTime } = useRelativeTime();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -254,18 +275,40 @@ const currentUser = computed(() => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const props = defineProps({
|
||||||
|
queryParams: {
|
||||||
|
//外部传入的请求参数-获取评论
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
filteredUsers,
|
||||||
|
searching,
|
||||||
|
search,
|
||||||
|
selectedUsersCache,
|
||||||
|
recordSelection,
|
||||||
|
getSelectedUsers,
|
||||||
|
clearSelection,
|
||||||
|
} = useUserSearch((keyword, signal) => handleFetchSearch(keyword, signal));
|
||||||
|
|
||||||
// 评论业务逻辑
|
// 评论业务逻辑
|
||||||
const activeReply = reactive({ parentId: null, targetName: "" });
|
const activeReply = reactive({ parentId: null, targetName: "" }); // 点击reply回复的数据信息
|
||||||
const mainInput = ref("");
|
const mainInput = ref("");
|
||||||
const loading = ref(true); //当前骨架屏显示
|
const loading = ref(true); //当前骨架屏显示
|
||||||
|
const expandingCount = ref(0); //展开收起统计
|
||||||
// 评论数据
|
// 评论数据
|
||||||
const commentData = ref([
|
const commentData = ref([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
content: "已完成ROI测算,请审核。",
|
content: "这是我的测试评论数据信息@张三1 😄",
|
||||||
createTime: "2023-10-27T14:30:00",
|
createTime: "2023-10-27T14:30:00",
|
||||||
mentions: [{ userId: 2, nickname: "冯娜", start: 2, end: 6 }],
|
mentions: [{
|
||||||
|
"id": 4,
|
||||||
|
"name": "张三1",
|
||||||
|
"start": 12,
|
||||||
|
"end": 16
|
||||||
|
}],
|
||||||
employee: {
|
employee: {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "李星倩",
|
name: "李星倩",
|
||||||
@@ -274,23 +317,37 @@ const commentData = ref([
|
|||||||
childrenCount: 10,
|
childrenCount: 10,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 101,
|
content: "好的那我来测试下艾特人员信息@冯娜 @张三1 你们好啊",
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
mentions: [
|
||||||
content: "收到,数据已入库,我马上看下。",
|
{
|
||||||
createTime: 1767604936684,
|
|
||||||
mentions: [{ userId: 2, nickname: "冯娜", start: 11, end: 15 }],
|
|
||||||
employee: {
|
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "冯娜",
|
name: "冯娜",
|
||||||
|
start: 14,
|
||||||
|
end: 17,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "张三1",
|
||||||
|
start: 18,
|
||||||
|
end: 22,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
employee: {
|
||||||
|
id: 1,
|
||||||
|
name: "李星倩",
|
||||||
avatar: "",
|
avatar: "",
|
||||||
},
|
},
|
||||||
|
reply: {
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 102,
|
id: 102,
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
||||||
content: "收到,数据已入库,我马上看下。",
|
content: "收到,数据已入库,我马上看下。",
|
||||||
createTime: 1767604936684,
|
createTime: 1767604936684,
|
||||||
mentions: [{ userId: 2, nickname: "冯娜", start: 11, end: 15 }],
|
mentions: [{ userId: 2, name: "冯娜", start: 11, end: 15 }],
|
||||||
replyEmployee: {
|
replyEmployee: {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "冯娜",
|
name: "冯娜",
|
||||||
@@ -306,7 +363,7 @@ const commentData = ref([
|
|||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
||||||
content: "收到,数据已入库,我马上看下。",
|
content: "收到,数据已入库,我马上看下。",
|
||||||
createTime: 1767604936684,
|
createTime: 1767604936684,
|
||||||
mentions: [{ userId: 2, nickname: "冯娜", start: 11, end: 15 }],
|
mentions: [{ userId: 2, name: "冯娜", start: 11, end: 15 }],
|
||||||
employee: {
|
employee: {
|
||||||
id: 3,
|
id: 3,
|
||||||
name: "王五",
|
name: "王五",
|
||||||
@@ -317,6 +374,48 @@ const commentData = ref([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// TODO:请求用户列表的接口函数
|
||||||
|
const handleFetchSearch = async (keyword, signal) => {
|
||||||
|
console.log("获取参数信息", keyword, signal);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
return [
|
||||||
|
{ id: 1, name: "李星倩" },
|
||||||
|
{ id: 2, name: "冯娜" },
|
||||||
|
{ id: 4, name: "张三1" },
|
||||||
|
{ id: 5, name: "张三2" },
|
||||||
|
{ id: 6, name: "张三3" },
|
||||||
|
{ id: 7, name: "张三4" },
|
||||||
|
{ id: 8, name: "张三5" },
|
||||||
|
{ id: 9, name: "张三6" },
|
||||||
|
{ id: 10, name: "张三7" },
|
||||||
|
{ id: 11, name: "张三8" },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 点击回复插入 mentions 块
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
const el = e.target;
|
||||||
|
const pos = el.selectionStart;
|
||||||
|
const val = mainInput.value;
|
||||||
|
|
||||||
|
if (activeReply.parentId && activeReply.targetName) {
|
||||||
|
const prefix = `@${activeReply.targetName} `;
|
||||||
|
if (pos === prefix.length && val.startsWith(prefix)) {
|
||||||
|
e.preventDefault();
|
||||||
|
mainInput.value = val.slice(prefix.length);
|
||||||
|
resetReplyStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用户选中@圈人的操作
|
||||||
|
const onUserSelect = (user: any) => {
|
||||||
|
console.log("获取当前返回的用户信息:", user);
|
||||||
|
recordSelection(user);
|
||||||
|
};
|
||||||
|
|
||||||
// 回复
|
// 回复
|
||||||
const openReply = (target, group) => {
|
const openReply = (target, group) => {
|
||||||
// 1. 设置回复的目标关系
|
// 1. 设置回复的目标关系
|
||||||
@@ -355,54 +454,58 @@ const onSelectEmoji = (emoji) => {
|
|||||||
mainInput.value += emoji;
|
mainInput.value += emoji;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 取消评论
|
// 清除回复信息的操作
|
||||||
const cancelReply = () => {
|
const resetReplyStatus = () => {
|
||||||
activeReply.parentId = null;
|
activeReply.parentId = null;
|
||||||
activeReply.targetName = "";
|
activeReply.targetName = "";
|
||||||
activeReply.groupId = null;
|
activeReply.groupId = null;
|
||||||
mainInput.value = "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// @圈人的操作
|
// 取消评论
|
||||||
const userList = [
|
const cancelReply = () => {
|
||||||
{ id: 1, nickname: "李星倩" },
|
resetReplyStatus();
|
||||||
{ id: 2, nickname: "冯娜" },
|
mainInput.value = "";
|
||||||
{ id: 3, nickname: "张三" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// TODO:@ 获取选中的用户信息
|
|
||||||
const onUserMentioned = (user) => {
|
|
||||||
console.log("Mentioned:", user);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一发送评论/回复的方法
|
* 统一发送评论/回复的方法
|
||||||
* @param {string} type - 'main' (主评论) 或 'reply' (回复)
|
|
||||||
*/
|
*/
|
||||||
const handleSendComment = async () => {
|
const handleSendComment = async () => {
|
||||||
const type = activeReply.parentId ? "reply" : "main";
|
|
||||||
const isReply = type === "reply";
|
|
||||||
let rawText = mainInput.value;
|
let rawText = mainInput.value;
|
||||||
|
|
||||||
if (!rawText.trim()) return ElMessage.warning("内容不能为空");
|
if (!rawText.trim()) return ElMessage.warning("内容不能为空");
|
||||||
|
|
||||||
const prefix = `@${activeReply.targetName} `;
|
let finalParentId = null;
|
||||||
// 如果用户没有删掉开头的 @人名,则把它剔除
|
const expectedPrefix = `@${activeReply.targetName} `;
|
||||||
if (rawText.startsWith(prefix)) {
|
const isActuallyReply =
|
||||||
rawText = rawText.slice(prefix.length).trimStart();
|
activeReply.parentId && rawText.startsWith(expectedPrefix);
|
||||||
|
if (isActuallyReply) {
|
||||||
|
finalParentId = activeReply.parentId;
|
||||||
|
rawText = rawText.slice(expectedPrefix.length);
|
||||||
|
} else {
|
||||||
|
finalParentId = null;
|
||||||
}
|
}
|
||||||
|
const type = finalParentId ? "reply" : "main"; //ui构造渲染判断
|
||||||
// 构造 Payload
|
// 构造 Payload
|
||||||
const mentionList = [];
|
const mentionList: any[] = [];
|
||||||
|
const localCache = new Map();
|
||||||
|
// 从 Hooks 获取当前所有缓存的克隆
|
||||||
|
for (const [name, users] of selectedUsersCache.entries()) {
|
||||||
|
localCache.set(name, [...users]);
|
||||||
|
}
|
||||||
const regex = /@([^\s@]+)(?=\s|$)/g;
|
const regex = /@([^\s@]+)(?=\s|$)/g;
|
||||||
let match;
|
let match;
|
||||||
while ((match = regex.exec(rawText)) !== null) {
|
while ((match = regex.exec(rawText)) !== null) {
|
||||||
const nickname = match[1];
|
const nickname = match[1];
|
||||||
const user = userList.find((u) => u.nickname === nickname);
|
const user = localCache.get(nickname);
|
||||||
if (user) {
|
const userQueue = localCache.get(nickname);
|
||||||
|
if (userQueue && userQueue.length > 0) {
|
||||||
|
const currentUser = userQueue.shift();
|
||||||
|
console.log(
|
||||||
|
`匹配到用户: ${currentUser?.name}, ID: ${currentUser?.id}, 位置: ${match.index}`
|
||||||
|
);
|
||||||
mentionList.push({
|
mentionList.push({
|
||||||
id: user.id,
|
id: currentUser.id,
|
||||||
name: user.name,
|
name: currentUser.name,
|
||||||
start: match.index,
|
start: match.index,
|
||||||
end: match.index + match[0].length,
|
end: match.index + match[0].length,
|
||||||
});
|
});
|
||||||
@@ -413,40 +516,40 @@ const handleSendComment = async () => {
|
|||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
content: rawText,
|
content: rawText,
|
||||||
mentionList: mentionList,
|
mentions: mentionList,
|
||||||
// 如果是回复,带上关联 ID
|
// TODO:如果是回复,带上关联 ID
|
||||||
reply: {
|
reply: {
|
||||||
id: isReply ? activeReply.groupId : null,
|
id: activeReply.groupId,
|
||||||
name: isReply ? activeReply.groupId : null,
|
name: activeReply.groupId,
|
||||||
},
|
},
|
||||||
|
...props.queryParams,
|
||||||
};
|
};
|
||||||
|
console.log("获取传递给后端的数据信息:", params);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 请求后端接口提交数据
|
||||||
// const res = await api.postComment(params);
|
// const res = await api.postComment(params);
|
||||||
console.log("发送请求, 参数为:", params);
|
|
||||||
|
|
||||||
// 前端 UI 更新
|
// 前端 UI 更新
|
||||||
updateUIAfterSend(type, params);
|
updateUIAfterSend(type, params);
|
||||||
|
|
||||||
// 清空输入框
|
// 清空输入框
|
||||||
cancelReply();
|
cancelReply();
|
||||||
ElMessage.success(isReply ? "回复成功" : "评论成功");
|
clearSelection();
|
||||||
|
ElMessage.success(type === "reply" ? "回复成功" : "评论成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("error", error);
|
console.log("error", error);
|
||||||
ElMessage.error("发送失败");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新当前的UI层
|
// 更新当前的UI层
|
||||||
const updateUIAfterSend = (type, params) => {
|
const updateUIAfterSend = (type, params) => {
|
||||||
const newComment = {
|
const newComment = {
|
||||||
id: Date.now(), // 这个地方需要替换为后端返回的真实id 用作后续的删除
|
id: Date.now(), // TODO:这个地方需要替换为后端返回的真实id 用作后续的删除
|
||||||
employee: {
|
employee: {
|
||||||
...currentUser.value,
|
...currentUser.value,
|
||||||
},
|
},
|
||||||
reply: params.reply,
|
reply: params.reply,
|
||||||
content: params.content,
|
content: params.content,
|
||||||
mentions: params.mentionList, // 这里直接存入带下标的数组
|
mentions: params.mentionList,
|
||||||
createTime: new Date().valueOf(),
|
createTime: new Date().valueOf(),
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
@@ -458,9 +561,7 @@ const updateUIAfterSend = (type, params) => {
|
|||||||
//回复某人的数据渲染
|
//回复某人的数据渲染
|
||||||
const targetGroup = commentData.value.find((i) => i.id === params.reply.id);
|
const targetGroup = commentData.value.find((i) => i.id === params.reply.id);
|
||||||
if (targetGroup) {
|
if (targetGroup) {
|
||||||
// 记录回复对象的名字以便显示 "@某某"
|
|
||||||
newComment.replyTo = activeReply.targetName;
|
newComment.replyTo = activeReply.targetName;
|
||||||
// targetGroup.children.unshift(newComment);
|
|
||||||
if (!targetGroup.localReplies) targetGroup.localReplies = [];
|
if (!targetGroup.localReplies) targetGroup.localReplies = [];
|
||||||
targetGroup.localReplies.unshift(newComment);
|
targetGroup.localReplies.unshift(newComment);
|
||||||
}
|
}
|
||||||
@@ -470,8 +571,9 @@ const updateUIAfterSend = (type, params) => {
|
|||||||
// TODO:展开
|
// TODO:展开
|
||||||
const MOCK_REPLIES_POOL = Array.from({ length: 20 }, (_, i) => ({
|
const MOCK_REPLIES_POOL = Array.from({ length: 20 }, (_, i) => ({
|
||||||
id: 200 + i,
|
id: 200 + i,
|
||||||
content: `这是模拟的第 ${i + 1} 条回复内容,用于测试分页加载。`,
|
content: `这是模拟的第 ${i + 1} 条回复内容,@张三 用于测试分页加载。`,
|
||||||
createTime: Date.now() - i * 100000,
|
createTime: Date.now() - i * 100000,
|
||||||
|
mentions: [{ id: 1, name: "张三", start: 3, end: 10 }],
|
||||||
employee: {
|
employee: {
|
||||||
id: 10 + i,
|
id: 10 + i,
|
||||||
name: `同事${i + 1}`,
|
name: `同事${i + 1}`,
|
||||||
@@ -481,6 +583,7 @@ const MOCK_REPLIES_POOL = Array.from({ length: 20 }, (_, i) => ({
|
|||||||
}));
|
}));
|
||||||
const loadReplies = async (item) => {
|
const loadReplies = async (item) => {
|
||||||
if (item.loading) return;
|
if (item.loading) return;
|
||||||
|
expandingCount.value++;
|
||||||
item.loading = true;
|
item.loading = true;
|
||||||
try {
|
try {
|
||||||
// 后端获取最终的数据信息
|
// 后端获取最终的数据信息
|
||||||
@@ -511,95 +614,28 @@ const loadReplies = async (item) => {
|
|||||||
console.log("error", error);
|
console.log("error", error);
|
||||||
} finally {
|
} finally {
|
||||||
item.loading = false;
|
item.loading = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
expandingCount.value--;
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 收起
|
// 收起
|
||||||
const collapseReplies = (item) => {
|
const collapseReplies = (item) => {
|
||||||
|
expandingCount.value++;
|
||||||
item.showAllReplies = false;
|
item.showAllReplies = false;
|
||||||
|
console.log("收起数据", expandingCount.value);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = document.getElementById(`comment-${item.id}`);
|
const el = document.getElementById(`comment-${item.id}`);
|
||||||
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
expandingCount.value--;
|
||||||
|
}, 300);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// 初始化 (骨架屏展示)
|
||||||
* @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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// FIXME:加载更多
|
|
||||||
const page = ref(1);
|
|
||||||
const loadingMore = ref(false); // 分页加载状态
|
|
||||||
const noMore = ref(false); // 是否全部加载完毕
|
|
||||||
const totalCount = ref(20); // 模拟后端返回的总评论数
|
|
||||||
const disabled = computed(() => loadingMore.value || noMore.value);
|
|
||||||
const loadMore = async () => {
|
|
||||||
if (disabled.value) return;
|
|
||||||
|
|
||||||
loadingMore.value = true;
|
|
||||||
|
|
||||||
// 模拟接口请求
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 模拟构造下一页 Mock 数据
|
|
||||||
const nextBatch = [
|
|
||||||
{
|
|
||||||
id: Date.now(),
|
|
||||||
content: `这是第 ${page.value + 1} 页的评论内容`,
|
|
||||||
createTime: new Date().toISOString(),
|
|
||||||
employee: { id: 88, name: "滚动测试员", avatar: "" },
|
|
||||||
childrenCount: 2,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: Date.now() + 1,
|
|
||||||
content: "多拉出一条数据",
|
|
||||||
createTime: new Date().toISOString(),
|
|
||||||
employee: { id: 89, name: "张三", avatar: "" },
|
|
||||||
childrenCount: 0,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 合并数据
|
|
||||||
commentData.value.push(...nextBatch);
|
|
||||||
page.value++;
|
|
||||||
|
|
||||||
// 判断是否加载完(模拟)
|
|
||||||
if (commentData.value.length >= totalCount.value) {
|
|
||||||
noMore.value = true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载失败", error);
|
|
||||||
} finally {
|
|
||||||
loadingMore.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|||||||
@@ -1,406 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="mention-container" ref="containerRef">
|
|
||||||
<slot></slot>
|
|
||||||
|
|
||||||
<div ref="mirrorRef" class="textarea-mirror" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<transition name="el-zoom-in-top">
|
|
||||||
<div
|
|
||||||
v-if="showPopover"
|
|
||||||
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"
|
|
||||||
:key="user.id"
|
|
||||||
:class="['mention-item', { active: index === selectIndex }]"
|
|
||||||
@mousedown.prevent="handleSelect(user)"
|
|
||||||
@mouseenter="selectIndex = index"
|
|
||||||
>
|
|
||||||
<name-avatar :name="user.nickname" :size="30" />
|
|
||||||
<span class="name">{{ user.nickname }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="mention-empty">
|
|
||||||
<el-empty :image-size="40" description="未匹配到人员" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<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: () => [] }
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'select']);
|
|
||||||
|
|
||||||
const containerRef = ref(null);
|
|
||||||
const mirrorRef = ref(null);
|
|
||||||
const showPopover = ref(false);
|
|
||||||
const searchKey = ref("");
|
|
||||||
const selectIndex = ref(0);
|
|
||||||
const popoverPos = ref({ top: 0, left: 0 });
|
|
||||||
let targetInput = null; // 存储原生 textarea 引用
|
|
||||||
|
|
||||||
// 搜索查询模块
|
|
||||||
const filteredList = ref([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
let abortController = null;
|
|
||||||
// 计算像素坐标的核心逻辑
|
|
||||||
const computeCaretPosition = () => {
|
|
||||||
if (!targetInput || !mirrorRef.value) return;
|
|
||||||
|
|
||||||
const val = targetInput.value;
|
|
||||||
const cursorIndex = targetInput.selectionStart;
|
|
||||||
|
|
||||||
// 找到最近的一个 @
|
|
||||||
const textBefore = val.slice(0, cursorIndex);
|
|
||||||
const lastAtPos = textBefore.lastIndexOf("@");
|
|
||||||
|
|
||||||
// 获取 @ 到光标之间的内容作为搜索词
|
|
||||||
searchKey.value = textBefore.slice(lastAtPos + 1);
|
|
||||||
|
|
||||||
// --- 镜像同步逻辑 ---
|
|
||||||
const style = window.getComputedStyle(targetInput);
|
|
||||||
const mirror = mirrorRef.value;
|
|
||||||
|
|
||||||
// 复制所有会影响排版的样式
|
|
||||||
const propList = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'paddingTop', 'paddingLeft', 'paddingRight', 'paddingBottom', 'borderWidth', 'boxSizing', 'letterSpacing', 'wordBreak'];
|
|
||||||
propList.forEach(prop => mirror.style[prop] = style[prop]);
|
|
||||||
mirror.style.width = targetInput.offsetWidth + 'px';
|
|
||||||
|
|
||||||
// 将内容填入镜像,在 @ 位置插入标记元素
|
|
||||||
const contentBeforeAt = val.slice(0, lastAtPos);
|
|
||||||
const contentAtToCursor = val.slice(lastAtPos, cursorIndex);
|
|
||||||
|
|
||||||
// 使用 textContent 防止 XSS,使用 <br> 处理换行
|
|
||||||
mirror.textContent = contentBeforeAt;
|
|
||||||
const marker = document.createElement('span');
|
|
||||||
marker.textContent = '@';
|
|
||||||
marker.style.color = 'red'; // 仅调试可见
|
|
||||||
mirror.appendChild(marker);
|
|
||||||
|
|
||||||
// 继续填充剩余内容以保持排版一致
|
|
||||||
const remaining = document.createTextNode(contentAtToCursor);
|
|
||||||
mirror.appendChild(remaining);
|
|
||||||
|
|
||||||
// 获取标记位的相对坐标
|
|
||||||
nextTick(() => {
|
|
||||||
popoverPos.value = {
|
|
||||||
// offsetTop 是输入框顶部的偏移 + 标记位高度 - 滚动条高度
|
|
||||||
top: targetInput.offsetTop + marker.offsetTop + parseInt(style.lineHeight) - targetInput.scrollTop,
|
|
||||||
left: targetInput.offsetLeft + marker.offsetLeft
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 定义圈人的搜索方法
|
|
||||||
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; // 当前光标位置
|
|
||||||
const textBefore = val.slice(0, pos);
|
|
||||||
const lastAtPos = textBefore.lastIndexOf("@");
|
|
||||||
|
|
||||||
if (lastAtPos !== -1) {
|
|
||||||
// 3. 关键逻辑:获取 @ 到光标之间的内容
|
|
||||||
const contentBetween = textBefore.slice(lastAtPos + 1);
|
|
||||||
|
|
||||||
// 4. 判断逻辑:
|
|
||||||
// - 如果 @ 和光标之间有空格,说明这一段提及已结束,不弹窗
|
|
||||||
// - 如果 @ 前面不是空格且不是开头,说明可能是邮箱地址,不弹窗
|
|
||||||
const charBeforeAt = textBefore[lastAtPos - 1];
|
|
||||||
const isAtStartOrAfterSpace = !charBeforeAt || /\s/.test(charBeforeAt);
|
|
||||||
const hasSpaceBetween = /\s/.test(contentBetween);
|
|
||||||
|
|
||||||
if (isAtStartOrAfterSpace && !hasSpaceBetween) {
|
|
||||||
searchKey.value = contentBetween;
|
|
||||||
showPopover.value = true;
|
|
||||||
computeCaretPosition(); // 重新计算位置
|
|
||||||
|
|
||||||
fetchRemoteUsers(contentBetween); //触发当前搜索
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其余情况全部关闭
|
|
||||||
showPopover.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
// 兼容删除 删除整个块而不是单独的文字
|
|
||||||
if (e.key === 'Backspace') {
|
|
||||||
const el = e.target;
|
|
||||||
const pos = el.selectionStart;
|
|
||||||
const val = el.value;
|
|
||||||
|
|
||||||
// 1. 获取光标之前的文本
|
|
||||||
const textBefore = val.slice(0, pos);
|
|
||||||
|
|
||||||
// 2. 正则匹配:光标前是否紧跟 "@姓名 "
|
|
||||||
const mentionMatch = textBefore.match(/@([^\s@]+)\s$/);
|
|
||||||
|
|
||||||
if (mentionMatch) {
|
|
||||||
// 匹配到了,说明光标刚好在某个 "@姓名 " 的末尾
|
|
||||||
const wholeMatch = mentionMatch[0];
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// 3. 计算新值:删掉整个匹配到的块
|
|
||||||
const startPos = pos - wholeMatch.length;
|
|
||||||
const newValue = val.slice(0, startPos) + val.slice(pos);
|
|
||||||
|
|
||||||
emit('update:modelValue', newValue);
|
|
||||||
|
|
||||||
// 4. 在下个 tick 修正光标位置
|
|
||||||
nextTick(() => {
|
|
||||||
el.setSelectionRange(startPos, startPos);
|
|
||||||
// 同时也关闭可能存在的弹窗
|
|
||||||
showPopover.value = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!showPopover.value) return;
|
|
||||||
if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
selectIndex.value = (selectIndex.value - 1 + filteredList.value.length) % filteredList.value.length;
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
selectIndex.value = (selectIndex.value + 1) % filteredList.value.length;
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (filteredList.value[selectIndex.value]) handleSelect(filteredList.value[selectIndex.value]);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
showPopover.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (user) => {
|
|
||||||
const val = props.modelValue;
|
|
||||||
const pos = targetInput.selectionStart;
|
|
||||||
const textBefore = val.slice(0, pos);
|
|
||||||
const lastAtPos = textBefore.lastIndexOf("@");
|
|
||||||
|
|
||||||
const insertName = `@${user.nickname} `; // 注意结尾有空格
|
|
||||||
const newValue = val.slice(0, lastAtPos) + insertName + val.slice(pos);
|
|
||||||
|
|
||||||
emit('update:modelValue', newValue);
|
|
||||||
|
|
||||||
// 传给父组件一个包含位置的对象
|
|
||||||
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) {
|
|
||||||
targetInput.addEventListener('input', handleInput);
|
|
||||||
targetInput.addEventListener('keydown', handleKeyDown);
|
|
||||||
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>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.mention-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 镜像层:必须绝对定位且隐藏 */
|
|
||||||
.textarea-mirror {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: -999;
|
|
||||||
visibility: hidden;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-popover {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 3000;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
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 {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 0;
|
|
||||||
|
|
||||||
.mention-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: var(--el-color-primary-light-9, #ecf5ff);
|
|
||||||
color: var(--el-color-primary, #409eff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-empty {
|
|
||||||
padding: 12px;
|
|
||||||
text-align: center;
|
|
||||||
color: #909399;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
98
src/modules/Comment/useUserSearch.ts
Normal file
98
src/modules/Comment/useUserSearch.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { ref, shallowRef } from "vue";
|
||||||
|
import { debounce } from "lodash-es";
|
||||||
|
|
||||||
|
interface SearchOptions {
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserSearch(
|
||||||
|
apiFn: (keyword: string, signal: AbortSignal) => Promise<any[]>,
|
||||||
|
options: SearchOptions = {}
|
||||||
|
) {
|
||||||
|
const { debounceMs = 300 } = options;
|
||||||
|
|
||||||
|
const filteredUsers = ref<any[]>([]);
|
||||||
|
const selectedUsersCache = new Map<string, any[]>();
|
||||||
|
const searching = ref(false);
|
||||||
|
|
||||||
|
// 使用 shallowRef 存储 Controller,因为它不需要深度响应
|
||||||
|
const abortController = shallowRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// 存储用户选中的缓存
|
||||||
|
const recordSelection = (user: any) => {
|
||||||
|
if (user && user.name) {
|
||||||
|
// 依然按名存数组,支持同名
|
||||||
|
const list = selectedUsersCache.get(user.name) || [];
|
||||||
|
if (!list.find((u) => u.id === user.id)) {
|
||||||
|
list.push(user);
|
||||||
|
selectedUsersCache.set(user.name, [...list]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取用户已选中的用户组
|
||||||
|
const getSelectedUsers = () => Array.from(selectedUsersCache.values()).flat();
|
||||||
|
|
||||||
|
// 清除用户选中的缓存
|
||||||
|
const clearSelection = () => {
|
||||||
|
selectedUsersCache.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从缓存池中查找对应的数据
|
||||||
|
const getCacheByName = (name) => {
|
||||||
|
const list = selectedUsersCache.get(name);
|
||||||
|
return list ? [...list] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真正的请求逻辑
|
||||||
|
*/
|
||||||
|
const performSearch = async (keyword: string) => {
|
||||||
|
// 1. 中断之前的请求
|
||||||
|
if (abortController.value) {
|
||||||
|
abortController.value.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 创建新的控制实例
|
||||||
|
abortController.value = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. 执行外部传入的 API
|
||||||
|
const res = await apiFn(keyword, abortController.value.signal);
|
||||||
|
filteredUsers.value = res;
|
||||||
|
} catch (err: any) {
|
||||||
|
// 仅处理非取消类的错误
|
||||||
|
if (err.name !== "AbortError" && err.message !== "canceled") {
|
||||||
|
console.error("Search API Error:", err);
|
||||||
|
filteredUsers.value = [];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 4. 如果当前请求没有被中断,则关闭 loading
|
||||||
|
if (!abortController.value?.signal.aborted) {
|
||||||
|
searching.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. 创建防抖函数
|
||||||
|
const debouncedSearch = debounce(performSearch, debounceMs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暴露给外部调用的入口
|
||||||
|
*/
|
||||||
|
const search = (keyword: string) => {
|
||||||
|
searching.value = true;
|
||||||
|
debouncedSearch(keyword);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filteredUsers,
|
||||||
|
searching,
|
||||||
|
selectedUsersCache,
|
||||||
|
recordSelection,
|
||||||
|
clearSelection,
|
||||||
|
getSelectedUsers,
|
||||||
|
getCacheByName,
|
||||||
|
search,
|
||||||
|
};
|
||||||
|
}
|
||||||
31
src/modules/Comment/utils.ts
Normal file
31
src/modules/Comment/utils.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* @description
|
||||||
|
* @param {string} text 原始文本
|
||||||
|
* @param {string[]} atUsers 这条评论真正圈到的人员列表
|
||||||
|
*/
|
||||||
|
type atUserProps = {
|
||||||
|
id:string|number;
|
||||||
|
name?:string;
|
||||||
|
}
|
||||||
|
export const parseMention = (text: string, atUsers: atUserProps[]) => {
|
||||||
|
if (!text) return "";
|
||||||
|
if (!atUsers || atUsers.length === 0) {
|
||||||
|
return text.replace(/</g, "<").replace(/>/g, ">").replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
// 只循环这条评论里【真正圈到】的人
|
||||||
|
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 rawMentionText = result.slice(m.start, m.end);
|
||||||
|
const cleanName = rawMentionText.trim().replace(/^@+/, "");
|
||||||
|
const displayName = `@${cleanName}`;
|
||||||
|
const safeDisplayName = displayName.replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
const highlight = `<span class="mention-highlight" data-user-id="${m.userId}">${safeDisplayName}</span>`;
|
||||||
|
|
||||||
|
result = prefix + highlight + suffix;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.replace(/\n/g, '<br>');
|
||||||
|
};
|
||||||
@@ -9,13 +9,9 @@
|
|||||||
destroy-on-close
|
destroy-on-close
|
||||||
class="standard-ui-back-drawer"
|
class="standard-ui-back-drawer"
|
||||||
>
|
>
|
||||||
<div style="padding: 20px">
|
<div style="padding: 20px;height:100%">
|
||||||
<Comment />
|
<Comment />
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
|
||||||
<el-button @click="drawer = false">取 消</el-button>
|
|
||||||
<el-button type="primary" @click="drawer = false">确 定</el-button>
|
|
||||||
</template>
|
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user