Commit 674789ad by lijiabin

【需求 21402】 feat: 完成关于私信相关的内容

parent deea553b
...@@ -15,6 +15,7 @@ export * from './otherUserPage' ...@@ -15,6 +15,7 @@ export * from './otherUserPage'
export * from './auction' export * from './auction'
export * from './dailyLottery' export * from './dailyLottery'
export * from './launchCampaign' export * from './launchCampaign'
export * from './selfMessage'
// 导出类型 // 导出类型
export * from './task/types' export * from './task/types'
export * from './shop/types' export * from './shop/types'
...@@ -32,3 +33,4 @@ export * from './otherUserPage/types' ...@@ -32,3 +33,4 @@ export * from './otherUserPage/types'
export * from './auction/types' export * from './auction/types'
export * from './dailyLottery/types' export * from './dailyLottery/types'
export * from './launchCampaign/types' export * from './launchCampaign/types'
export * from './selfMessage/types'
import service from '@/utils/request/index'
import type {
SendMessageDto,
GetMessageListDto,
ConversationResponseDto,
GetMessageDetailListDto,
MessageDetailListItem,
} from './types'
// 关于私信的相关接口
/**
* 给用户发表私信
*/
export const sendMessage = (data: SendMessageDto) => {
return service.request({
url: '/api/cultureDialog/sendDialogMessage',
method: 'POST',
data,
})
}
// 获取私信列表
export const getMessageList = (data: GetMessageListDto) => {
return service.request<ConversationResponseDto>({
url: '/api/cultureDialog/getDialogList',
method: 'POST',
data,
})
}
// 查看某个私信详情
export const getMessageDetailList = (data: GetMessageDetailListDto) => {
return service.request<MessageDetailListItem[]>({
url: `/api/cultureDialog/getDialogMessageList`,
method: 'POST',
data,
})
}
import { BooleanFlag } from '@/constants'
/**
* 发送私信的参数
*/
export interface SendMessageDto {
content: string
chatType: BooleanFlag // 0 实名 1 匿名
senderId: string
receiverId: string
images: string
}
/**
* 获取私信列表的参数
*/
export interface GetMessageListDto {
chatType?: BooleanFlag
search?: string
}
/**
* 获取私信列表的响应
*/
export interface ConversationResponseDto {
anonymousUnreadCount: number
realUnreadCount: number
totalUnreadCount: number
dialogList: ConversationItem[]
}
export interface ConversationItem {
chat_type: string
chat_type_desc: string
had_read: number
id: number
is_self_last_msg: boolean
last_message: string
last_message_prefix: string
last_sender_name: string
last_time: string
last_time_str: string
last_user_id: string
other_user_account: string
other_user_avatar: string
other_user_id: string
other_user_name: string
status: number
un_read_count: number
}
/**
* 获取私信详情的列表参数
*/
export interface GetMessageDetailListDto {
receiverId: string
dialogId: number
chatType: BooleanFlag
}
/**
* 获取私信详情的列表响应
*/
export interface MessageDetailListItem {
images: string
relation_status: number
create_time: string
receiver_id: string
message_id: number
sender_name: string
sender_avatar: string
content: string
sender_id: string
relation_id: number
create_time_str: string
is_anonymous: boolean
dialog_id: number
is_self: boolean
image_list: string[]
}
<template> <template>
<div ref="commentRef" class="bg-white rounded-3 shadow-sm border border-gray-100 overflow-hidden"> <div ref="commentRef" class="bg-white rounded-3 shadow-sm border border-gray-100">
<!-- 评论筛选 --> <!-- 评论筛选 -->
<div class="p-4 border-b border-gray-100"> <div class="p-4 border-b border-gray-100">
<div class="flex items-center gap-4 justify-between"> <div class="flex items-center gap-4 justify-between">
......
<script setup lang="ts"> <script setup lang="ts">
import CommentBox from '@/components/common/CommentBox/index.vue' import CommentBox from '@/components/common/CommentBox/index.vue'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import dayjs from 'dayjs'
import { push } from 'notivue'
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'
type MessageCategory = 'anonymous' | 'realname' import { getMessageList, sendMessage, getMessageDetailList } from '@/api'
type MessageSender = 'self' | 'other' import type { ConversationItem, MessageDetailListItem } from '@/api/selfMessage/types'
import { parseEmoji } from '@/utils/emoji'
interface ChatMessage { import { BooleanFlag } from '@/constants'
id: number
sender: MessageSender
text: string
images: string[]
createdAt: string
}
interface ConversationItem {
id: number
category: MessageCategory
name: string
avatar: string
badge: string
subtitle: string
sourceTitle: string
sourceTypeLabel: string
onlineStatus: string
unread: number
isPinned?: boolean
messages: ChatMessage[]
}
const userStore = useUserStore() const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore) const { userInfo } = storeToRefs(userStore)
const route = useRoute()
const categoryTabs = [ const categoryTabs = [
{ {
key: 'anonymous' as MessageCategory, key: BooleanFlag.YES,
label: '匿名私信', label: '匿名私信',
description: '适用于除问吧、实践、专访文章以外的内容',
}, },
{ {
key: 'realname' as MessageCategory, key: BooleanFlag.NO,
label: '实名私信', label: '实名私信',
description: '适用于问吧、实践、专访文章',
}, },
] ]
const conversationList = ref<ConversationItem[]>([ const conversationList = ref<ConversationItem[]>([])
{
id: 1,
category: 'realname',
name: '陈菲',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=ChengFei',
badge: '实名',
subtitle: '你上次提到的培训案例已经补充好了',
sourceTitle: '实践 · 从复盘到落地的团队协作方法',
sourceTypeLabel: '实践',
onlineStatus: '29分钟前活跃',
unread: 2,
isPinned: true,
messages: [
{
id: 101,
sender: 'other',
text: '你好,这篇实践文章里提到的复盘模板能发我看一下吗?',
images: [],
createdAt: '2026-04-09 09:18',
},
{
id: 102,
sender: 'self',
text: '可以,我整理完私信给你。',
images: [],
createdAt: '2026-04-09 09:22',
},
{
id: 103,
sender: 'other',
text: '好的,我也很想看看你们怎么把共识拆成执行动作。',
images: [],
createdAt: '2026-04-09 09:24',
},
],
},
{
id: 2,
category: 'anonymous',
name: '树洞来信',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousA',
badge: '匿名',
subtitle: '谢谢你愿意认真回复我的困惑',
sourceTitle: '案例库 · 一次被看见的小改变',
sourceTypeLabel: '案例',
onlineStatus: '刚刚',
unread: 0,
messages: [
{
id: 201,
sender: 'other',
text: '看到你的评论后想私信你,原来很多人也会经历类似阶段。',
images: [],
createdAt: '2026-04-09 08:31',
},
{
id: 202,
sender: 'self',
text: '会的,所以别急着否定自己,先把能做的小事做起来。',
images: [],
createdAt: '2026-04-09 08:36',
},
],
},
{
id: 3,
category: 'realname',
name: '王守勇',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=WangShouYong',
badge: '实名',
subtitle: '关于专访提到的组织演进,我补了两张图',
sourceTitle: '专访 · 从宏大叙事到实事求是',
sourceTypeLabel: '专访',
onlineStatus: '今天 08:45',
unread: 1,
messages: [
{
id: 301,
sender: 'other',
text: '这两张图是给你补充的版本,可以一起带给同事看。',
images: [
'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&w=600&q=80',
],
createdAt: '2026-04-09 08:45',
},
],
},
{
id: 4,
category: 'anonymous',
name: '未署名同事',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousB',
badge: '匿名',
subtitle: '想继续请教你怎么带新人',
sourceTitle: '视频 · 一次普通晨会为什么能开得更好',
sourceTypeLabel: '视频',
onlineStatus: '昨天',
unread: 0,
messages: [
{
id: 401,
sender: 'other',
text: '我发现自己总在说结论,带新人时对方不太能跟上。',
images: [],
createdAt: '2026-04-08 17:20',
},
{
id: 402,
sender: 'self',
text: '可以先从为什么这么做开始讲,再拆成两个最关键动作。',
images: [],
createdAt: '2026-04-08 17:35',
},
],
},
{
id: 11,
category: 'realname',
name: '陈菲',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=ChengFei',
badge: '实名',
subtitle: '你上次提到的培训案例已经补充好了',
sourceTitle: '实践 · 从复盘到落地的团队协作方法',
sourceTypeLabel: '实践',
onlineStatus: '29分钟前活跃',
unread: 2,
isPinned: true,
messages: [
{
id: 101,
sender: 'other',
text: '你好,这篇实践文章里提到的复盘模板能发我看一下吗?',
images: [],
createdAt: '2026-04-09 09:18',
},
{
id: 102,
sender: 'self',
text: '可以,我整理完私信给你。',
images: [],
createdAt: '2026-04-09 09:22',
},
{
id: 103,
sender: 'other',
text: '好的,我也很想看看你们怎么把共识拆成执行动作。',
images: [],
createdAt: '2026-04-09 09:24',
},
],
},
{
id: 21,
category: 'anonymous',
name: '树洞来信',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousA',
badge: '匿名',
subtitle: '谢谢你愿意认真回复我的困惑',
sourceTitle: '案例库 · 一次被看见的小改变',
sourceTypeLabel: '案例',
onlineStatus: '刚刚',
unread: 0,
messages: [
{
id: 201,
sender: 'other',
text: '看到你的评论后想私信你,原来很多人也会经历类似阶段。',
images: [],
createdAt: '2026-04-09 08:31',
},
{
id: 202,
sender: 'self',
text: '会的,所以别急着否定自己,先把能做的小事做起来。',
images: [],
createdAt: '2026-04-09 08:36',
},
],
},
{
id: 31,
category: 'realname',
name: '王守勇',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=WangShouYong',
badge: '实名',
subtitle: '关于专访提到的组织演进,我补了两张图',
sourceTitle: '专访 · 从宏大叙事到实事求是',
sourceTypeLabel: '专访',
onlineStatus: '今天 08:45',
unread: 1,
messages: [
{
id: 301,
sender: 'other',
text: '这两张图是给你补充的版本,可以一起带给同事看。',
images: [
'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&w=600&q=80',
],
createdAt: '2026-04-09 08:45',
},
],
},
{
id: 41,
category: 'anonymous',
name: '未署名同事',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousB',
badge: '匿名',
subtitle: '想继续请教你怎么带新人',
sourceTitle: '视频 · 一次普通晨会为什么能开得更好',
sourceTypeLabel: '视频',
onlineStatus: '昨天',
unread: 0,
messages: [
{
id: 401,
sender: 'other',
text: '我发现自己总在说结论,带新人时对方不太能跟上。',
images: [],
createdAt: '2026-04-08 17:20',
},
{
id: 402,
sender: 'self',
text: '可以先从为什么这么做开始讲,再拆成两个最关键动作。',
images: [],
createdAt: '2026-04-08 17:35',
},
],
},
{
id: 12,
category: 'realname',
name: '陈菲',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=ChengFei',
badge: '实名',
subtitle: '你上次提到的培训案例已经补充好了',
sourceTitle: '实践 · 从复盘到落地的团队协作方法',
sourceTypeLabel: '实践',
onlineStatus: '29分钟前活跃',
unread: 2,
isPinned: true,
messages: [
{
id: 101,
sender: 'other',
text: '你好,这篇实践文章里提到的复盘模板能发我看一下吗?',
images: [],
createdAt: '2026-04-09 09:18',
},
{
id: 102,
sender: 'self',
text: '可以,我整理完私信给你。',
images: [],
createdAt: '2026-04-09 09:22',
},
{
id: 103,
sender: 'other',
text: '好的,我也很想看看你们怎么把共识拆成执行动作。',
images: [],
createdAt: '2026-04-09 09:24',
},
],
},
{
id: 22,
category: 'anonymous',
name: '树洞来信',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousA',
badge: '匿名',
subtitle: '谢谢你愿意认真回复我的困惑',
sourceTitle: '案例库 · 一次被看见的小改变',
sourceTypeLabel: '案例',
onlineStatus: '刚刚',
unread: 0,
messages: [
{
id: 201,
sender: 'other',
text: '看到你的评论后想私信你,原来很多人也会经历类似阶段。',
images: [],
createdAt: '2026-04-09 08:31',
},
{
id: 202,
sender: 'self',
text: '会的,所以别急着否定自己,先把能做的小事做起来。',
images: [],
createdAt: '2026-04-09 08:36',
},
],
},
{
id: 32,
category: 'realname',
name: '王守勇',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=WangShouYong',
badge: '实名',
subtitle: '关于专访提到的组织演进,我补了两张图',
sourceTitle: '专访 · 从宏大叙事到实事求是',
sourceTypeLabel: '专访',
onlineStatus: '今天 08:45',
unread: 1,
messages: [
{
id: 301,
sender: 'other',
text: '这两张图是给你补充的版本,可以一起带给同事看。',
images: [
'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&w=600&q=80',
],
createdAt: '2026-04-09 08:45',
},
],
},
{
id: 4,
category: 'anonymous',
name: '未署名同事',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousB',
badge: '匿名',
subtitle: '想继续请教你怎么带新人',
sourceTitle: '视频 · 一次普通晨会为什么能开得更好',
sourceTypeLabel: '视频',
onlineStatus: '昨天',
unread: 0,
messages: [
{
id: 401,
sender: 'other',
text: '我发现自己总在说结论,带新人时对方不太能跟上。',
images: [],
createdAt: '2026-04-08 17:20',
},
{
id: 402,
sender: 'self',
text: '可以先从为什么这么做开始讲,再拆成两个最关键动作。',
images: [],
createdAt: '2026-04-08 17:35',
},
],
},
{
id: 13,
category: 'realname',
name: '陈菲',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=ChengFei',
badge: '实名',
subtitle: '你上次提到的培训案例已经补充好了',
sourceTitle: '实践 · 从复盘到落地的团队协作方法',
sourceTypeLabel: '实践',
onlineStatus: '29分钟前活跃',
unread: 2,
isPinned: true,
messages: [
{
id: 101,
sender: 'other',
text: '你好,这篇实践文章里提到的复盘模板能发我看一下吗?',
images: [],
createdAt: '2026-04-09 09:18',
},
{
id: 102,
sender: 'self',
text: '可以,我整理完私信给你。',
images: [],
createdAt: '2026-04-09 09:22',
},
{
id: 103,
sender: 'other',
text: '好的,我也很想看看你们怎么把共识拆成执行动作。',
images: [],
createdAt: '2026-04-09 09:24',
},
],
},
{
id: 2,
category: 'anonymous',
name: '树洞来信',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousA',
badge: '匿名',
subtitle: '谢谢你愿意认真回复我的困惑',
sourceTitle: '案例库 · 一次被看见的小改变',
sourceTypeLabel: '案例',
onlineStatus: '刚刚',
unread: 0,
messages: [
{
id: 201,
sender: 'other',
text: '看到你的评论后想私信你,原来很多人也会经历类似阶段。',
images: [],
createdAt: '2026-04-09 08:31',
},
{
id: 202,
sender: 'self',
text: '会的,所以别急着否定自己,先把能做的小事做起来。',
images: [],
createdAt: '2026-04-09 08:36',
},
],
},
{
id: 3,
category: 'realname',
name: '王守勇',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=WangShouYong',
badge: '实名',
subtitle: '关于专访提到的组织演进,我补了两张图',
sourceTitle: '专访 · 从宏大叙事到实事求是',
sourceTypeLabel: '专访',
onlineStatus: '今天 08:45',
unread: 1,
messages: [
{
id: 301,
sender: 'other',
text: '这两张图是给你补充的版本,可以一起带给同事看。',
images: [
'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&w=600&q=80',
],
createdAt: '2026-04-09 08:45',
},
],
},
{
id: 4,
category: 'anonymous',
name: '未署名同事',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousB',
badge: '匿名',
subtitle: '想继续请教你怎么带新人',
sourceTitle: '视频 · 一次普通晨会为什么能开得更好',
sourceTypeLabel: '视频',
onlineStatus: '昨天',
unread: 0,
messages: [
{
id: 401,
sender: 'other',
text: '我发现自己总在说结论,带新人时对方不太能跟上。',
images: [],
createdAt: '2026-04-08 17:20',
},
{
id: 402,
sender: 'self',
text: '可以先从为什么这么做开始讲,再拆成两个最关键动作。',
images: [],
createdAt: '2026-04-08 17:35',
},
],
},
{
id: 1,
category: 'realname',
name: '陈菲',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=ChengFei',
badge: '实名',
subtitle: '你上次提到的培训案例已经补充好了',
sourceTitle: '实践 · 从复盘到落地的团队协作方法',
sourceTypeLabel: '实践',
onlineStatus: '29分钟前活跃',
unread: 2,
isPinned: true,
messages: [
{
id: 101,
sender: 'other',
text: '你好,这篇实践文章里提到的复盘模板能发我看一下吗?',
images: [],
createdAt: '2026-04-09 09:18',
},
{
id: 102,
sender: 'self',
text: '可以,我整理完私信给你。',
images: [],
createdAt: '2026-04-09 09:22',
},
{
id: 103,
sender: 'other',
text: '好的,我也很想看看你们怎么把共识拆成执行动作。',
images: [],
createdAt: '2026-04-09 09:24',
},
],
},
{
id: 23,
category: 'anonymous',
name: '树洞来信',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousA',
badge: '匿名',
subtitle: '谢谢你愿意认真回复我的困惑',
sourceTitle: '案例库 · 一次被看见的小改变',
sourceTypeLabel: '案例',
onlineStatus: '刚刚',
unread: 0,
messages: [
{
id: 201,
sender: 'other',
text: '看到你的评论后想私信你,原来很多人也会经历类似阶段。',
images: [],
createdAt: '2026-04-09 08:31',
},
{
id: 202,
sender: 'self',
text: '会的,所以别急着否定自己,先把能做的小事做起来。',
images: [],
createdAt: '2026-04-09 08:36',
},
],
},
{
id: 33,
category: 'realname',
name: '王守勇',
avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=WangShouYong',
badge: '实名',
subtitle: '关于专访提到的组织演进,我补了两张图',
sourceTitle: '专访 · 从宏大叙事到实事求是',
sourceTypeLabel: '专访',
onlineStatus: '今天 08:45',
unread: 1,
messages: [
{
id: 301,
sender: 'other',
text: '这两张图是给你补充的版本,可以一起带给同事看。',
images: [
'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&w=600&q=80',
],
createdAt: '2026-04-09 08:45',
},
],
},
{
id: 42,
category: 'anonymous',
name: '未署名同事',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=AnonymousB',
badge: '匿名',
subtitle: '想继续请教你怎么带新人',
sourceTitle: '视频 · 一次普通晨会为什么能开得更好',
sourceTypeLabel: '视频',
onlineStatus: '昨天',
unread: 0,
messages: [
{
id: 401,
sender: 'other',
text: '我发现自己总在说结论,带新人时对方不太能跟上。',
images: [],
createdAt: '2026-04-08 17:20',
},
{
id: 402,
sender: 'self',
text: '可以先从为什么这么做开始讲,再拆成两个最关键动作。',
images: [],
createdAt: '2026-04-08 17:35',
},
],
},
])
const activeCategory = ref<MessageCategory>('anonymous') const activeCategory = ref<BooleanFlag>(BooleanFlag.NO)
const activeConversationId = ref<number>(2) const activeConversationId = ref<number>(0)
const keyword = ref('') const keyword = ref('')
const draftText = ref('') const draftText = ref('')
const draftImages = ref('') const draftImages = ref('')
const sending = ref(false) const sending = ref(false)
const messageListRef = useTemplateRef<HTMLDivElement>('messageListRef')
const conversationBoxRef = useTemplateRef<ScrollbarInstance>('conversationBoxRef') const conversationBoxRef = useTemplateRef<ScrollbarInstance>('conversationBoxRef')
const toChatType = (chatType: ConversationItem['chat_type']) => Number(chatType) as BooleanFlag
const listLoading = ref(false)
const getCategoryFromRoute = () => {
const isReal = Number(route.query.isReal)
if (isReal === 1) return BooleanFlag.NO
if (isReal === 0) return BooleanFlag.YES
return null
}
const filteredConversationList = computed(() => { const applyCategoryFromRoute = () => {
const lowerKeyword = keyword.value.trim().toLowerCase() const routeCategory = getCategoryFromRoute()
if (routeCategory === null || routeCategory === activeCategory.value) return false
activeCategory.value = routeCategory
activeConversationId.value = 0
messageDetailList.value = []
return true
}
return conversationList.value const refreshConversationList = async (searchText = keyword.value.trim()) => {
.filter((item) => item.category === activeCategory.value) listLoading.value = true
.filter((item) => { try {
if (!lowerKeyword) return true const { data } = await getMessageList({
return [item.name, item.subtitle, item.sourceTitle].some((field) => search: searchText || undefined,
field.toLowerCase().includes(lowerKeyword),
)
})
.sort((a, b) => {
if (!!a.isPinned !== !!b.isPinned) return a.isPinned ? -1 : 1
return (
dayjs(b.messages.at(-1)?.createdAt).valueOf() -
dayjs(a.messages.at(-1)?.createdAt).valueOf()
)
}) })
conversationList.value = data?.dialogList || []
} finally {
listLoading.value = false
}
}
const filteredConversationList = computed(() => {
const list = conversationList.value.filter(
(item) => toChatType(item.chat_type) === activeCategory.value,
)
return list
}) })
const activeConversation = computed(() => { const activeConversation = computed(() => {
...@@ -667,37 +77,11 @@ const activeConversation = computed(() => { ...@@ -667,37 +77,11 @@ const activeConversation = computed(() => {
return current || filteredConversationList.value[0] || null return current || filteredConversationList.value[0] || null
}) })
const groupedMessages = computed(() => {
if (!activeConversation.value) return []
const groups: Array<{ label: string; items: ChatMessage[] }> = []
activeConversation.value.messages.forEach((message) => {
const label = dayjs(message.createdAt).format('YYYY-MM-DD HH:mm')
const lastGroup = groups.at(-1)
if (!lastGroup || lastGroup.label !== label) {
groups.push({ label, items: [message] })
return
}
lastGroup.items.push(message)
})
return groups
})
const currentUserName = computed(() => userInfo.value?.hiddenName || userInfo.value?.name || '我')
const currentUserAvatar = computed(
() =>
userInfo.value?.hiddenAvatar ||
userInfo.value?.avatar ||
'https://api.dicebear.com/7.x/adventurer/svg?seed=Me',
)
const conversationStats = computed(() => ({ const conversationStats = computed(() => ({
anonymous: conversationList.value.filter((item) => item.category === 'anonymous').length, anonymous: conversationList.value.filter((item) => toChatType(item.chat_type) === BooleanFlag.YES)
realname: conversationList.value.filter((item) => item.category === 'realname').length, .length,
realname: conversationList.value.filter((item) => toChatType(item.chat_type) === BooleanFlag.NO)
.length,
})) }))
const canSend = computed(() => { const canSend = computed(() => {
...@@ -708,67 +92,74 @@ const canSend = computed(() => { ...@@ -708,67 +92,74 @@ const canSend = computed(() => {
) )
}) })
watch( watch(filteredConversationList, (list) => {
filteredConversationList, if (!list.length) {
(list) => { activeConversationId.value = 0
if (!list.length) { return
activeConversationId.value = 0 }
return
}
if (!list.some((item) => item.id === activeConversationId.value) && list[0]) { if (!list.some((item) => item.id === activeConversationId.value) && list[0]) {
activeConversationId.value = list[0].id activeConversationId.value = list[0].id
} }
}, })
{ immediate: true },
)
watch( watch(
() => activeConversation.value?.id, () => activeConversation.value?.id,
async (id) => { async (id) => {
if (!id) return if (!id) return
const current = conversationList.value.find((item) => item.id === id) const current = conversationList.value.find((item) => item.id === id)
if (current) current.unread = 0 if (current) current.un_read_count = 0
if (!messageDetailList.value.length && current) {
await selectConversation(current)
}
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
}, },
{ immediate: true },
) )
const getConversationPreviewTime = (conversation: ConversationItem) => { const messageDetailList = ref<MessageDetailListItem[]>([])
const latest = conversation.messages.at(-1)?.createdAt
if (!latest) return '--'
const now = dayjs()
const target = dayjs(latest)
if (target.isSame(now, 'day')) return target.format('HH:mm') const selectConversation = async (item: ConversationItem) => {
if (target.isSame(now.subtract(1, 'day'), 'day')) return '昨天' activeConversationId.value = item.id
return target.format('MM-DD') 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,
}
})
} }
const getMessageText = (message: ChatMessage) => { const switchCategory = (category: BooleanFlag) => {
if (message.text.trim()) return message.text if (activeCategory.value === category) return
if (message.images.length) return '[图片]' activeCategory.value = category
return '[新消息]' activeConversationId.value = 0
messageDetailList.value = []
} }
const selectConversation = (id: number) => { const handleSearchBlur = async () => {
activeConversationId.value = id await refreshConversationList()
} }
const switchCategory = (category: MessageCategory) => { const handleSearchClear = () => {
activeCategory.value = category
keyword.value = '' keyword.value = ''
refreshConversationList('')
} }
const scrollToBottom = () => { const scrollToBottom = () => {
const wrapper = conversationBoxRef.value const wrapper = conversationBoxRef.value
console.log(wrapper, 'wrapper') if (!wrapper?.wrapRef) return
if (!wrapper) return
console.log(wrapper.$el.scrollHeight, 'wrapper.$el.scrollHeight')
wrapper.scrollTo({ wrapper.scrollTo({
top: wrapper.$el.scrollHeight, top: wrapper.wrapRef.scrollHeight,
behavior: 'smooth', behavior: 'smooth',
}) })
} }
...@@ -778,25 +169,18 @@ const handleSend = async () => { ...@@ -778,25 +169,18 @@ const handleSend = async () => {
sending.value = true sending.value = true
try { try {
const newMessage: ChatMessage = { await sendMessage({
id: Date.now(), content: draftText.value,
sender: 'self', chatType: activeCategory.value,
text: draftText.value.trim(), senderId: userInfo.value.userId,
images: draftImages.value receiverId: activeConversation.value.other_user_id,
.split(',') images: draftImages.value,
.map((item) => item.trim()) })
.filter(Boolean),
createdAt: dayjs().format('YYYY-MM-DD HH:mm'),
}
activeConversation.value.messages.push(newMessage)
activeConversation.value.subtitle = getMessageText(newMessage)
draftText.value = '' draftText.value = ''
draftImages.value = '' draftImages.value = ''
await selectConversation(activeConversation.value)
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
push.success('私信已发送')
} finally { } finally {
sending.value = false sending.value = false
} }
...@@ -808,6 +192,11 @@ const handleInputKeydown = (event: KeyboardEvent) => { ...@@ -808,6 +192,11 @@ const handleInputKeydown = (event: KeyboardEvent) => {
handleSend() handleSend()
} }
} }
onActivated(async () => {
applyCategoryFromRoute()
await refreshConversationList()
})
</script> </script>
<template> <template>
...@@ -815,7 +204,7 @@ const handleInputKeydown = (event: KeyboardEvent) => { ...@@ -815,7 +204,7 @@ const handleInputKeydown = (event: KeyboardEvent) => {
<BackButton /> <BackButton />
<section class="flex h-[calc(100vh-6.25rem)] gap-4 overflow-hidden"> <section class="flex h-[calc(100vh-6.25rem)] gap-4 overflow-hidden">
<aside <aside
class="flex-1/4 rounded-lg border border-[#d8e5f2] bg-[linear-gradient(180deg,#ffffff_0%,#f8fbff_100%)] p-4 shadow-[0_0.5rem_1.5rem_rgba(134,167,207,0.12)]" class="flex-1/4 rounded-lg border border-[#d8e5f2] bg-white p-4 shadow-[0_0.5rem_1.5rem_rgba(134,167,207,0.12)]"
> >
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<div> <div>
...@@ -842,7 +231,7 @@ const handleInputKeydown = (event: KeyboardEvent) => { ...@@ -842,7 +231,7 @@ const handleInputKeydown = (event: KeyboardEvent) => {
class="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-[#e8eff8] px-1.5 text-3 font-700" class="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-[#e8eff8] px-1.5 text-3 font-700"
> >
{{ {{
item.key === 'anonymous' item.key === BooleanFlag.YES
? conversationStats.anonymous ? conversationStats.anonymous
: conversationStats.realname : conversationStats.realname
}} }}
...@@ -856,6 +245,9 @@ const handleInputKeydown = (event: KeyboardEvent) => { ...@@ -856,6 +245,9 @@ const handleInputKeydown = (event: KeyboardEvent) => {
clearable clearable
placeholder="搜索用户名" placeholder="搜索用户名"
class="mb-4" class="mb-4"
:loading="listLoading"
@blur="handleSearchBlur"
@clear="handleSearchClear"
> >
<template #prefix> <template #prefix>
<el-icon><IEpSearch /></el-icon> <el-icon><IEpSearch /></el-icon>
...@@ -868,38 +260,45 @@ const handleInputKeydown = (event: KeyboardEvent) => { ...@@ -868,38 +260,45 @@ const handleInputKeydown = (event: KeyboardEvent) => {
<button <button
v-for="item in filteredConversationList" v-for="item in filteredConversationList"
:key="item.id" :key="item.id"
class="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
? 'border-[#cbdcff] bg-[linear-gradient(180deg,#f6faff_0%,#edf5ff_100%)] shadow-[0_0.5rem_1.125rem_rgba(134,167,207,0.15)]' ? '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]' : 'border-transparent bg-transparent hover:border-[#dde8f3] hover:bg-[#f9fbfe]'
" "
@click="selectConversation(item.id)" @click="selectConversation(item)"
> >
<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.avatar" /> <el-avatar :size="46" :src="item.other_user_avatar" />
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center 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]"> <span class="max-w-[6.875rem] truncate text-4 font-700 text-[#17305d]">
{{ item.name }} {{ item.other_user_name }}
</span> </span>
</div> </div>
<span class="shrink-0 text-3 text-[#8ca0b8]">{{ <span class="shrink-0 text-3 text-[#8ca0b8]">{{ item.last_time_str }}</span>
getConversationPreviewTime(item)
}}</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">
<span <span
class="inline-flex h-5 shrink-0 items-center rounded-full bg-[#fff3dc] px-2 text-3 text-[#9a6817]" class="inline-flex h-5 shrink-0 items-center rounded-full bg-[#fff3dc] px-2 text-3 text-[#9a6817]"
> >
{{ item.sourceTypeLabel }} {{ 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> </span>
<span class="truncate text-3 text-[#7890aa]">{{ item.sourceTitle }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -913,100 +312,97 @@ const handleInputKeydown = (event: KeyboardEvent) => { ...@@ -913,100 +312,97 @@ const handleInputKeydown = (event: KeyboardEvent) => {
</aside> </aside>
<main <main
class="overflow-hidden h-full flex-3/4 rounded-lg border border-[#d8e5f2] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbff_100%)] shadow-[0_0.5rem_1.5rem_rgba(134,167,207,0.12)]" class="overflow-hidden h-full flex-3/4 rounded-lg border border-[#d8e5f2] bg-white shadow-[0_0.5rem_1.5rem_rgba(134,167,207,0.12)]"
> >
<template v-if="activeConversation"> <template v-if="activeConversation">
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
<header <header
class="flex flex-col gap-3 border-b border-[#e6eef6] bg-[linear-gradient(180deg,#f6faff_0%,#ffffff_100%)] px-5 py-4 md:flex-row md:items-center md:justify-between" class="relative z-1 flex flex-col gap-3 border-b border-[#e6eef6] bg-#fff px-5 py-4 shadow-[0_10px_16px_-16px_rgba(15,23,42,0.22)] md:flex-row md:items-center md:justify-between"
> >
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<el-avatar :size="50" :src="activeConversation.avatar" /> <el-avatar :size="50" :src="activeConversation.other_user_avatar" />
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<strong class="text-4.5 font-700 text-[#18305d]">{{ <strong class="text-4.5 font-700 text-[#18305d]">{{
activeConversation.name activeConversation.other_user_name
}}</strong> }}</strong>
<span <span
class="inline-flex h-6 items-center rounded-full px-2.5 text-3 font-700" class="inline-flex h-6 items-center rounded-full px-2.5 text-3 font-700"
:class=" :class="
activeConversation.category === 'anonymous' toChatType(activeConversation.chat_type) === BooleanFlag.NO
? 'bg-[#e7f7ed] text-[#2a7f61]' ? 'bg-[#e7f7ed] text-[#2a7f61]'
: 'bg-[#e8f0ff] text-[#3762c7]' : 'bg-[#e8f0ff] text-[#3762c7]'
" "
> >
{{ activeConversation.badge }} {{ activeConversation.chat_type_desc }}
</span> </span>
</div> </div>
<p class="mt-1 truncate text-[0.8125rem] leading-[1.375rem] text-[#7588a3]">
来源:{{ activeConversation.sourceTitle }} ·
{{ activeConversation.onlineStatus }}
</p>
</div> </div>
</div> </div>
<div class="text-[0.8125rem] text-[#6f84a4]"> <div class="text-[0.8125rem] text-[#6f84a4]">
当前对话按 当前对话按
<span class="mx-1 font-700 text-[#4a6fd2]">{{ activeConversation.badge }}</span> <span class="mx-1 font-700 text-[#4a6fd2]">{{
activeConversation.chat_type_desc
}}</span>
方式展示 方式展示
</div> </div>
</header> </header>
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
<el-scrollbar ref="conversationBoxRef"> <div
<div class="bg-[linear-gradient(180deg,#fcfdff_0%,#f8fbff_100%)] px-5 py-5"> v-show="!messageDetailList.length"
<div v-for="group in groupedMessages" :key="group.label" class="pb-3"> class="flex h-full items-center justify-center"
<div class="mb-4 text-center text-3 text-[#96a6bb]"> >
<span class="rounded-full bg-[#eef4fb] px-3 py-1">{{ group.label }}</span> <el-empty description="没有找到匹配的会话" :image-size="90" />
</div> </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-for="message in messageDetailList" :key="message.message_id" class="pb-3">
<div <div
v-for="message in group.items"
:key="message.id"
class="mb-4 flex items-start gap-3" class="mb-4 flex items-start gap-3"
:class="{ 'flex-row-reverse': message.sender === 'self' }" :class="{ 'flex-row-reverse': message.is_self }"
> >
<el-avatar <el-avatar :size="38" :src="message.sender_avatar" class="shrink-0" />
:size="38"
:src="
message.sender === 'self' ? currentUserAvatar : activeConversation.avatar
"
class="shrink-0"
/>
<div <div
class="flex max-w-[min(72%,38.75rem)] flex-col gap-1" class="flex max-w-[min(72%,38.75rem)] flex-col gap-1"
:class="{ 'items-end': message.sender === 'self' }" :class="{ 'items-end': message.is_self }"
> >
<div class="text-3 text-[#8c9bb0]"> <div class="flex items-center gap-2 text-3 text-[#8c9bb0]">
{{ <span>{{
message.sender === 'self' ? currentUserName : activeConversation.name message.is_self ? userInfo.name : activeConversation.other_user_name
}} }}</span>
<span class="text-[#9eb0c6]">{{ message.create_time_str }}</span>
</div> </div>
<div <div
class="rounded-lg border px-4 py-3 shadow-[0_0.125rem_0.5rem_rgba(39,74,120,0.04)]" class="rounded-lg border px-3 py-2 shadow-[0_0.125rem_0.5rem_rgba(39,74,120,0.04)]"
:class=" :class="
message.sender === 'self' message.is_self
? 'rounded-tr-[0.375rem] border-[#d4e4ff] bg-[#dbeaff] text-[#1a3f73]' ? 'rounded-tr-[0.375rem] border-[#d4e4ff] bg-[#dbeaff] text-[#1a3f73]'
: 'rounded-tl-[0.375rem] border-[#e7edf5] bg-white text-[#23364c]' : 'rounded-tl-[0.375rem] border-[#e7edf5] bg-white text-[#23364c]'
" "
> >
<p <p
v-if="message.text" v-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>
<div
v-if="message.image_list?.length"
class="mt-2 grid gap-2 sm:grid-cols-2"
> >
{{ message.text }}
</p>
<div v-if="message.images.length" class="mt-2 grid gap-2 sm:grid-cols-2">
<el-image <el-image
v-for="image in message.images" v-for="image in message.image_list"
:key="image" :key="image"
:src="image" :src="image"
fit="cover" fit="cover"
:preview-src-list="message.images" :preview-src-list="message.image_list"
preview-teleported preview-teleported
class="h-30 w-full overflow-hidden rounded-[0.875rem]" class="h-30 w-30 overflow-hidden rounded-lg"
/> />
</div> </div>
</div> </div>
...@@ -1016,7 +412,9 @@ const handleInputKeydown = (event: KeyboardEvent) => { ...@@ -1016,7 +412,9 @@ const handleInputKeydown = (event: KeyboardEvent) => {
</div> </div>
</el-scrollbar> </el-scrollbar>
</div> </div>
<footer class="border-t border-[#e6eef6] bg-white px-5 py-4 h-auto"> <footer
class="relative z-1 h-auto border-t border-[#e6eef6] bg-white px-5 py-4 shadow-[0_-10px_16px_-16px_rgba(15,23,42,0.22)]"
>
<div <div
class="mb-3 flex items-center justify-between gap-3 text-[0.8125rem] text-[#7690ac]" class="mb-3 flex items-center justify-between gap-3 text-[0.8125rem] text-[#7690ac]"
> >
...@@ -1032,7 +430,7 @@ const handleInputKeydown = (event: KeyboardEvent) => { ...@@ -1032,7 +430,7 @@ const handleInputKeydown = (event: KeyboardEvent) => {
v-model:inputText="draftText" v-model:inputText="draftText"
v-model:inputImg="draftImages" v-model:inputImg="draftImages"
:textAreaHeight="72" :textAreaHeight="72"
placeholder="请输入回复内容,回车发送" placeholder="请输入回复内容"
> >
<template #submit> <template #submit>
<button <button
......
...@@ -99,7 +99,7 @@ ...@@ -99,7 +99,7 @@
<div class="bg-white rounded-lg shadow-sm mb-4"> <div class="bg-white rounded-lg shadow-sm mb-4">
<div <div
v-for="item in menuUserItems" v-for="item in menuUserItems"
:key="item.path" :key="item.tab"
@click="changeMenu(item.path)" @click="changeMenu(item.path)"
:class="[ :class="[
'flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-gray-100 last:border-b-0', 'flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-gray-100 last:border-b-0',
...@@ -202,7 +202,7 @@ const { userInfo } = storeToRefs(userStore) ...@@ -202,7 +202,7 @@ const { userInfo } = storeToRefs(userStore)
// 左侧普通用户菜单 // 左侧普通用户菜单
const menuUserItems = [ const menuUserItems = [
{ {
path: '/selfMessage', path: () => `/selfMessage?isReal=${isReal.value}`,
label: '我的私信', label: '我的私信',
icon: () => <IEpMessage />, icon: () => <IEpMessage />,
tab: '我的私信', tab: '我的私信',
...@@ -302,8 +302,8 @@ const currentUserInfo = computed(() => ...@@ -302,8 +302,8 @@ const currentUserInfo = computed(() =>
}, },
) )
const changeMenu = (key: string) => { const changeMenu = (key: string | (() => string)) => {
router.push(key) router.push(typeof key === 'function' ? key() : key)
} }
const handleEdit = () => { const handleEdit = () => {
......
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