Commit 9d2e1e9c by lijiabin

【需求 21402】 feat: 完成评论区@功能

parent ddfca05a
......@@ -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
}
......@@ -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,
......
......@@ -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
}
......@@ -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 = ''
......
......@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/** 将纯文本中的 @昵称 转为带 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>&nbsp;`
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">
......
......@@ -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()
......
......@@ -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,
})
......
<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>
<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>
......@@ -52,6 +52,7 @@
<!-- 主要内容输入 -->
<div class="relative mb-3">
<RichTextarea
:showMention="false"
:placeholder="textMap[type].content"
:maxlength="maxLength"
:imgList="imgList"
......
<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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
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 || '&nbsp;'"></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>
......@@ -16,6 +16,7 @@
placeholder="输入私信内容…"
v-model:inputText="form.content"
v-model:inputImg="form.images"
:showMention="false"
>
<template #submit>
<el-button
......
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>>
import emojis from './face.json'
function escapeHTML(str: string) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// function escapeHTML(str: string) {
// return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// }
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, '\\$&')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment