Commit 5d676393 by lijiabin

【需求 21402】 feat: 完成私信相关的功能

parent 674789ad
......@@ -5,6 +5,7 @@ import type {
ConversationResponseDto,
GetMessageDetailListDto,
MessageDetailListItem,
DeleteMessageDto,
} from './types'
// 关于私信的相关接口
......@@ -37,3 +38,12 @@ export const getMessageDetailList = (data: GetMessageDetailListDto) => {
data,
})
}
// 删除某个私信
export const deleteMessage = (data: DeleteMessageDto) => {
return service.request({
url: '/api/cultureDialog/deleteMessage',
method: 'POST',
data,
})
}
......@@ -78,3 +78,13 @@ export interface MessageDetailListItem {
is_self: boolean
image_list: string[]
}
/** 删除私信:会话或单条消息 */
export type DeleteCultureMessageKind = 'user' | 'message'
export interface DeleteMessageDto {
/** user:按会话删除;message:按消息删除 */
type: DeleteCultureMessageKind
/** 会话 id 列表或消息 id 列表 */
idList: number[]
}
......@@ -4,13 +4,17 @@ import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
import BackButton from '@/components/common/BackButton/index.vue'
import type { ScrollbarInstance } from 'element-plus'
import { getMessageList, sendMessage, getMessageDetailList } from '@/api'
import { getMessageList, sendMessage, getMessageDetailList, deleteMessage } from '@/api'
import type { ConversationItem, MessageDetailListItem } from '@/api/selfMessage/types'
import { parseEmoji } from '@/utils/emoji'
import { push } from 'notivue'
import { BooleanFlag } from '@/constants'
import { useMessageBox } from '@/hooks'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const route = useRoute()
const { confirm } = useMessageBox()
const categoryTabs = [
{
......@@ -71,10 +75,9 @@ const filteredConversationList = computed(() => {
})
const activeConversation = computed(() => {
const current = filteredConversationList.value.find(
(item) => item.id === activeConversationId.value,
return (
filteredConversationList.value.find((item) => item.id === activeConversationId.value) ?? null
)
return current || filteredConversationList.value[0] || null
})
const conversationStats = computed(() => ({
......@@ -98,47 +101,45 @@ watch(filteredConversationList, (list) => {
return
}
if (!list.some((item) => item.id === activeConversationId.value) && list[0]) {
activeConversationId.value = list[0].id
// 仅当当前选中 id 已不在列表中时才清空,避免进入页面时自动选中第一项
if (
activeConversationId.value !== 0 &&
!list.some((item) => item.id === activeConversationId.value)
) {
activeConversationId.value = 0
messageDetailList.value = []
}
})
watch(
() => activeConversation.value?.id,
async (id) => {
if (!id) return
const current = conversationList.value.find((item) => item.id === id)
if (current) current.un_read_count = 0
if (!messageDetailList.value.length && current) {
await selectConversation(current)
}
await nextTick()
scrollToBottom()
},
)
const messageDetailList = ref<MessageDetailListItem[]>([])
const selectConversation = async (item: ConversationItem) => {
/** 拉取会话详情并滚动到底;不要在 watch(activeConversation) 里再调,否则会与点击逻辑并发请求两次 */
const selectConversation = async (item: ConversationItem, shouldScrollToBottom = true) => {
activeConversationId.value = item.id
const { data } = await getMessageDetailList({
receiverId: item.other_user_id,
dialogId: item.id,
chatType: activeCategory.value,
})
messageDetailList.value = (data || []).map((message) => {
const imageList = Array.isArray(message.image_list)
? message.image_list
: message.images
? message.images.split(',').filter(Boolean)
: []
return {
...message,
image_list: imageList,
}
})
messageDetailList.value = data || []
if (shouldScrollToBottom) {
await nextTick()
scrollToBottom()
}
}
watch(
() => activeConversation.value?.id,
(id) => {
if (!id) return
const current = conversationList.value.find((item) => item.id === id)
if (current) {
current.un_read_count = 0
current.had_read = BooleanFlag.YES
}
},
)
const switchCategory = (category: BooleanFlag) => {
if (activeCategory.value === category) return
activeCategory.value = category
......@@ -179,8 +180,6 @@ const handleSend = async () => {
draftText.value = ''
draftImages.value = ''
await selectConversation(activeConversation.value)
await nextTick()
scrollToBottom()
} finally {
sending.value = false
}
......@@ -193,6 +192,43 @@ const handleInputKeydown = (event: KeyboardEvent) => {
}
}
const onConversationMenu = async (cmd: string | number, item: ConversationItem) => {
if (cmd !== 'delete') return
await confirm({
title: '删除会话',
message: '确定删除该会话?删除后聊天记录将不可恢复。',
confirmText: '删除',
cancelText: '取消',
type: 'warning',
})
try {
await deleteMessage({ type: 'user', idList: [item.id] })
conversationList.value = conversationList.value.filter((c) => c.id !== item.id)
// 选中会话被删时,由 watch(filteredConversationList) 把 activeConversationId 置 0 并清空 messageDetailList
push.success('已删除会话')
} catch {
/* 失败时请求封装已 toast */
}
}
const onMessageMenu = async (cmd: string | number, message: MessageDetailListItem) => {
if (cmd !== 'delete') return
if (!message.is_self) return
await confirm({
title: '删除消息',
message: '确定删除这条消息?',
confirmText: '删除',
cancelText: '取消',
type: 'warning',
})
await deleteMessage({ type: 'message', idList: [message.message_id] })
// await refreshConversationList()
if (activeConversation.value) await selectConversation(activeConversation.value, false)
push.success('已删除消息')
}
onActivated(async () => {
applyCategoryFromRoute()
await refreshConversationList()
......@@ -257,52 +293,71 @@ onActivated(async () => {
<div class="min-h-0 flex-1">
<el-scrollbar>
<div v-if="filteredConversationList.length" class="space-y-3">
<button
<el-dropdown
v-for="item in filteredConversationList"
:key="item.id"
class="cursor-pointer w-full rounded-lg border p-3 text-left transition-all"
:class="
activeConversation?.id === item.id
? 'border-[#cbdcff] bg-[linear-gradient(180deg,#f6faff_0%,#edf5ff_100%)] shadow-[0_0.5rem_1.125rem_rgba(134,167,207,0.15)]'
: 'border-transparent bg-transparent hover:border-[#dde8f3] hover:bg-[#f9fbfe]'
"
@click="selectConversation(item)"
class="block w-full"
trigger="contextmenu"
@command="(cmd) => onConversationMenu(cmd, item)"
>
<div class="flex gap-3">
<div class="relative shrink-0">
<el-avatar :size="46" :src="item.other_user_avatar" />
</div>
<button
type="button"
class="cursor-pointer w-full rounded-lg border p-3 text-left transition-all"
:class="
activeConversation?.id === item.id
? 'border-[#cbdcff] bg-[linear-gradient(180deg,#f6faff_0%,#edf5ff_100%)] shadow-[0_0.5rem_1.125rem_rgba(134,167,207,0.15)]'
: 'border-transparent bg-transparent hover:border-[#dde8f3] hover:bg-[#f9fbfe]'
"
@click="selectConversation(item)"
>
<div class="flex gap-3">
<div class="relative shrink-0">
<el-avatar :size="46" :src="item.other_user_avatar" />
<span
v-if="Number(item.had_read) === BooleanFlag.NO"
class="pointer-events-none absolute right-0 top-0 h-2.5 w-2.5 rounded-full border-2 border-white bg-[#f56c6c] shadow-[0_0_0_1px_rgba(245,108,108,0.35)]"
title="未读"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<span class="max-w-[6.875rem] truncate text-4 font-700 text-[#17305d]">
{{ item.other_user_name }}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<span class="max-w-[6.875rem] truncate text-4 font-700 text-[#17305d]">
{{ item.other_user_name }}
</span>
</div>
<span class="shrink-0 text-3 text-[#8ca0b8]">{{
item.last_time_str
}}</span>
</div>
<span class="shrink-0 text-3 text-[#8ca0b8]">{{ item.last_time_str }}</span>
</div>
<div class="mt-2 flex min-w-0 items-center gap-2">
<span
class="inline-flex h-5 shrink-0 items-center rounded-full bg-[#fff3dc] px-2 text-3 text-[#9a6817]"
>
{{ item.chat_type_desc }}
</span>
<span
class="truncate text-base text-[#7890aa]"
v-html="parseEmoji(item.last_message)"
></span>
<span
v-if="item.un_read_count > 0"
class="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-[#ffebeb] px-1.5 text-3 font-700 text-[#df3e3e]"
>
{{ item.un_read_count > 99 ? '99+' : item.un_read_count }}
</span>
<div class="mt-2 flex min-w-0 items-center gap-2">
<span
class="inline-flex h-5 shrink-0 items-center rounded-full bg-[#fff3dc] px-2 text-3 text-[#9a6817]"
>
{{ item.chat_type_desc }}
</span>
<span
class="truncate text-base text-[#7890aa]"
v-html="parseEmoji(item.last_message)"
></span>
<span
v-if="item.un_read_count > 0"
class="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-[#ffebeb] px-1.5 text-3 font-700 text-[#df3e3e]"
>
{{ item.un_read_count > 99 ? '99+' : item.un_read_count }}
</span>
</div>
</div>
</div>
</div>
</button>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="delete"> 删除会话 </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-empty v-else description="没有找到匹配的会话" :image-size="90" />
......@@ -356,13 +411,10 @@ onActivated(async () => {
<el-empty description="没有找到匹配的会话" :image-size="90" />
</div>
<el-scrollbar v-show="messageDetailList.length" ref="conversationBoxRef">
<div
v-show="messageDetailList.length"
class="bg-[linear-gradient(180deg,#fcfdff_0%,#f8fbff_100%)] px-5 py-5"
>
<div v-show="messageDetailList.length" class="px-5 py-5">
<div v-for="message in messageDetailList" :key="message.message_id" class="pb-3">
<div
class="mb-4 flex items-start gap-3"
class="mb-4 flex w-full min-w-0 cursor-default items-start gap-3"
:class="{ 'flex-row-reverse': message.is_self }"
>
<el-avatar :size="38" :src="message.sender_avatar" class="shrink-0" />
......@@ -379,21 +431,42 @@ onActivated(async () => {
</div>
<div
class="rounded-lg border px-3 py-2 shadow-[0_0.125rem_0.5rem_rgba(39,74,120,0.04)]"
class="w-fit max-w-full rounded-lg px-3 py-2 shadow-[0_0.125rem_0.5rem_rgba(39,74,120,0.04)]"
:class="
message.is_self
? 'rounded-tr-[0.375rem] border-[#d4e4ff] bg-[#dbeaff] text-[#1a3f73]'
: 'rounded-tl-[0.375rem] border-[#e7edf5] bg-white text-[#23364c]'
? 'rounded-tr-[0.375rem] border border-[#d4e4ff] bg-[#dbeaff] text-[#1a3f73]'
: 'rounded-tl-[0.375rem] border border-[#d9e3ef] bg-[#f3f7fc] text-[#1f2f46]'
"
>
<el-dropdown
v-if="message.is_self"
class="inline-block max-w-full"
trigger="contextmenu"
@command="(cmd) => onMessageMenu(cmd, message)"
>
<p
v-if="message.content"
v-html="parseEmoji(message.content)"
class="cursor-pointer m-0 whitespace-pre-wrap break-words text-3.5 leading-[1.625rem]"
></p>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="delete"> 删除消息 </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<p
v-if="message.content"
v-else-if="message.content"
v-html="parseEmoji(message.content)"
class="m-0 whitespace-pre-wrap break-words text-3.5 leading-[1.625rem]"
></p>
<div
v-if="message.image_list?.length"
class="mt-2 grid gap-2 sm:grid-cols-2"
class="mt-2 flex flex-wrap gap-2"
:class="'justify-start'"
:style="{
width: message.image_list.length > 1 ? '15.5rem' : '7.5rem',
}"
>
<el-image
v-for="image in message.image_list"
......
......@@ -112,6 +112,12 @@
<component :is="item.icon" />
</el-icon>
<span class="text-sm">{{ item.label }}</span>
<el-badge
v-if="item.tab === '我的私信'"
:value="privateMessageUnreadDisplay > 99 ? '99+' : privateMessageUnreadDisplay"
:hidden="privateMessageUnreadDisplay <= 0"
class="ml-auto"
/>
</div>
</div>
<!-- 左侧菜单 —— 官方账号菜单 审核操作等 -->
......@@ -164,7 +170,7 @@
<script lang="tsx" setup>
import { storeToRefs } from 'pinia'
import EditUserInfo from './components/editUserInfo.vue'
import { generateLoginKey, hasOfficialAccount } from '@/api'
import { generateLoginKey, hasOfficialAccount, getMessageList } from '@/api'
import type { OfficialAccountItemDto } from '@/api/user/types'
import { wxLogin } from '@/utils/wxUtil'
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'
......@@ -198,6 +204,8 @@ const componentRef = useTemplateRef<{
const editUserInfoRef = useTemplateRef<InstanceType<typeof EditUserInfo>>('editUserInfoRef')
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const anonymousUnreadCount = ref(0)
const realUnreadCount = ref(0)
// 左侧普通用户菜单
const menuUserItems = [
......@@ -281,6 +289,11 @@ const menuOfficialItems = [
const isReal = ref<BooleanFlag>(BooleanFlag.NO)
/** 与私信页一致:实名 → realUnreadCount,匿名 → anonymousUnreadCount */
const privateMessageUnreadDisplay = computed(() =>
isReal.value === BooleanFlag.YES ? realUnreadCount.value : anonymousUnreadCount.value,
)
provide(IS_REAL_KEY, isReal)
watch(isReal, () => {
......@@ -321,6 +334,17 @@ const getIsOfficial = async () => {
officialAccountList.value = data
}
const getSelfMessageUnreadCount = async () => {
try {
const { data } = await getMessageList({})
anonymousUnreadCount.value = data?.anonymousUnreadCount ?? 0
realUnreadCount.value = data?.realUnreadCount ?? 0
} catch {
anonymousUnreadCount.value = 0
realUnreadCount.value = 0
}
}
const handleSwitchAccount = async () => {
const selectedEmail = ref('')
ElMessageBox({
......@@ -429,6 +453,10 @@ const handleClearCache = async () => {
onMounted(() => {
getIsOfficial()
})
onActivated(() => {
getSelfMessageUnreadCount()
})
</script>
<style scoped>
......
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