Commit 5d676393 by lijiabin

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

parent 674789ad
...@@ -5,6 +5,7 @@ import type { ...@@ -5,6 +5,7 @@ import type {
ConversationResponseDto, ConversationResponseDto,
GetMessageDetailListDto, GetMessageDetailListDto,
MessageDetailListItem, MessageDetailListItem,
DeleteMessageDto,
} from './types' } from './types'
// 关于私信的相关接口 // 关于私信的相关接口
...@@ -37,3 +38,12 @@ export const getMessageDetailList = (data: GetMessageDetailListDto) => { ...@@ -37,3 +38,12 @@ export const getMessageDetailList = (data: GetMessageDetailListDto) => {
data, data,
}) })
} }
// 删除某个私信
export const deleteMessage = (data: DeleteMessageDto) => {
return service.request({
url: '/api/cultureDialog/deleteMessage',
method: 'POST',
data,
})
}
...@@ -78,3 +78,13 @@ export interface MessageDetailListItem { ...@@ -78,3 +78,13 @@ export interface MessageDetailListItem {
is_self: boolean is_self: boolean
image_list: string[] 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' ...@@ -4,13 +4,17 @@ import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import BackButton from '@/components/common/BackButton/index.vue' import BackButton from '@/components/common/BackButton/index.vue'
import type { ScrollbarInstance } from 'element-plus' 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 type { ConversationItem, MessageDetailListItem } from '@/api/selfMessage/types'
import { parseEmoji } from '@/utils/emoji' import { parseEmoji } from '@/utils/emoji'
import { push } from 'notivue'
import { BooleanFlag } from '@/constants' import { BooleanFlag } from '@/constants'
import { useMessageBox } from '@/hooks'
const userStore = useUserStore() const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore) const { userInfo } = storeToRefs(userStore)
const route = useRoute() const route = useRoute()
const { confirm } = useMessageBox()
const categoryTabs = [ const categoryTabs = [
{ {
...@@ -71,10 +75,9 @@ const filteredConversationList = computed(() => { ...@@ -71,10 +75,9 @@ const filteredConversationList = computed(() => {
}) })
const activeConversation = computed(() => { const activeConversation = computed(() => {
const current = filteredConversationList.value.find( return (
(item) => item.id === activeConversationId.value, filteredConversationList.value.find((item) => item.id === activeConversationId.value) ?? null
) )
return current || filteredConversationList.value[0] || null
}) })
const conversationStats = computed(() => ({ const conversationStats = computed(() => ({
...@@ -98,47 +101,45 @@ watch(filteredConversationList, (list) => { ...@@ -98,47 +101,45 @@ watch(filteredConversationList, (list) => {
return return
} }
if (!list.some((item) => item.id === activeConversationId.value) && list[0]) { // 仅当当前选中 id 已不在列表中时才清空,避免进入页面时自动选中第一项
activeConversationId.value = list[0].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 messageDetailList = ref<MessageDetailListItem[]>([])
const selectConversation = async (item: ConversationItem) => { /** 拉取会话详情并滚动到底;不要在 watch(activeConversation) 里再调,否则会与点击逻辑并发请求两次 */
const selectConversation = async (item: ConversationItem, shouldScrollToBottom = true) => {
activeConversationId.value = item.id activeConversationId.value = item.id
const { data } = await getMessageDetailList({ const { data } = await getMessageDetailList({
receiverId: item.other_user_id, receiverId: item.other_user_id,
dialogId: item.id, dialogId: item.id,
chatType: activeCategory.value, chatType: activeCategory.value,
}) })
messageDetailList.value = (data || []).map((message) => { messageDetailList.value = data || []
const imageList = Array.isArray(message.image_list) if (shouldScrollToBottom) {
? message.image_list await nextTick()
: message.images scrollToBottom()
? message.images.split(',').filter(Boolean)
: []
return {
...message,
image_list: imageList,
} }
})
} }
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) => { const switchCategory = (category: BooleanFlag) => {
if (activeCategory.value === category) return if (activeCategory.value === category) return
activeCategory.value = category activeCategory.value = category
...@@ -179,8 +180,6 @@ const handleSend = async () => { ...@@ -179,8 +180,6 @@ const handleSend = async () => {
draftText.value = '' draftText.value = ''
draftImages.value = '' draftImages.value = ''
await selectConversation(activeConversation.value) await selectConversation(activeConversation.value)
await nextTick()
scrollToBottom()
} finally { } finally {
sending.value = false sending.value = false
} }
...@@ -193,6 +192,43 @@ const handleInputKeydown = (event: KeyboardEvent) => { ...@@ -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 () => { onActivated(async () => {
applyCategoryFromRoute() applyCategoryFromRoute()
await refreshConversationList() await refreshConversationList()
...@@ -257,9 +293,15 @@ onActivated(async () => { ...@@ -257,9 +293,15 @@ onActivated(async () => {
<div class="min-h-0 flex-1"> <div class="min-h-0 flex-1">
<el-scrollbar> <el-scrollbar>
<div v-if="filteredConversationList.length" class="space-y-3"> <div v-if="filteredConversationList.length" class="space-y-3">
<button <el-dropdown
v-for="item in filteredConversationList" v-for="item in filteredConversationList"
:key="item.id" :key="item.id"
class="block w-full"
trigger="contextmenu"
@command="(cmd) => onConversationMenu(cmd, item)"
>
<button
type="button"
class="cursor-pointer w-full rounded-lg border p-3 text-left transition-all" class="cursor-pointer w-full rounded-lg border p-3 text-left transition-all"
:class=" :class="
activeConversation?.id === item.id activeConversation?.id === item.id
...@@ -271,6 +313,11 @@ onActivated(async () => { ...@@ -271,6 +313,11 @@ onActivated(async () => {
<div class="flex gap-3"> <div class="flex gap-3">
<div class="relative shrink-0"> <div class="relative shrink-0">
<el-avatar :size="46" :src="item.other_user_avatar" /> <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>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
...@@ -280,7 +327,9 @@ onActivated(async () => { ...@@ -280,7 +327,9 @@ onActivated(async () => {
{{ item.other_user_name }} {{ item.other_user_name }}
</span> </span>
</div> </div>
<span class="shrink-0 text-3 text-[#8ca0b8]">{{ item.last_time_str }}</span> <span class="shrink-0 text-3 text-[#8ca0b8]">{{
item.last_time_str
}}</span>
</div> </div>
<div class="mt-2 flex min-w-0 items-center gap-2"> <div class="mt-2 flex min-w-0 items-center gap-2">
...@@ -303,6 +352,12 @@ onActivated(async () => { ...@@ -303,6 +352,12 @@ onActivated(async () => {
</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> </div>
<el-empty v-else description="没有找到匹配的会话" :image-size="90" /> <el-empty v-else description="没有找到匹配的会话" :image-size="90" />
...@@ -356,13 +411,10 @@ onActivated(async () => { ...@@ -356,13 +411,10 @@ onActivated(async () => {
<el-empty description="没有找到匹配的会话" :image-size="90" /> <el-empty description="没有找到匹配的会话" :image-size="90" />
</div> </div>
<el-scrollbar v-show="messageDetailList.length" ref="conversationBoxRef"> <el-scrollbar v-show="messageDetailList.length" ref="conversationBoxRef">
<div <div v-show="messageDetailList.length" class="px-5 py-5">
v-show="messageDetailList.length"
class="bg-[linear-gradient(180deg,#fcfdff_0%,#f8fbff_100%)] px-5 py-5"
>
<div v-for="message in messageDetailList" :key="message.message_id" class="pb-3"> <div v-for="message in messageDetailList" :key="message.message_id" class="pb-3">
<div <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 }" :class="{ 'flex-row-reverse': message.is_self }"
> >
<el-avatar :size="38" :src="message.sender_avatar" class="shrink-0" /> <el-avatar :size="38" :src="message.sender_avatar" class="shrink-0" />
...@@ -379,21 +431,42 @@ onActivated(async () => { ...@@ -379,21 +431,42 @@ onActivated(async () => {
</div> </div>
<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=" :class="
message.is_self message.is_self
? 'rounded-tr-[0.375rem] border-[#d4e4ff] bg-[#dbeaff] text-[#1a3f73]' ? 'rounded-tr-[0.375rem] border border-[#d4e4ff] bg-[#dbeaff] text-[#1a3f73]'
: 'rounded-tl-[0.375rem] border-[#e7edf5] bg-white text-[#23364c]' : '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 <p
v-if="message.content" v-if="message.content"
v-html="parseEmoji(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-else-if="message.content"
v-html="parseEmoji(message.content)"
class="m-0 whitespace-pre-wrap break-words text-3.5 leading-[1.625rem]" class="m-0 whitespace-pre-wrap break-words text-3.5 leading-[1.625rem]"
></p> ></p>
<div <div
v-if="message.image_list?.length" 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 <el-image
v-for="image in message.image_list" v-for="image in message.image_list"
......
...@@ -112,6 +112,12 @@ ...@@ -112,6 +112,12 @@
<component :is="item.icon" /> <component :is="item.icon" />
</el-icon> </el-icon>
<span class="text-sm">{{ item.label }}</span> <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>
</div> </div>
<!-- 左侧菜单 —— 官方账号菜单 审核操作等 --> <!-- 左侧菜单 —— 官方账号菜单 审核操作等 -->
...@@ -164,7 +170,7 @@ ...@@ -164,7 +170,7 @@
<script lang="tsx" setup> <script lang="tsx" setup>
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import EditUserInfo from './components/editUserInfo.vue' 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 type { OfficialAccountItemDto } from '@/api/user/types'
import { wxLogin } from '@/utils/wxUtil' import { wxLogin } from '@/utils/wxUtil'
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router' import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'
...@@ -198,6 +204,8 @@ const componentRef = useTemplateRef<{ ...@@ -198,6 +204,8 @@ const componentRef = useTemplateRef<{
const editUserInfoRef = useTemplateRef<InstanceType<typeof EditUserInfo>>('editUserInfoRef') const editUserInfoRef = useTemplateRef<InstanceType<typeof EditUserInfo>>('editUserInfoRef')
const userStore = useUserStore() const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore) const { userInfo } = storeToRefs(userStore)
const anonymousUnreadCount = ref(0)
const realUnreadCount = ref(0)
// 左侧普通用户菜单 // 左侧普通用户菜单
const menuUserItems = [ const menuUserItems = [
...@@ -281,6 +289,11 @@ const menuOfficialItems = [ ...@@ -281,6 +289,11 @@ const menuOfficialItems = [
const isReal = ref<BooleanFlag>(BooleanFlag.NO) const isReal = ref<BooleanFlag>(BooleanFlag.NO)
/** 与私信页一致:实名 → realUnreadCount,匿名 → anonymousUnreadCount */
const privateMessageUnreadDisplay = computed(() =>
isReal.value === BooleanFlag.YES ? realUnreadCount.value : anonymousUnreadCount.value,
)
provide(IS_REAL_KEY, isReal) provide(IS_REAL_KEY, isReal)
watch(isReal, () => { watch(isReal, () => {
...@@ -321,6 +334,17 @@ const getIsOfficial = async () => { ...@@ -321,6 +334,17 @@ const getIsOfficial = async () => {
officialAccountList.value = data 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 handleSwitchAccount = async () => {
const selectedEmail = ref('') const selectedEmail = ref('')
ElMessageBox({ ElMessageBox({
...@@ -429,6 +453,10 @@ const handleClearCache = async () => { ...@@ -429,6 +453,10 @@ const handleClearCache = async () => {
onMounted(() => { onMounted(() => {
getIsOfficial() getIsOfficial()
}) })
onActivated(() => {
getSelfMessageUnreadCount()
})
</script> </script>
<style scoped> <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