From 65090d8dcf4dc9d7a09eeb1d72b492273fb71b40 Mon Sep 17 00:00:00 2001 From: liangdong <1789719643@qq.com> Date: Tue, 6 Jan 2026 19:18:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E5=AE=8C=E5=96=84=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.d.ts | 1 + index.html | 2 +- package.json | 1 + pnpm-lock.yaml | 3 + src/components/nameAvatar/index.vue | 8 +- src/modules/Comment/index.scss | 255 +++++++ src/modules/Comment/index.vue | 686 ++++++++---------- src/modules/Comment/mentionEditor.vue | 181 ++++- .../origanization/OrganizationDetail.vue | 5 +- src/pages/stage/personnel/index.vue | 29 +- 10 files changed, 761 insertions(+), 410 deletions(-) create mode 100644 src/modules/Comment/index.scss diff --git a/components.d.ts b/components.d.ts index 6409fd4..9e46a16 100644 --- a/components.d.ts +++ b/components.d.ts @@ -33,6 +33,7 @@ declare module 'vue' { ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElHeader: typeof import('element-plus/es')['ElHeader'] diff --git a/index.html b/index.html index 66c5580..fd56d45 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - 智视界 + 智服链
diff --git a/package.json b/package.json index c948ce4..2fd8263 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@vue/tsconfig": "^0.8.1", "axios": "^1.13.2", "dayjs": "^1.11.19", + "lodash-es": "^4.17.22", "sass": "^1.97.1", "typescript": "~5.9.3", "unicode-emoji-json": "^0.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d3e39e..62af595 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: dayjs: specifier: ^1.11.19 version: 1.11.19 + lodash-es: + specifier: ^4.17.22 + version: 4.17.22 sass: specifier: ^1.97.1 version: 1.97.1 diff --git a/src/components/nameAvatar/index.vue b/src/components/nameAvatar/index.vue index dbdc516..5446ca0 100644 --- a/src/components/nameAvatar/index.vue +++ b/src/components/nameAvatar/index.vue @@ -2,7 +2,7 @@ {{ displayText }} @@ -22,6 +22,10 @@ const displayText = computed(() => { return props.name ? props.name.charAt(0) : ''; }); +const fontSize = computed(() => { + return `${Math.max(props.size * 0.4, 12)}px`; +}); + const bgColor = computed(() => { if (!props.name) return '#409EFF'; let hash = 0; @@ -49,7 +53,7 @@ const bgColor = computed(() => { box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); .avatar-text { color: var(--el-avatar-text-color); - font-size: 16px; + font-size: var(--avatar-text-size); letter-spacing: -0.5px; line-height: 1; } diff --git a/src/modules/Comment/index.scss b/src/modules/Comment/index.scss new file mode 100644 index 0000000..0fa968b --- /dev/null +++ b/src/modules/Comment/index.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/src/modules/Comment/index.vue b/src/modules/Comment/index.vue index 3e4ae7b..4621391 100644 --- a/src/modules/Comment/index.vue +++ b/src/modules/Comment/index.vue @@ -1,44 +1,6 @@ @@ -213,33 +225,69 @@ const { t } = useI18n(); // 当前用户信息 const currentUser = computed(() => { return { - nickname: userStore.userInfo.username, + name: userStore.userInfo.username, avatar: userStore.userInfo.avatar, + id: 1, }; }); // 评论业务逻辑 const activeReply = reactive({ parentId: null, targetName: "" }); const mainInput = ref(""); -const replyInput = ref(""); const loading = ref(true); //当前骨架屏显示 // 评论数据 const commentData = ref([ { id: 1, - nickname: "李星倩", - avatar: "", content: "已完成ROI测算,请审核。", - time: "2023-10-27T14:30:00", - canDelete: true, + createTime: "2023-10-27T14:30:00", + mentions: [{ userId: 2, nickname: "冯娜", start: 2, end: 6 }], + employee: { + id: 1, + name: "李星倩", + avatar: "", + }, + childrenCount: 3, children: [ { id: 101, - nickname: "冯娜", - replyTo: "李星倩", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2", content: "收到,数据已入库,我马上看下。", - time: 1767604936684, + createTime: 1767604936684, + mentions: [{ userId: 2, nickname: "冯娜", start: 11, end: 15 }], + employee: { + id: 2, + name: "冯娜", + avatar: "", + }, + }, + { + id: 102, + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2", + content: "收到,数据已入库,我马上看下。", + createTime: 1767604936684, + mentions: [{ userId: 2, nickname: "冯娜", start: 11, end: 15 }], + replyEmployee: { + id: 1, + name: "冯娜", + }, + employee: { + id: 2, + name: "zhanghan", + avatar: "", + }, + }, + { + id: 103, + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2", + content: "收到,数据已入库,我马上看下。", + createTime: 1767604936684, + mentions: [{ userId: 2, nickname: "冯娜", start: 11, end: 15 }], + employee: { + id: 3, + name: "王五", + avatar: "", + }, }, ], }, @@ -247,9 +295,27 @@ const commentData = ref([ // 回复 const openReply = (target, group) => { - activeReply.groupId = group.id; - activeReply.targetName = target.nickname; - replyInput.value = ""; + + // 1. 设置回复的目标关系 + activeReply.groupId = group.id; // 根评论ID + activeReply.parentId = target.id; // 直接父级ID + activeReply.targetName = target.employee.name; + + // 2. 沿用 @ 逻辑:自动在输入框插入 @某人 + const mentionStr = `@${target.employee.name} `; + + // 如果输入框里已经有内容了,在前面追加,否则直接赋值 + if (!mainInput.value.includes(mentionStr)) { + mainInput.value = mentionStr + mainInput.value; + } + + // 3. 聚焦到输入框 + nextTick(() => { + const textarea = document.querySelector('.fixed-publisher textarea'); + textarea?.focus(); + // 飞书细节:光标移到最后 + textarea.selectionStart = textarea.value.length; + }); }; // 删除回复-删除评论 @@ -274,73 +340,162 @@ const onSelectEmoji = (emoji, type) => { console.log("emoji", emoji, type); if (type === "main") { mainInput.value += emoji; - } else { - replyInput.value += emoji; } }; // 取消评论 const cancelReply = () => { + activeReply.parentId = null; + activeReply.targetName = ""; activeReply.groupId = null; + mainInput.value = ''; }; // @圈人的操作 const userList = [ { id: 1, nickname: "李星倩" }, { id: 2, nickname: "冯娜" }, - { id: 3, nickname: "张三" } + { id: 3, nickname: "张三" }, ]; +// TODO:@ 获取选中的用户信息 const onUserMentioned = (user) => { - console.log('Mentioned:', user.nickname); + console.log("Mentioned:", user.nickname); }; -const submitMainComment = () => { - if (!mainInput.value.trim()) return ElMessage.warning("内容不能为空"); +/** + * 统一发送评论/回复的方法 + * @param {string} type - 'main' (主评论) 或 'reply' (回复) + */ +const handleSendComment = async () => { + const type = activeReply.parentId ? 'reply' : 'main'; + const isReply = type === "reply"; + let rawText = mainInput.value; - const formattedContent = mainInput.value.replace( - /@([^\s@]+)/g, - '@$1' - ); + if (!rawText.trim()) return ElMessage.warning("内容不能为空"); - commentData.value.unshift({ - id: Date.now(), - ...currentUser.value, - content: formattedContent, - time: new Date().valueOf(), - canDelete: true, - children: [], - }); - - mainInput.value = ""; -}; - -// 通用的内容转换函数 -const parseMention = (text) => { - if (!text) return ""; - return text.replace( - /@([^\s@]+)/g, - '@$1' - ); -}; - -const submitReply = () => { - const targetGroup = commentData.value.find(i => i.id === activeReply.groupId); - if (targetGroup) { - const formattedReply = replyInput.value.replace( - /@([^\s@]+)/g, - '@$1' - ); - - targetGroup.children.push({ - id: Date.now(), - ...currentUser.value, - replyTo: activeReply.targetName, - content: formattedReply, // 使用处理后的 HTML - time: new Date().valueOf(), - }); - cancelReply(); + const prefix = `@${activeReply.targetName} `; + // 如果用户没有删掉开头的 @人名,则把它剔除 + if (rawText.startsWith(prefix)) { + rawText = rawText.slice(prefix.length).trimStart(); } + + // 构造 Payload + const mentionList = []; + const regex = /@([^\s@]+)(?=\s|$)/g; + let match; + while ((match = regex.exec(rawText)) !== null) { + const nickname = match[1]; + const user = userList.find((u) => u.nickname === nickname); + if (user) { + mentionList.push({ + id: user.id, + name: user.name, + start: match.index, + end: match.index + match[0].length, + }); + } + } + + // 组装接口请求的参数 + + const params = { + content: rawText, + mentionList: mentionList, + // 如果是回复,带上关联 ID + reply: { + id: isReply ? activeReply.groupId : null, + name: isReply ? activeReply.groupId : null, + }, + }; + + try { + // const res = await api.postComment(params); + console.log("发送请求, 参数为:", params); + + // 前端 UI 更新 + updateUIAfterSend(type, params); + + // 清空输入框 + cancelReply(); + ElMessage.success(isReply ? "回复成功" : "评论成功"); + } catch (error) { + console.log("error", error); + ElMessage.error("发送失败"); + } +}; + +// 更新当前的UI层 +const updateUIAfterSend = (type, params) => { + const newComment = { + id: Date.now(), // 这个地方需要替换为后端返回的真实id 用作后续的删除 + employee: { + ...currentUser.value, + }, + reply: params.reply, + content: params.content, + mentions: params.mentionList, // 这里直接存入带下标的数组 + createTime: new Date().valueOf(), + children: [], + }; + + if (type === "main") { + // 评论 + commentData.value.unshift(newComment); + } else { + //回复某人的数据渲染 + const targetGroup = commentData.value.find((i) => i.id === params.reply.id); + if (targetGroup) { + // 记录回复对象的名字以便显示 "@某某" + newComment.replyTo = activeReply.targetName; + // targetGroup.children.unshift(newComment); + if (!targetGroup.localReplies) targetGroup.localReplies = []; + targetGroup.localReplies.unshift(newComment); + } + } +}; + +// TODO:展开更多-收起 +const handleExpand = async (item) => { + try { + // 后端获取最终的数据信息 + // const res = await xxxxxx() + const res = []; + const combined = [...(item.localReplies || []), ...res]; + item.children = combined.filter( + (v, i, a) => a.findIndex((t) => t.id === v.id) === i + ); + item.localReplies = []; + item.showAllReplies = true; + } catch (error) { + console.log("error", error); + } +}; + +/** + * @param {string} text - 原始文本 + * @param {Array} atUsers - 这条评论真正圈到的人员列表 + */ +const parseMention = (text, atUsers = userList) => { + if (!text) return ""; + if (!atUsers || atUsers.length === 0) return text; + // 只循环这条评论里【真正圈到】的人 + const sortedMentions = [...atUsers].sort((a, b) => b.start - a.start); + let result = text; + sortedMentions.forEach((m) => { + const prefix = result.slice(0, m.start); + const suffix = result.slice(m.end); + const mentionText = result.slice(m.start, m.end); + + const hasAt = mentionText.trim().startsWith("@"); + const displayName = hasAt ? mentionText : `@${mentionText}`; + + // 插入高亮标签 + const highlight = `${displayName} `; + + result = prefix + highlight + suffix; + }); + return result; }; // 初始化 @@ -352,228 +507,5 @@ onMounted(() => { diff --git a/src/modules/Comment/mentionEditor.vue b/src/modules/Comment/mentionEditor.vue index 1134ad4..ffb4351 100644 --- a/src/modules/Comment/mentionEditor.vue +++ b/src/modules/Comment/mentionEditor.vue @@ -10,6 +10,10 @@ class="mention-popover" :style="{ top: popoverPos.top + 'px', left: popoverPos.left + 'px' }" > +
+ +
+ - @@ -233,6 +359,17 @@ onMounted(() => { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); border: 1px solid #e4e7ed; min-width: 160px; + transition: top 0.1s ease-out, left 0.1s ease-out; + overflow: hidden; + transform: translateY(-100%); + margin-top: -26px; + &.el-zoom-in-top-enter-active { + transform-origin: center bottom; + } + .mention-loading{ + text-align: center; + padding: 20px 0; + } } .mention-list { @@ -246,11 +383,11 @@ onMounted(() => { gap: 10px; padding: 8px 12px; cursor: pointer; - transition: all 0.2s; + transition: background-color 0.15s ease, color 0.15s ease; &.active { - background-color: #f0f7ff; - color: #409eff; + background-color: var(--el-color-primary-light-9, #ecf5ff); + color: var(--el-color-primary, #409eff); } .name { diff --git a/src/pages/stage/origanization/OrganizationDetail.vue b/src/pages/stage/origanization/OrganizationDetail.vue index d3f1772..66f1482 100644 --- a/src/pages/stage/origanization/OrganizationDetail.vue +++ b/src/pages/stage/origanization/OrganizationDetail.vue @@ -9,9 +9,8 @@
集团1 - 集团 + +
ID: 3
diff --git a/src/pages/stage/personnel/index.vue b/src/pages/stage/personnel/index.vue index 8ac9516..e127293 100644 --- a/src/pages/stage/personnel/index.vue +++ b/src/pages/stage/personnel/index.vue @@ -1,12 +1,31 @@ - +