Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
C
corporate-culture-qd
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
王立鹏
corporate-culture-qd
Commits
9d2e1e9c
Commit
9d2e1e9c
authored
Apr 17, 2026
by
lijiabin
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
【需求 21402】 feat: 完成评论区@功能
parent
ddfca05a
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
718 additions
and
56 deletions
+718
-56
env.d.ts
env.d.ts
+13
-0
index.ts
src/api/article/index.ts
+8
-2
types.ts
src/api/article/types.ts
+46
-0
index.vue
src/components/common/Comment/index.vue
+66
-25
index.vue
src/components/common/CommentBox/index.vue
+62
-1
index.vue
src/components/common/CommentDialog/index.vue
+6
-2
index.vue
src/components/common/CommentListDialog/index.vue
+31
-8
index.vue
src/components/common/MentionBox/index.vue
+177
-0
index.vue
src/components/common/MentionList/index.vue
+179
-0
index.vue
src/components/common/PublishBox/index.vue
+1
-0
index.vue
src/components/common/RichTextarea/index.vue
+112
-12
index.vue
src/components/common/SendMessageDialog/index.vue
+1
-0
symbolKey.ts
src/constants/symbolKey.ts
+11
-2
index.ts
src/utils/emoji/index.ts
+5
-4
No files found.
env.d.ts
View file @
9d2e1e9c
...
...
@@ -20,3 +20,16 @@ declare module '@wangeditor/editor-for-vue' {
const
Toolbar
:
any
export
{
Editor
,
Toolbar
}
}
declare
module
'textarea-caret'
{
interface
CaretCoordinates
{
top
:
number
left
:
number
height
:
number
}
export
default
function
getCaretCoordinates
(
element
:
HTMLTextAreaElement
|
HTMLInputElement
,
position
:
number
,
):
CaretCoordinates
}
src/api/article/index.ts
View file @
9d2e1e9c
...
...
@@ -9,6 +9,7 @@ import type {
CommentItemDto
,
CommentSearchParams
,
InterviewItemDto
,
AtUserInfoDto
,
ColumnItemDto
,
VideoOptionDto
,
CommentChildrenSearchParams
,
...
...
@@ -383,8 +384,13 @@ export const topOrCancelTopComment = (commentId: number) => {
/**
* 获取可@的用户列表
*/
export
const
getAtUserList
=
(
data
:
{
findType
?:
BooleanFlag
;
findValue
?:
string
})
=>
{
return
service
.
request
<
string
[]
>
({
export
const
getAtUserList
=
(
data
:
PageSearchParams
&
{
findType
?:
BooleanFlag
findValue
?:
string
},
)
=>
{
return
service
.
request
<
BackendServicePageResult
<
AtUserInfoDto
>>
({
url
:
`/api/auth/getUserInfo`
,
method
:
'POST'
,
data
,
...
...
src/api/article/types.ts
View file @
9d2e1e9c
...
...
@@ -334,6 +334,10 @@ export interface AddCommentDto {
content
:
string
pid
?:
number
|
string
imgUrl
?:
string
// at的人 逗号隔开
mentionUserIdList
?:
string
// 评论的html字符串
contentHtml
?:
string
}
/**
...
...
@@ -344,6 +348,8 @@ export interface CommentItemDto {
avatar
:
string
children
:
CommentItemDto
[]
content
:
string
/** 后端返回的富文本(@ 可点),无则回退 content */
contentHtml
:
string
createTime
:
number
hasPraise
:
BooleanFlag
hiddenAvatar
:
string
...
...
@@ -450,3 +456,43 @@ export interface UpdateArticleRecommendAndSortDto {
isRecommend
:
BooleanFlag
articleId
:
number
}
/**
* 可@的用户列表
*/
export
interface
AtUserInfoDto
{
account
:
string
avatar
:
string
birthday
:
string
createTime
:
string
createUser
:
number
deptId
:
number
directLeader
:
number
email
:
string
entryDate
:
string
hadFansPoint
:
number
hiddenAvatar
:
string
hiddenName
:
string
interactiveMessageCount
:
number
isOfficialAccount
:
number
jobNumId
:
string
level
:
number
loginTime
:
string
name
:
string
officialTag
:
null
password
:
string
passwordChangeStatus
:
number
passwordUpdateTime
:
string
phone
:
string
region
:
string
regionHide
:
string
roleId
:
string
salt
:
string
sex
:
string
signature
:
string
status
:
string
updateTime
:
string
updateUser
:
number
userId
:
number
version
:
number
}
src/components/common/Comment/index.vue
View file @
9d2e1e9c
...
...
@@ -7,43 +7,65 @@
<div
class=
"flex items-center gap-2"
>
<button
class=
"cursor-pointer px-3 py-1.5 text-sm transition-all relative"
@
click=
"((searchParams.sortType =
2
), refresh())"
@
click=
"((searchParams.sortType =
CommentSortTypeEnum.MOST_LIKE
), refresh())"
:class=
"
{
'text-indigo-600 font-medium': searchParams.sortType === 2,
'text-gray-600 hover:text-gray-900': searchParams.sortType !== 2,
'text-indigo-600 font-medium':
searchParams.sortType === CommentSortTypeEnum.MOST_LIKE,
'text-gray-600 hover:text-gray-900':
searchParams.sortType !== CommentSortTypeEnum.MOST_LIKE,
}"
>
最
新
最
高点赞
<span
v-if=
"searchParams.sortType ===
2
"
v-if=
"searchParams.sortType ===
CommentSortTypeEnum.MOST_LIKE
"
class=
"absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600"
></span>
</button>
<button
class=
"cursor-pointer px-3 py-1.5 text-sm transition-all relative"
@
click=
"((searchParams.sortType =
4
), refresh())"
@
click=
"((searchParams.sortType =
CommentSortTypeEnum.MOST_COMMENT
), refresh())"
:class=
"
{
'text-indigo-600 font-medium': searchParams.sortType === 4,
'text-gray-600 hover:text-gray-900': searchParams.sortType !== 4,
'text-indigo-600 font-medium':
searchParams.sortType === CommentSortTypeEnum.MOST_COMMENT,
'text-gray-600 hover:text-gray-900':
searchParams.sortType !== CommentSortTypeEnum.MOST_COMMENT,
}"
>
最多
点赞
最多
评论
<span
v-if=
"searchParams.sortType ===
4
"
v-if=
"searchParams.sortType ===
CommentSortTypeEnum.MOST_COMMENT
"
class=
"absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600"
></span>
</button>
<button
class=
"cursor-pointer px-3 py-1.5 text-sm transition-all relative"
@
click=
"((searchParams.sortType =
1
), refresh())"
@
click=
"((searchParams.sortType =
CommentSortTypeEnum.EARLIEST_PUBLISH
), refresh())"
:class=
"
{
'text-indigo-600 font-medium': searchParams.sortType === 1,
'text-gray-600 hover:text-gray-900': searchParams.sortType !== 1,
'text-indigo-600 font-medium':
searchParams.sortType === CommentSortTypeEnum.EARLIEST_PUBLISH,
'text-gray-600 hover:text-gray-900':
searchParams.sortType !== CommentSortTypeEnum.EARLIEST_PUBLISH,
}"
>
最多评论
最早发布
<span
v-if=
"searchParams.sortType === CommentSortTypeEnum.EARLIEST_PUBLISH"
class=
"absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600"
></span>
</button>
<button
class=
"cursor-pointer px-3 py-1.5 text-sm transition-all relative"
@
click=
"((searchParams.sortType = CommentSortTypeEnum.NEWEST_PUBLISH), refresh())"
:class=
"
{
'text-indigo-600 font-medium':
searchParams.sortType === CommentSortTypeEnum.NEWEST_PUBLISH,
'text-gray-600 hover:text-gray-900':
searchParams.sortType !== CommentSortTypeEnum.NEWEST_PUBLISH,
}"
>
最新发布
<span
v-if=
"searchParams.sortType ===
1
"
v-if=
"searchParams.sortType ===
CommentSortTypeEnum.NEWEST_PUBLISH
"
class=
"absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600"
></span>
</button>
...
...
@@ -67,6 +89,7 @@
v-model:inputText=
"myComment"
v-model:inputImg=
"myCommentImgStr"
class=
"flex-1"
ref=
"myCommentBoxRef"
>
<template
#
submit
>
<button
...
...
@@ -155,7 +178,8 @@
<!-- 换行 -->
<p
class=
"text-gray-800 my-2 break-all whitespace-pre-wrap"
v-html=
"parseEmoji(item.content)"
v-html=
"parseEmoji(item.contentHtml || item.content)"
v-parse-comment
></p>
<!-- 评论图片列表 -->
<div
class=
"flex flex-wrap gap-2"
>
...
...
@@ -232,7 +256,8 @@
</div>
<p
class=
"text-gray-800 my-2 break-all whitespace-pre-wrap text-[16px]"
v-html=
"parseEmoji(child.content)"
v-html=
"parseEmoji(child.contentHtml || child.content)"
v-parse-comment
></p>
<!-- 评论图片列表 -->
<div
class=
"flex flex-wrap gap-2"
>
...
...
@@ -345,7 +370,10 @@
v-model:inputText=
"commentToOther"
v-model:inputImg=
"commentToOtherImgStr"
class=
"flex-1"
:ref=
"(el) => (replyToOtherBoxRefList[index] = el as HTMLElement)"
:ref=
"
(ins) =>
(replyToOtherBoxRefList[index] = ins as InstanceType<typeof CommentBox>)
"
>
<
template
#
submit
>
<button
...
...
@@ -414,6 +442,7 @@ import { parseEmoji } from '@/utils/emoji'
import
CommentBox
from
'../CommentBox/index.vue'
import
dayjs
from
'dayjs'
import
{
push
}
from
'notivue'
import
{
IS_REAL_KEY_COMMENT
,
CommentSortTypeEnum
}
from
'@/constants'
const
{
jumpToUserHomePage
}
=
useNavigation
()
const
{
...
...
@@ -442,12 +471,15 @@ const total = defineModel<number>('total', { required: true, default: 0 })
const
userStore
=
useUserStore
()
const
{
userInfo
}
=
storeToRefs
(
userStore
)
const
isReal
=
computed
(
const
isReal
=
computed
<
BooleanFlag
>
(
()
=>
type
===
ArticleTypeEnum
.
PRACTICE
||
type
===
ArticleTypeEnum
.
INTERVIEW
||
type
===
ArticleTypeEnum
.
QUESTION
,
+
(
type
===
ArticleTypeEnum
.
PRACTICE
||
type
===
ArticleTypeEnum
.
INTERVIEW
||
type
===
ArticleTypeEnum
.
QUESTION
),
)
provide
(
IS_REAL_KEY_COMMENT
,
isReal
)
const
userAvatar
=
computed
(()
=>
{
return
isReal
.
value
?
userInfo
.
value
.
avatar
:
userInfo
.
value
.
hiddenAvatar
})
...
...
@@ -456,7 +488,7 @@ const isAuthor = computed(() => {
})
const
commentRef
=
useTemplateRef
<
HTMLElement
|
null
>
(
'commentRef'
)
const
commentListDialogRef
=
useTemplateRef
<
typeof
CommentListDialog
>
(
'commentListDialogRef'
)
const
replyToOtherBoxRefList
=
ref
<
HTMLElement
[]
>
([])
const
replyToOtherBoxRefList
=
ref
<
InstanceType
<
typeof
CommentBox
>
[]
>
([])
const
commentItemRefList
=
ref
<
HTMLElement
[]
>
([])
// 回滚到评论框
const
{
handleBackTop
}
=
useScrollTop
(
commentRef
)
...
...
@@ -475,10 +507,10 @@ const {
}
=
usePageSearch
(
isQuestion
?
getSecondCommentList
:
getCommentList
,
{
defaultParams
:
{
...(
commentId
?
{
pid
:
commentId
,
sortType
:
2
}
?
{
pid
:
commentId
,
sortType
:
CommentSortTypeEnum
.
MOST_LIKE
}
:
{
articleId
:
id
,
sortType
:
2
,
sortType
:
CommentSortTypeEnum
.
MOST_LIKE
,
}),
},
defaultSize
,
...
...
@@ -523,6 +555,7 @@ const myCommentImgStr = ref('')
// 回复别人的图片
const
commentToOtherImgStr
=
ref
(
''
)
const
currentCommentId
=
ref
(
-
1
)
const
myCommentBoxRef
=
useTemplateRef
<
InstanceType
<
typeof
CommentBox
>>
(
'myCommentBoxRef'
)
const
handleLickComment
=
async
(
item
:
CommentItemDto
)
=>
{
await
addOrCancelCommentLike
(
item
.
id
)
...
...
@@ -594,6 +627,8 @@ const handleMyComment = async () => {
content
:
myComment
.
value
,
...(
commentId
?
{
pid
:
commentId
}
:
{}),
imgUrl
:
myCommentImgStr
.
value
,
mentionUserIdList
:
myCommentBoxRef
.
value
?.
getMentionFns
()?.
getMentionUserIds
?.()?.
join
?.(
','
),
contentHtml
:
myCommentBoxRef
.
value
?.
getAnswerHtml
(),
})
push
.
success
(
'发表评论成功'
)
refresh
()
...
...
@@ -616,6 +651,12 @@ const handleComment = async (index: number) => {
content
:
commentToOther
.
value
,
...(
currentCommentId
.
value
?
{
pid
:
currentCommentId
.
value
}
:
{}),
imgUrl
:
commentToOtherImgStr
.
value
,
mentionUserIdList
:
replyToOtherBoxRefList
.
value
[
index
]
?.
getMentionFns
?.()
?.
getMentionUserIds
?.()
?.
join
?.(
','
)
||
''
,
contentHtml
:
replyToOtherBoxRefList
.
value
[
index
]?.
getAnswerHtml
(),
})
push
.
success
(
'发表评论成功'
)
commentToOther
.
value
=
''
...
...
src/components/common/CommentBox/index.vue
View file @
9d2e1e9c
...
...
@@ -5,13 +5,60 @@ import UploadImgIcon from '../UploadImgIcon/index.vue'
import
UploadEmojiIcon
from
'../UploadEmojiIcon/index.vue'
import
{
useUploadImg
}
from
'@/hooks'
import
type
{
IEmoji
}
from
'@/utils/emoji/type'
import
{
MENTION_USER_FN_KEY
,
IS_REAL_KEY_COMMENT
}
from
'@/constants/symbolKey'
const
isReal
=
inject
(
IS_REAL_KEY_COMMENT
)
!
function
escapeHtml
(
str
:
string
)
{
return
str
.
replace
(
/&/g
,
'&'
)
.
replace
(
/</g
,
'<'
)
.
replace
(
/>/g
,
'>'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
'''
)
}
/** 将纯文本中的 @昵称 转为带 data-id 的 span(仅对在 MentionBox 中选中的用户生效) */
function
buildAnswerHtml
(
source
:
string
,
mentionUsers
:
Array
<
{
userId
:
string
;
name
:
string
}
>
)
{
const
mentions
=
mentionUsers
.
filter
((
u
)
=>
u
?.
name
)
.
sort
((
a
,
b
)
=>
b
.
name
.
length
-
a
.
name
.
length
)
.
map
((
u
)
=>
({
token
:
`@
${
u
.
name
}
`
,
userId
:
String
(
u
.
userId
)
}))
const
isBoundary
=
(
ch
:
string
|
undefined
)
=>
!
ch
||
/
\s
/
.
test
(
ch
)
let
html
=
''
let
i
=
0
while
(
i
<
source
.
length
)
{
const
prev
=
i
>
0
?
source
[
i
-
1
]
:
undefined
const
matched
=
mentions
.
find
(({
token
})
=>
{
if
(
!
source
.
startsWith
(
token
,
i
))
return
false
const
next
=
source
[
i
+
token
.
length
]
return
isBoundary
(
prev
)
&&
isBoundary
(
next
)
})
if
(
matched
)
{
html
+=
`<span contenteditable="false" data-id="
${
escapeHtml
(
matched
.
userId
)}
" data-isreal="
${
unref
(
isReal
)}
" style="color: #2563eb;cursor:pointer;">
${
escapeHtml
(
matched
.
token
)}
</span> `
i
+=
matched
.
token
.
length
continue
}
html
+=
escapeHtml
(
source
.
charAt
(
i
))
i
++
}
return
html
}
interface
CommentBoxProps
{
textAreaHeight
?:
number
placeholder
?:
string
showMention
?:
boolean
}
const
{
textAreaHeight
=
55
,
placeholder
=
'请输入内容'
}
=
defineProps
<
CommentBoxProps
>
()
const
{
textAreaHeight
=
55
,
placeholder
=
'请输入内容'
,
showMention
=
true
,
}
=
defineProps
<
CommentBoxProps
>
()
const
inputStr
=
defineModel
<
string
>
(
'inputText'
,
{
required
:
true
})
const
imgStrs
=
defineModel
<
string
>
(
'inputImg'
,
{
required
:
true
})
...
...
@@ -37,11 +84,24 @@ const handleSelectEmoji = async (emoji: IEmoji) => {
textarea
.
selectionStart
=
textarea
.
selectionEnd
=
start
+
emoji
.
name
.
length
}
const
mentionFnObj
:
{
getMentionUserIds
?:
()
=>
string
[]
getMentionUsers
?:
()
=>
Array
<
{
userId
:
string
;
name
:
string
}
>
}
=
{}
provide
(
MENTION_USER_FN_KEY
,
mentionFnObj
)
const
getAnswerHtml
=
():
string
=>
buildAnswerHtml
(
inputStr
.
value
??
''
,
mentionFnObj
.
getMentionUsers
?.()
??
[])
defineExpose
({
focus
:
async
()
=>
{
await
nextTick
()
richTextareaRef
.
value
?.
getTextarea
()?.
focus
()
},
getMentionFns
:
()
=>
{
return
mentionFnObj
},
getAnswerHtml
,
})
</
script
>
...
...
@@ -55,6 +115,7 @@ defineExpose({
@
deleteImg=
"handleDeleteImg"
:height=
"textAreaHeight"
:placeholder=
"placeholder"
:showMention=
"showMention"
/>
<div
class=
"flex justify-between items-center mt-3"
>
<div
class=
"flex items-center gap-2"
>
...
...
src/components/common/CommentDialog/index.vue
View file @
9d2e1e9c
...
...
@@ -15,6 +15,7 @@
:textAreaHeight=
"100"
v-model:inputText=
"commentStr"
v-model:inputImg=
"commentImgStr"
ref=
"commentBoxRef"
>
<template
#
submit
>
<el-button
...
...
@@ -37,7 +38,7 @@ import { storeToRefs } from 'pinia'
import
{
addComment
}
from
'@/api'
import
CommentBox
from
'../CommentBox/index.vue'
import
{
push
}
from
'notivue'
import
{
BooleanFlag
,
IS_REAL_KEY_COMMENT
}
from
'@/constants'
const
emit
=
defineEmits
<
{
(
e
:
'commentSuccess'
):
void
}
>
()
...
...
@@ -49,10 +50,11 @@ const commentStr = ref('')
const
commentImgStr
=
ref
(
''
)
const
loading
=
ref
(
false
)
const
isDisabled
=
computed
(()
=>
!
commentStr
.
value
.
trim
()
||
loading
.
value
)
const
commentBoxRef
=
useTemplateRef
<
InstanceType
<
typeof
CommentBox
>>
(
'commentBoxRef'
)
const
userStore
=
useUserStore
()
const
{
userInfo
}
=
storeToRefs
(
userStore
)
provide
(
IS_REAL_KEY_COMMENT
,
BooleanFlag
.
YES
)
let
articleId
=
0
// 暴露 open 方法
...
...
@@ -79,6 +81,8 @@ const handleSubmit = async () => {
articleId
:
articleId
,
content
:
commentStr
.
value
,
imgUrl
:
commentImgStr
.
value
,
mentionUserIdList
:
commentBoxRef
.
value
?.
getMentionFns
?.()?.
getMentionUserIds
?.()?.
join
?.(
','
),
contentHtml
:
commentBoxRef
.
value
?.
getAnswerHtml
?.(),
})
push
.
success
(
'评论发表成功'
)
handleClose
()
...
...
src/components/common/CommentListDialog/index.vue
View file @
9d2e1e9c
...
...
@@ -3,7 +3,7 @@
v-model=
"visible"
:title=
"dialogTitle"
width=
"650px"
class=
"rounded-2xl!
overflow-hidden
"
class=
"rounded-2xl!"
:show-close=
"false"
top=
"5vh"
append-to-body
...
...
@@ -51,7 +51,8 @@
<div
class=
"text-gray-800 text-base leading-relaxed mb-2"
v-html=
"parseEmoji(parentComment.content)"
v-html=
"parseEmoji(parentComment.contentHtml || parentComment.content)"
v-parse-comment=
"closeDialog"
></div>
<!-- 下方图片 -->
...
...
@@ -124,7 +125,8 @@
<p
class=
"text-gray-700 text-base mb-2 break-all leading-relaxed"
v-html=
"parseEmoji(item.content)"
v-html=
"parseEmoji(item.contentHtml || item.content)"
v-parse-comment=
"closeDialog"
></p>
<!-- 下方图片 -->
<div
class=
"flex flex-wrap gap-2"
v-if=
"item.imgUrl"
>
...
...
@@ -160,14 +162,16 @@
v-model:inputImg=
"imgUrl"
:textAreaHeight=
"60"
:placeholder=
"`回复 ${item.replyUser}`"
:ref=
"(el) => (replyToOtherBoxRefList[index] = el as HTMLElement)"
:ref=
"
(el) => (replyToOtherBoxRefList[index] = el as InstanceType<typeof CommentBox>)
"
>
<
template
#
submit
>
<el-button
:disabled=
"isDisabled"
:loading=
"loadingBtn"
type=
"primary"
@
click=
"submitReply(item.id)"
@
click=
"submitReply(item.id
, index
)"
class=
"px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200"
>
发表
</el-button
>
...
...
@@ -204,6 +208,7 @@
v-model:inputImg=
"bottomImgUrl"
:textAreaHeight=
"20"
:placeholder=
"`回复 ${parentComment?.replyUser}`"
ref=
"commentBoxRef"
/>
<el-button
...
...
@@ -237,6 +242,7 @@ import { parseEmoji } from '@/utils/emoji'
import
CommentBox
from
'../CommentBox/index.vue'
import
dayjs
from
'dayjs'
import
{
push
}
from
'notivue'
import
{
IS_REAL_KEY_COMMENT
}
from
'@/constants/symbolKey'
const
{
articleId
,
pid
}
=
defineProps
<
{
articleId
:
number
...
...
@@ -247,6 +253,8 @@ const emit = defineEmits<{
(
e
:
'refresh'
):
void
// 通知父组件刷新
}
>
()
provide
(
IS_REAL_KEY_COMMENT
,
BooleanFlag
.
YES
)
// Store
const
userStore
=
useUserStore
()
const
{
userInfo
}
=
storeToRefs
(
userStore
)
...
...
@@ -262,8 +270,8 @@ const currentInlineReplyId = ref<number | null>(null)
const
bottomCommentContent
=
ref
(
''
)
const
bottomImgUrl
=
ref
(
''
)
const
bottomLoadingBtn
=
ref
(
false
)
const
replyToOtherBoxRefList
=
ref
<
HTMLElement
[]
>
([])
const
replyToOtherBoxRefList
=
ref
<
InstanceType
<
typeof
CommentBox
>
[]
>
([])
const
commentBoxRef
=
useTemplateRef
<
InstanceType
<
typeof
CommentBox
>>
(
'commentBoxRef'
)
const
scrollContainerRef
=
useTemplateRef
(
'scrollContainer'
)
const
commentStr
=
ref
(
''
)
...
...
@@ -318,7 +326,7 @@ const handleReplyInline = (item: CommentItemDto, index: number) => {
// 提交评论 (共用逻辑)
// targetId: 如果是回复父评论,传 parentComment.id;如果是回复子评论,传 item.id
const
submitReply
=
async
(
targetId
:
number
|
undefined
)
=>
{
const
submitReply
=
async
(
targetId
:
number
|
undefined
,
index
?:
number
)
=>
{
if
(
!
targetId
)
return
// 判断使用的是哪个输入框的内容
...
...
@@ -326,16 +334,27 @@ const submitReply = async (targetId: number | undefined) => {
const
content
=
isBottom
?
bottomCommentContent
.
value
:
commentStr
.
value
const
imgStr
=
isBottom
?
bottomImgUrl
.
value
:
imgUrl
.
value
try
{
let
mentionUserIdList
:
string
|
undefined
=
undefined
let
contentHtml
:
string
|
undefined
=
undefined
if
(
isBottom
)
{
bottomLoadingBtn
.
value
=
true
mentionUserIdList
=
commentBoxRef
.
value
?.
getMentionFns
?.()?.
getMentionUserIds
?.()?.
join
?.(
','
)
contentHtml
=
commentBoxRef
.
value
?.
getAnswerHtml
?.()
}
else
{
loadingBtn
.
value
=
true
mentionUserIdList
=
replyToOtherBoxRefList
.
value
[
index
!
]
?.
getMentionFns
?.()
?.
getMentionUserIds
?.()
?.
join
?.(
','
)
contentHtml
=
replyToOtherBoxRefList
.
value
[
index
!
]?.
getAnswerHtml
?.()
}
await
addComment
({
articleId
:
articleId
,
content
:
content
,
pid
:
targetId
,
// 这里的pid逻辑根据您的后端接口来,通常回复子评论也是传该子评论ID作为pid
imgUrl
:
imgStr
,
mentionUserIdList
,
contentHtml
,
})
push
.
success
(
'回复成功'
)
...
...
@@ -389,6 +408,10 @@ const handleLike = async (item: CommentItemDto) => {
}
}
const
closeDialog
=
()
=>
{
visible
.
value
=
false
}
defineExpose
({
open
,
})
...
...
src/components/common/MentionBox/index.vue
0 → 100644
View file @
9d2e1e9c
<
script
lang=
"tsx"
>
import
{
type
VNode
,
cloneVNode
,
inject
,
nextTick
,
ref
,
render
,
}
from
'vue'
import
getCaretCoordinates
from
'textarea-caret'
import
MentionList
from
'../MentionList/index.vue'
import
type
{
AtUserInfoDto
}
from
'@/api/article/types'
import
{
MENTION_USER_FN_KEY
,
IS_REAL_KEY_COMMENT
}
from
'@/constants/symbolKey'
type
SlotModelAccessor
=
{
getValue
:
()
=>
string
setValue
:
(
v
:
string
)
=>
void
}
const
extractModelAccessor
=
(
vnode
:
VNode
):
SlotModelAccessor
|
null
=>
{
const
props
=
vnode
.
props
if
(
!
props
)
return
null
const
updater
=
props
[
'onUpdate:modelValue'
]
as
((
v
:
string
)
=>
void
)
|
undefined
if
(
!
updater
)
return
null
const
dirs
=
(
vnode
as
any
).
dirs
as
{
dir
:
any
;
value
:
any
}[]
|
undefined
const
modelDir
=
dirs
?.
find
((
d
)
=>
d
.
dir
?.
mounted
&&
d
.
dir
?.
beforeUpdate
)
return
{
getValue
:
()
=>
(
modelDir
?.
value
as
string
)
??
(
props
.
value
as
string
)
??
''
,
setValue
:
(
v
:
string
)
=>
updater
(
v
),
}
}
export
default
{
name
:
'MentionBox'
,
setup
(
_
,
{
slots
})
{
const
isReal
=
inject
(
IS_REAL_KEY_COMMENT
)
!
const
renderBoxRef
=
useTemplateRef
<
HTMLDivElement
>
(
'renderBoxRef'
)
let
cursorPos
=
0
let
accessor
:
SlotModelAccessor
|
null
=
null
const
mentionUsers
=
ref
<
Array
<
{
userId
:
string
;
name
:
string
}
>>
([])
const
mousePosition
=
ref
({
x
:
0
,
y
:
0
})
const
escapeRegExp
=
(
str
:
string
)
=>
str
.
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
)
const
existsMentionToken
=
(
text
:
string
,
name
:
string
)
=>
{
const
pattern
=
new
RegExp
(
`(^|\\s)@
${
escapeRegExp
(
name
)}
(?=\\s|$)`
)
return
pattern
.
test
(
text
)
}
const
syncMentionUsersWithText
=
(
text
:
string
)
=>
{
mentionUsers
.
value
=
mentionUsers
.
value
.
filter
((
u
)
=>
existsMentionToken
(
text
,
u
.
name
))
}
const
updateMentionPanelPosition
=
(
textarea
:
HTMLTextAreaElement
,
caretIndex
:
number
)
=>
{
const
{
left
,
top
}
=
getCaretCoordinates
(
textarea
,
caretIndex
)
mousePosition
.
value
=
{
x
:
left
-
18
,
y
:
top
+
30
}
}
/** 光标是否在「可弹出选人」的 @ 片段末尾(@ 后可为空或正在输入关键词) */
const
shouldOpenMentionAtCursor
=
(
beforeCursor
:
string
)
=>
{
if
(
!
/@
([^\s
@
]
*
)
$/
.
test
(
beforeCursor
))
return
false
const
atIndex
=
beforeCursor
.
lastIndexOf
(
'@'
)
if
(
atIndex
<
0
)
return
false
if
(
atIndex
===
0
)
return
true
const
prev
=
beforeCursor
.
charAt
(
atIndex
-
1
)
if
(
prev
===
'@'
)
return
false
if
(
prev
&&
/
\s
/
.
test
(
prev
))
return
true
// 非空白、非连续 @ 的前缀(如 12@、ca1@)也应能触发,旧版 (^|\\s)@ 会漏掉
return
true
}
const
showMentionList
=
()
=>
{
render
(
<
MentionList
mousePosition
=
{
mousePosition
.
value
}
onMention
=
{
handleMention
}
isReal
=
{
unref
(
isReal
)}
/>
,
renderBoxRef
.
value
as
HTMLElement
,
)
}
const
handleInput
=
(
e
:
Event
)
=>
{
const
textarea
=
e
.
target
as
HTMLTextAreaElement
cursorPos
=
textarea
.
selectionStart
syncMentionUsersWithText
(
textarea
.
value
)
const
beforeCursor
=
textarea
.
value
.
slice
(
0
,
cursorPos
)
if
(
shouldOpenMentionAtCursor
(
beforeCursor
))
{
updateMentionPanelPosition
(
textarea
,
cursorPos
)
showMentionList
()
}
}
const
findMentionRangeByIndex
=
(
text
:
string
,
index
:
number
)
=>
{
if
(
index
<
0
||
index
>=
text
.
length
)
return
null
for
(
const
u
of
mentionUsers
.
value
)
{
const
token
=
`@
${
u
.
name
}
`
let
start
=
text
.
indexOf
(
token
)
while
(
start
!==
-
1
)
{
const
end
=
start
+
token
.
length
const
prev
=
start
>
0
?
text
.
charAt
(
start
-
1
)
:
''
const
next
=
end
<
text
.
length
?
text
.
charAt
(
end
)
:
''
const
boundaryOk
=
(
!
prev
||
/
\s
/
.
test
(
prev
))
&&
(
!
next
||
/
\s
/
.
test
(
next
))
if
(
boundaryOk
&&
index
>=
start
&&
index
<
end
)
{
return
{
start
,
end
}
}
start
=
text
.
indexOf
(
token
,
start
+
token
.
length
)
}
}
return
null
}
const
handleKeydown
=
(
e
:
KeyboardEvent
)
=>
{
if
(
!
accessor
)
return
if
(
e
.
key
!==
'Backspace'
&&
e
.
key
!==
'Delete'
)
return
const
textarea
=
e
.
target
as
HTMLTextAreaElement
const
value
=
accessor
.
getValue
()
const
caret
=
textarea
.
selectionStart
const
targetIndex
=
e
.
key
===
'Backspace'
?
caret
-
1
:
caret
const
mentionRange
=
findMentionRangeByIndex
(
value
,
targetIndex
)
if
(
!
mentionRange
)
return
e
.
preventDefault
()
const
nextValue
=
value
.
slice
(
0
,
mentionRange
.
start
)
+
value
.
slice
(
mentionRange
.
end
)
accessor
.
setValue
(
nextValue
)
syncMentionUsersWithText
(
nextValue
)
nextTick
(()
=>
{
const
nextCaret
=
mentionRange
.
start
textarea
.
setSelectionRange
(
nextCaret
,
nextCaret
)
cursorPos
=
nextCaret
})
}
const
handleMention
=
(
item
:
AtUserInfoDto
)
=>
{
if
(
!
accessor
)
return
const
value
=
accessor
.
getValue
()
const
beforeAt
=
value
.
slice
(
0
,
cursorPos
).
replace
(
/@
([^\s
@
]
*
)
$/
,
''
)
const
after
=
value
.
slice
(
cursorPos
)
const
nextValue
=
`
${
beforeAt
}
@
${
item
.
name
}
${
after
}
`
accessor
.
setValue
(
nextValue
)
if
(
!
mentionUsers
.
value
.
some
((
u
)
=>
String
(
u
.
userId
)
===
String
(
item
.
userId
)))
{
mentionUsers
.
value
.
push
({
userId
:
String
(
item
.
userId
),
name
:
item
.
name
,
})
}
syncMentionUsersWithText
(
nextValue
)
}
const
mentionFnObj
=
inject
(
MENTION_USER_FN_KEY
)
if
(
mentionFnObj
)
{
mentionFnObj
.
getMentionUserIds
=
()
=>
mentionUsers
.
value
.
map
((
u
)
=>
u
.
userId
)
mentionFnObj
.
getMentionUsers
=
()
=>
mentionUsers
.
value
.
map
((
u
)
=>
({
...
u
}))
}
return
()
=>
{
const
raw
=
slots
.
default
?.()[
0
]
as
VNode
if
(
!
raw
)
return
null
accessor
=
extractModelAccessor
(
raw
)
const
wrapped
=
cloneVNode
(
raw
,
{
onInput
:
handleInput
,
onKeydown
:
handleKeydown
})
return
<
div
ref
=
'renderBoxRef'
class
=
"relative"
>
{
wrapped
}
<
/div
>
}
},
}
</
script
>
<
style
scoped
lang=
"scss"
></
style
>
src/components/common/MentionList/index.vue
0 → 100644
View file @
9d2e1e9c
<
script
setup
lang=
"ts"
>
import
{
getAtUserList
}
from
'@/api'
import
{
usePageSearch
}
from
'@/hooks'
import
type
{
AtUserInfoDto
}
from
'@/api/article/types'
import
{
BooleanFlag
}
from
'@/constants'
const
show
=
defineModel
<
boolean
>
()
const
{
mousePosition
=
{
x
:
10
,
y
:
10
},
isReal
}
=
defineProps
<
{
mousePosition
:
{
x
:
number
y
:
number
}
isReal
:
BooleanFlag
}
>
()
const
emit
=
defineEmits
<
{
mention
:
[
item
:
AtUserInfoDto
]
}
>
()
const
{
list
,
searchParams
,
total
,
goToPage
,
changePageSize
,
loading
}
=
usePageSearch
(
getAtUserList
,
{
defaultParams
:
{
current
:
1
,
size
:
10
,
findType
:
isReal
===
BooleanFlag
.
YES
?
0
:
1
,
},
},
)
// const list = [
// {
// id: 1,
// name: '李家彬',
// dept: '市场部',
// avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=li',
// },
// {
// id: 2,
// name: '王小雨',
// dept: '产品部',
// avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=wang',
// },
// {
// id: 3,
// name: '赵一鸣',
// dept: '技术部',
// avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=zhao',
// },
// {
// id: 4,
// name: '刘可可',
// dept: '运营部',
// avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=liu',
// },
// ]
watch
(
show
,
(
visible
)
=>
{
if
(
visible
)
{
document
.
addEventListener
(
'mousedown'
,
handleDocumentPointerDown
)
}
else
{
document
.
removeEventListener
(
'mousedown'
,
handleDocumentPointerDown
)
}
})
const
handleDocumentPointerDown
=
()
=>
{
show
.
value
=
false
}
const
handlePick
=
(
item
:
AtUserInfoDto
)
=>
{
show
.
value
=
false
emit
(
'mention'
,
item
)
}
onUpdated
(()
=>
{
show
.
value
=
true
})
onMounted
(()
=>
{
show
.
value
=
true
console
.
log
(
'onMounted'
)
})
onBeforeUnmount
(()
=>
{
console
.
log
(
'onBeforeUnmount'
)
document
.
removeEventListener
(
'mousedown'
,
handleDocumentPointerDown
)
})
defineExpose
({
show
:
()
=>
{
show
.
value
=
true
},
hide
:
()
=>
{
show
.
value
=
false
},
})
</
script
>
<
template
>
<transition
name=
"fade1"
>
<div
v-if=
"show"
class=
"absolute mention-panel z-[3000] w-60 rounded-xl border border-slate-200 bg-white p-2 shadow-[0_10px_26px_rgba(2,6,23,0.15)]"
:style=
"
{ left: mousePosition.x + 'px', top: mousePosition.y + 'px' }"
@mousedown.stop
>
<div
class=
"mb-1 flex items-center gap-1 border-b border-slate-100 pb-2"
>
<el-input
v-model=
"searchParams.findValue"
placeholder=
"搜索用户名"
size=
"small"
clearable
class=
"min-w-0 flex-1"
@
keyup
.
enter=
"goToPage(1)"
/>
<el-button
type=
"primary"
size=
"small"
:disabled=
"loading"
@
click=
"goToPage(1)"
>
搜索
</el-button>
<el-pagination
v-model:current-page=
"searchParams.current"
v-model:page-size=
"searchParams.size"
:total=
"total"
size=
"small"
class=
"shrink-0 !p-0"
layout=
"prev, slot, next"
@
size-change=
"changePageSize"
@
current-change=
"goToPage"
>
<span
class=
"text-xs text-slate-400"
>
{{
searchParams
.
current
}}
</span>
</el-pagination>
</div>
<el-scrollbar
class=
"h-64"
v-loading=
"loading"
>
<button
v-for=
"item in list"
:key=
"item.userId"
type=
"button"
class=
"cursor-pointer flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors hover:bg-blue-50"
@
click=
"handlePick(item)"
>
<img
:src=
"item.avatar"
:alt=
"item.name"
class=
"h-8 w-8 rounded-full border border-slate-200"
/>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"truncate text-sm font-medium text-slate-800"
>
{{
item
.
name
}}
</div>
</div>
<span
v-if=
"isReal"
class=
"text-xs text-blue-500"
>
@
{{
item
.
name
}}
</span>
</button>
</el-scrollbar>
</div>
</transition>
</
template
>
<
style
scoped
>
.mention-panel
::before
{
position
:
absolute
;
top
:
-6px
;
left
:
16px
;
width
:
12px
;
height
:
12px
;
border-top
:
1px
solid
#e2e8f0
;
border-left
:
1px
solid
#e2e8f0
;
background
:
#fff
;
content
:
''
;
transform
:
rotate
(
45deg
);
}
.fade1-enter-active
,
.fade1-leave-active
{
transition
:
opacity
0.2s
ease
,
transform
0.2s
ease
;
}
.fade1-enter-from
,
.fade1-leave-to
{
opacity
:
0
;
transform
:
translateY
(
6px
);
}
</
style
>
src/components/common/PublishBox/index.vue
View file @
9d2e1e9c
...
...
@@ -52,6 +52,7 @@
<!-- 主要内容输入 -->
<div
class=
"relative mb-3"
>
<RichTextarea
:showMention=
"false"
:placeholder=
"textMap[type].content"
:maxlength=
"maxLength"
:imgList=
"imgList"
...
...
src/components/common/RichTextarea/index.vue
View file @
9d2e1e9c
<
script
setup
lang=
"ts"
>
// 展示一个textarea 里面可展示图片等(暂时只加入了 图片 后续若有其他的 再添加)
// 暂时用到了快捷发布问吧 和 发布实践 以及 评论相关的内容
import
MentionBox
from
'../MentionBox/index.vue'
import
{
MENTION_USER_FN_KEY
}
from
'@/constants/symbolKey'
// console.log(VueTribute, 'VueTribute')
interface
RichTextareaProps
{
placeholder
?:
string
maxlength
?:
number
imgList
:
string
[]
uploadPercent
:
number
height
?:
number
showMention
?:
boolean
}
interface
Emits
{
deleteImg
:
[
img
:
string
]
...
...
@@ -19,11 +22,72 @@ const {
imgList
,
uploadPercent
,
height
=
55
,
showMention
=
true
,
}
=
defineProps
<
RichTextareaProps
>
()
const
emit
=
defineEmits
<
Emits
>
()
const
inputStr
=
defineModel
<
string
>
({
required
:
true
})
const
textareaRef
=
useTemplateRef
<
HTMLTextAreaElement
>
(
'textareaRef'
)
const
highlightRef
=
useTemplateRef
<
HTMLElement
>
(
'highlightRef'
)
const
isComposing
=
ref
(
false
)
const
mentionFns
=
inject
<
{
getMentionUsers
?:
()
=>
Array
<
{
userId
:
string
;
name
:
string
}
>
}
>
(
MENTION_USER_FN_KEY
,
{})
const
escapeHtml
=
(
str
:
string
)
=>
str
.
replaceAll
(
'&'
,
'&'
)
.
replaceAll
(
'<'
,
'<'
)
.
replaceAll
(
'>'
,
'>'
)
.
replaceAll
(
'"'
,
'"'
)
.
replaceAll
(
"'"
,
'''
)
const
highlightedHtml
=
computed
(()
=>
{
const
source
=
inputStr
.
value
||
''
const
mentions
=
(
mentionFns
?.
getMentionUsers
?.()
??
[])
.
filter
((
u
)
=>
u
?.
name
)
.
sort
((
a
,
b
)
=>
b
.
name
.
length
-
a
.
name
.
length
)
.
map
((
u
)
=>
`@
${
u
.
name
}
`
)
const
isBoundary
=
(
ch
:
string
|
undefined
)
=>
!
ch
||
/
\s
/
.
test
(
ch
)
let
html
=
''
let
i
=
0
while
(
i
<
source
.
length
)
{
const
prev
=
i
>
0
?
source
[
i
-
1
]
:
undefined
const
token
=
mentions
.
find
((
item
)
=>
{
if
(
!
source
.
startsWith
(
item
,
i
))
return
false
const
next
=
source
[
i
+
item
.
length
]
return
isBoundary
(
prev
)
&&
isBoundary
(
next
)
})
if
(
token
)
{
html
+=
`<span class="mention-token">
${
escapeHtml
(
token
)}
</span>`
i
+=
token
.
length
continue
}
html
+=
escapeHtml
(
source
.
charAt
(
i
))
i
++
}
return
html
})
const
syncHighlightScroll
=
(
e
:
Event
)
=>
{
const
textarea
=
e
.
target
as
HTMLTextAreaElement
if
(
!
highlightRef
.
value
)
return
highlightRef
.
value
.
scrollTop
=
textarea
.
scrollTop
highlightRef
.
value
.
scrollLeft
=
textarea
.
scrollLeft
}
const
handleCompositionStart
=
()
=>
{
isComposing
.
value
=
true
}
const
handleCompositionEnd
=
()
=>
{
isComposing
.
value
=
false
}
defineExpose
({
getTextarea
:
()
=>
textareaRef
.
value
,
})
...
...
@@ -34,15 +98,44 @@ defineExpose({
style=
"border: 1px solid rgb(229, 231, 235)"
class=
"relative w-full rounded-lg border border-gray-200 px-3 py-2 transition focus-within:border-[var(--el-color-primary)] focus-within:ring-1 focus-within:ring-[var(--el-color-primary)] bg-white"
>
<!-- 文本输入区 -->
<textarea
ref=
"textareaRef"
v-model=
"inputStr"
:placeholder=
"placeholder"
class=
"w-full resize-none border-none outline-none text-sm leading-5 text-gray-800"
:style=
"
{ height: height + 'px' }"
:maxlength="maxlength"
/>
<div
class=
"relative"
>
<template
v-if=
"showMention"
>
<div
ref=
"highlightRef"
aria-hidden=
"true"
class=
"pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words text-sm leading-5"
:style=
"
{ height: height + 'px' }"
:class="{ invisible: isComposing }"
>
<div
class=
"w-full text-gray-800"
v-html=
"highlightedHtml || ' '"
></div>
</div>
<MentionBox>
<textarea
ref=
"textareaRef"
v-model=
"inputStr"
:placeholder=
"placeholder"
class=
"w-full resize-none border-none bg-transparent outline-none text-sm leading-5 placeholder:text-gray-400 caret-gray-800"
:class=
"isComposing ? 'text-gray-800' : 'text-transparent'"
:style=
"
{ height: height + 'px' }"
:maxlength="maxlength"
@scroll="syncHighlightScroll"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
</MentionBox>
</
template
>
<
template
v-else
>
<!-- 无镜像高亮层时不能 text-transparent,否则正文不可见 -->
<textarea
ref=
"textareaRef"
v-model=
"inputStr"
:placeholder=
"placeholder"
class=
"w-full resize-none border-none bg-transparent outline-none text-sm leading-5 text-gray-800 placeholder:text-gray-400 caret-gray-800"
:style=
"
{ height: height + 'px' }"
:maxlength="maxlength"
/>
</
template
>
</div>
<!-- 定位到右边 -->
<span
v-if=
"maxlength"
class=
"flex justify-end text-xs text-gray-400"
>
{{ inputStr?.length }} / {{ maxlength }}
...
...
@@ -79,7 +172,14 @@ defineExpose({
</div>
</div>
</div>
<!-- <MentionList ref="mentionListRef" :mouse-position="mousePosition" @mention="handleMention" /> -->
</div>
</template>
<
style
scoped
lang=
"scss"
></
style
>
<
style
scoped
lang=
"scss"
>
:deep
(
.mention-token
)
{
color
:
#2563eb
;
background
:
rgba
(
37
,
99
,
235
,
0.1
);
border-radius
:
2px
;
}
</
style
>
src/components/common/SendMessageDialog/index.vue
View file @
9d2e1e9c
...
...
@@ -16,6 +16,7 @@
placeholder=
"输入私信内容…"
v-model:inputText=
"form.content"
v-model:inputImg=
"form.images"
:showMention=
"false"
>
<template
#
submit
>
<el-button
...
...
src/constants/symbolKey.ts
View file @
9d2e1e9c
import
type
{
InjectionKey
,
Ref
}
from
'vue'
import
type
{
InjectionKey
,
Ref
,
MaybeRef
}
from
'vue'
import
type
{
BooleanFlag
}
from
'./enums'
export
const
TABS_REF_KEY
=
Symbol
(
'tabsRef'
)
as
InjectionKey
<
Ref
<
HTMLElement
|
null
>>
export
const
COMMENT_REF_KEY
=
Symbol
(
'commentRef'
)
as
InjectionKey
<
Ref
<
HTMLElement
|
null
>>
export
const
IS_REAL_KEY
=
Symbol
(
'isReal'
)
as
InjectionKey
<
Ref
<
number
>>
// 获取at用户相关函数的key
export
const
MENTION_USER_FN_KEY
=
Symbol
(
'mentionUserFn'
)
as
InjectionKey
<
{
getMentionUserIds
?:
()
=>
string
[]
getMentionUsers
?:
()
=>
Array
<
{
userId
:
string
;
name
:
string
}
>
}
>
// 是否是实名 评论相关
export
const
IS_REAL_KEY_COMMENT
=
Symbol
(
'isRealComment'
)
as
InjectionKey
<
MaybeRef
<
BooleanFlag
>>
src/utils/emoji/index.ts
View file @
9d2e1e9c
import
emojis
from
'./face.json'
function
escapeHTML
(
str
:
string
)
{
return
str
.
replace
(
/&/g
,
'&'
).
replace
(
/</g
,
'<'
).
replace
(
/>/g
,
'>'
)
}
//
function escapeHTML(str: string) {
//
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
//
}
export
const
parseEmoji
=
(
content
:
string
)
=>
{
if
(
!
content
)
return
''
let
html
=
escapeHTML
(
content
)
// let html = escapeHTML(content)
let
html
=
content
emojis
.
forEach
((
item
)
=>
{
const
escapedName
=
item
.
name
.
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
)
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment