feat: 联系人

im
dylanmay 2024-12-12 22:19:14 +08:00
parent e5b90372a6
commit 5c1bb25237
20 changed files with 421 additions and 65 deletions

View File

@ -1,7 +1,7 @@
import request from '@/config/axios'
export interface DeptVO {
id?: number
id: number
name: string
parentId: number
status: number
@ -10,6 +10,7 @@ export interface DeptVO {
phone: string
email: string
createTime: Date
children?: DeptVO[]
}
// 查询部门(精简)列表

View File

@ -27,6 +27,15 @@ export const getAllUser = () => {
return request.get({ url: '/system/user/all' })
}
/**
*
* @param id
* @returns
*/
export const getDeptUser = (id: number) => {
return request.get({ url: '/system/user/listByDept?id='+ id })
}
// 查询用户详情
export const getUser = (id: number) => {
return request.get({ url: '/system/user/get?id=' + id })

View File

@ -1,3 +1,4 @@
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import { ConversationModelType } from '@/views/chat/types/types'
import { openDB, DBSchema, IDBPDatabase } from 'idb'
@ -14,7 +15,9 @@ let dbPromise: Promise<IDBPDatabase<MyDB>>
export const initDB = () => {
if (!dbPromise) {
try {
dbPromise = openDB<MyDB>('yudao-im-indexeddb', 1, {
const { wsCache } = useCache()
const user = wsCache.get(CACHE_KEY.USER).user
dbPromise = openDB<MyDB>('yudao-im-indexeddb-' + user.id, 1, {
upgrade(db) {
db.createObjectStore('Conversations', { keyPath: 'conversationNo' })
}

View File

@ -1,14 +1,17 @@
<template>
<view class="flex h-full flex-1">
<ToolSection @menu-select-change="toolMenuSelectChange" />
<Session v-if="bussinessType === MENU_LIST_ENUM.CONVERSATION" />
<Friends v-if="bussinessType === MENU_LIST_ENUM.FRIENDS" />
<view v-if="bussinessType === MENU_LIST_ENUM.CONVERSATION" class="flex w-full flex-col">
<Session v-if="chatStore.bussinessType === MENU_LIST_ENUM.CONVERSATION" />
<view class="flex">
<Department v-if="chatStore.bussinessType === MENU_LIST_ENUM.FRIENDS" />
<Friends v-if="chatStore.bussinessType === MENU_LIST_ENUM.FRIENDS" />
</view>
<view v-if="chatStore.bussinessType === MENU_LIST_ENUM.CONVERSATION" class="flex w-full flex-col">
<ChatHeader />
<ChatMessage />
<InputSection />
</view>
<FriendDetail v-if="bussinessType === MENU_LIST_ENUM.FRIENDS" />
<FriendDetail v-if="chatStore.bussinessType === MENU_LIST_ENUM.FRIENDS && useFriendStore.currentFriend" />
</view>
</template>
@ -20,22 +23,35 @@
import ToolSection from '../components/ToolSection/Index.vue'
import Session from '../components/Conversation/index.vue'
import Friends from '../components/Friends/Index.vue'
import Department from '../components/Department/index.vue'
import ChatHeader from '../components/ChatHeader/index.vue'
import ChatMessage from '../components/ChatMessage/index.vue'
import InputSection from '../components/InputSection/index.vue'
import FriendDetail from '../components/FriendDetail/Index.vue'
import { MENU_LIST_ENUM } from '../types/types'
import { useWebSocketStore } from '../store/websocketStore'
import { useFriendStoreWithOut } from '../store/friendstore'
import { useChatStore } from '../store/chatstore'
defineOptions({ name: 'ChatPage' })
const bussinessType = ref(1)
const webSocketStore = useWebSocketStore();
const useFriendStore = useFriendStoreWithOut()
const { resetFriendList } = useFriendStore
const chatStore = useChatStore()
const { setBussinessType } = useChatStore()
onMounted(() => {
webSocketStore.connect()
})
watch(() => chatStore.bussinessType, (newVal) => {
if (newVal !== MENU_LIST_ENUM.FRIENDS) {
resetFriendList()
}
})
const toolMenuSelectChange = (value) => {
bussinessType.value = value
setBussinessType(value)
}
</script>

View File

@ -2,12 +2,17 @@
* @Author: dylan.may@qq.com
* @Date: 2024-10-16 11:30:31
* @Last Modified by: dylan.may@qq.com
* @Last Modified time: 2024-10-16 16:01:25
* @Last Modified time: 2024-11-28 17:32:26
*/
import request from '@/config/axios'
import { ChatConversation } from '../model/ChatConversation'
interface createConversationParam {
targetId: string,
type: number
}
/**
*
*/
@ -19,4 +24,17 @@ export default class SessionApi {
static getSessionList(): Promise<Array<ChatConversation>> {
return request.get({ url: '/im/conversation/list' })
}
/**
*
* @param data createConversationParam
* @returns Promise<ChatConversation>
*/
static createConversation(data: createConversationParam):Promise<ChatConversation> {
return request.post({
url: '/im/conversation/create',
data
})
}
}

View File

@ -4,7 +4,7 @@
style="height: 60px; min-height: 60px"
>
<label class="text-black text-size-xl font-medium mx-4">{{
chatStore.currentSession?.name
chatStore.currentSession?.nickname || chatStore.currentSession?.name
}}</label>
</view>
</template>

View File

@ -1,5 +1,5 @@
<template>
<view class="flex flex-col items-center h-full py-2 b-1 b-gray b-solid" style="width: 258px">
<view class="flex flex-col items-center h-full py-2 b-1 b-gray b-solid overflow-auto" style="width: 258px">
<view class="flex flex-col w-full">
<SessionItem
v-for="(item, index) in chatStore.sessionList"

View File

@ -53,8 +53,8 @@ const timefontColor = () => {
*/
const lastMessage = computed(() => {
if (props.conversation.msgList.length === 0) {
return props.conversation.lastMessageDescription
if (!props.conversation.msgList || props.conversation.msgList.length === 0) {
return props.conversation.lastMessageDescription || ''
}
const lastIndex = props.conversation.msgList.length - 1

View File

@ -0,0 +1,53 @@
<template>
<div class="tree-node" >
<div class="node-title custom-hover" @click.stop="onClick">
<ElAvatar class="m-2" shape="square" v-if="node.children.length > 0">{{ node.name.substring(0,1) }}</ElAvatar>
<span class="mx-1 p-1">{{ node.name }}</span>
</div>
<div v-if="node.children && node.children.length > 0" class="children">
<TreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
@node-click="$emit('node-click', $event)"
/>
</div>
</div>
</template>
<script setup>
import TreeNode from './TreeNode.vue';
// `node`
const props = defineProps({
node: {
type: Object,
required: true,
},
});
const emit = defineEmits(['node-click'])
const onClick = () => {
emit('node-click', props.node)
}
</script>
<style scoped>
.tree-node {
padding-left: 8px;
margin-left: 16px;
border-left: 1px solid #ccc;
}
.node-title {
margin: 4px 0;
font-weight: 500;
color: #333;
}
.children {
margin-top: 4px;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="tree">
<TreeNode v-for="item in hierarchy" :key="item.id" :node="item" @node-click="$emit('tree-click', $event)"/>
</div>
</template>
<script setup>
import TreeNode from './TreeNode.vue';
// `hierarchy`
defineProps({
hierarchy: {
type: Array,
required: true,
},
});
const emit = defineEmits(['tree-click'])
</script>
<style scoped>
.tree {
font-family: Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<view class="flex flex-col items-left h-full py-2 b-1 b-gray b-solid" style="width: 248px; min-width: 248px">
<!-- <view v-for="item in departListState.list" class="w-full justify-left custom-hover border-b-gray border-1" :key="item.id" style="height: 70px;">
<ElAvatar shape="square">{{ item.name.substring(0,1) }}</ElAvatar>
<view class="text-size-sm ml-1">{{ item.name }}</view>
</view> -->
<el-skeleton animated :loading="state.loading" :throttle="{ leading: 500, initVal: true, trailing: 500 }"
>
<template #template>
<div v-for="item in 12" :key="item" class="flex flex-1 mx-2 my-3">
<el-skeleton-item animated variant="rect" style="width: 50px; height: 50px; max-width: 50px;" />
<div class="mx-2 flex flex-1 flex-col mt-2">
<el-skeleton-item animated variant="rect" style="height: 10px;" />
<el-skeleton-item animated variant="rect" style="height: 10px;" class="mt-3" />
</div>
</div>
</template>
<template #default>
<div class="w-full h-full">
<TreeView :hierarchy="useFriendStore.departmentList" @tree-click="handleNodeClick" />
</div>
</template>
</el-skeleton>
<view
v-if="useFriendStore.departmentList.length === 0 && !state.loading"
class="flex justify-center items-center h-full">No data</view>
</view>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue'
import TreeView from './components/TreeView.vue'
import { useFriendStoreWithOut } from '../../store/friendstore';
defineOptions({ name: 'Department' })
const useFriendStore = useFriendStoreWithOut()
const { fetchDepartment, setCurrentDepartmentId, fetchDeptUser } = useFriendStore
const state = reactive({
loading: true
})
onMounted(() => {
fetchDepartment()
})
watch(() => useFriendStore.departmentList, () => {
setTimeout(() => {
state.loading = false
}, 1000);
})
const handleNodeClick = (data: Tree) => {
setCurrentDepartmentId(data.id)
fetchDeptUser(data.id)
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<view
class="flex justify-center w-full border-b-1 border-b-gray border-b-solid flex-1 border-b-1 border-b-gray border-b-solid py-2"
class="flex flex-col items-center w-full border-b-1 border-b-gray border-b-solid flex-1 border-b-1 border-b-gray border-b-solid py-2"
>
<view class="flex mt-20" v-if="friendStore.currentFriend != null">
<el-image
@ -8,22 +8,34 @@
class="rounded"
:src="friendStore.currentFriend.avatar"
/>
<view class="flex flex-col ml-2">
<label class="font-500 text-black font-size-5">{{ friendStore.currentFriend?.name }}</label>
<label>{{ friendStore.currentFriend?.description }}</label>
<view class="flex flex-col ml-4 mt-10">
<label class="font-500 text-black font-size-5">{{ friendStore.currentFriend?.name || '无名' }}</label>
<label class="mt-2 text-size-sm">{{ friendStore.currentFriend?.description || '--人生若只如初见' }}</label>
</view>
</view>
<view v-else class="mt-50 flex flex-col items-center">
<Icon icon="ep:coffee-cup" :size="64" />
<label>空空如也</label>
</view>
<el-button type="primary" class="mt-10" v-if="friendStore.currentFriend != null" @click="onSend"> </el-button>
</view>
</template>
<script lang="ts" setup>
import { useChatStore } from '../../store/chatstore';
import { useFriendStore } from '../../store/friendstore'
import { CONVERSATION_TYPE } from '../../types/types';
defineOptions({ name: 'FriendDetail' })
const friendStore = useFriendStore()
const chatStore = useChatStore()
const onSend = () => {
const avatar = friendStore.currentFriend?.avatar || ''
const nickname = friendStore.currentFriend?.name || ''
chatStore.createConversation(friendStore.currentFriend?.id, CONVERSATION_TYPE.SINGLE, avatar, nickname)
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<view class="flex py-2 border-b-gray-3 border-b-solid items-center px-2" :class="bgColor()">
<el-avatar shape="square" size="default" class="mr-2" :src="friend.avatar" />
<label>{{ friend.name }}</label>
<label :class="fontColor()">{{ friend.name }}</label>
</view>
</template>
@ -25,4 +25,8 @@ const friendStore = useFriendStore()
const bgColor = () => {
return props.friend.id === friendStore.currentFriend?.id ? 'bg-blue' : 'bg-white'
}
const fontColor = () => {
return props.friend.id === friendStore.currentFriend?.id ? 'text-white' : 'text-black'
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<view
class="flex flex-col items-center h-full py-2 b-1 b-gray b-solid"
class="flex flex-col items-center h-full py-2 b-1 b-gray b-solid b-l-white"
style="width: 248px; min-width: 248px"
>
<view class="flex flex-col w-full">
@ -24,8 +24,9 @@ import Friend from '../../model/Friend'
defineOptions({ name: 'Friends' })
const friendStore = useFriendStore()
onMounted(() => {
onMounted(async () => {
// set default conversation
// await friendStore.fetchFriend()
})
const onFriendClick = (friend: Friend) => {

View File

@ -1,31 +1,35 @@
<template>
<view class="flex flex-col items-center bg-gray h-full py-2" style="width: 80px; min-width: 80px">
<view class="flex flex-col items-center bg-gray-2 h-full py-2" style="width: 80px; min-width: 80px">
<el-avatar shape="square" />
<icon
icon="ep:chat-line-round"
:size="24"
color="white"
class="px-4 py-4 mt-1 rounded-2"
:class="selectItem === MENU_LIST_ENUM.CONVERSATION ? 'bg-red' : ''"
@click="onConversatonClicked"
/>
<icon
icon="ep:avatar"
:size="24"
color="white"
class="px-4 py-4 rounded-2 mt-2"
:class="selectItem === MENU_LIST_ENUM.FRIENDS ? 'bg-red' : ''"
@click="onFriendsClicked"
/>
<div
class="flex flex-col items-center px-3 py-3 mt-4 rounded-2 hover:bg-white"
:class="chatStore.bussinessType === MENU_LIST_ENUM.CONVERSATION ? 'bg-gray-3' : ''" style="width: 60px;"
@click="onConversatonClicked">
<icon icon="ep:chat-line-round" :size="24" color="#409EFF" />
<span class="text-xs mt-1 text-gray-5"> </span>
<span></span>
</div>
<div
class="flex flex-col items-center rounded-2 mt-4 p-3 hover:bg-white"
:class="chatStore.bussinessType === MENU_LIST_ENUM.FRIENDS ? 'bg-gray-3' : ''" style="width: 60px;" @click="onFriendsClicked">
<icon icon="ep:avatar" :size="24" color="#409EFF" />
<span class="text-xs mt-1 text-gray-5">联系人</span>
</div>
</view>
</template>
<script lang="ts" setup>
import { useChatStore } from '../../store/chatstore';
import { MENU_LIST_ENUM } from '../../types/types'
defineOptions({ name: 'ToolSection' })
const selectItem = ref(1)
const chatStore = useChatStore()
const { setBussinessType } = useChatStore()
const emit = defineEmits(['menuSelectChange'])
watch(
@ -36,10 +40,10 @@ watch(
)
const onConversatonClicked = () => {
selectItem.value = MENU_LIST_ENUM.CONVERSATION
setBussinessType(MENU_LIST_ENUM.CONVERSATION)
}
const onFriendsClicked = () => {
selectItem.value = MENU_LIST_ENUM.FRIENDS
setBussinessType(MENU_LIST_ENUM.FRIENDS)
}
</script>

View File

@ -4,12 +4,16 @@ export default class Friend {
public name: string
public description: string
public createTime: number
public deptId: number
public deptName: string
constructor(id, avatar, name, description, createTime) {
constructor(id, avatar, name, description, createTime, deptId, deptName) {
this.id = id
this.avatar = avatar
this.name = name
this.description = description
this.createTime = createTime
this.deptId = deptId
this.deptName = deptName
}
}

View File

@ -2,7 +2,7 @@ import { store } from '@/store/index'
import { defineStore } from 'pinia'
import BaseConversation from '../model/BaseConversation'
import BaseMessage from '../model/BaseMessage'
import { ConversationModelType, MessageRole, ContentType, SendStatus } from '../types/types'
import { ConversationModelType, MessageRole, ContentType, SendStatus, MENU_LIST_ENUM } from '../types/types'
import SessionApi from '../api/sessionApi'
import MessageApi, { SendMsg } from '../api/messageApi'
import { useUserStore, useUserStoreWithOut } from '@/store/modules/user'
@ -16,7 +16,8 @@ interface ChatStoreModel {
sessionList: Array<ConversationModelType>
currentSession: ConversationModelType | null
currentSessionIndex: number
inputText: string
inputText: string,
bussinessType: number // conversation 1, friends 2
}
export const useChatStore = defineStore('chatStore', {
@ -24,7 +25,8 @@ export const useChatStore = defineStore('chatStore', {
sessionList: [],
currentSession: null,
currentSessionIndex: 0,
inputText: ''
inputText: '',
bussinessType: 1,
}),
getters: {
@ -59,6 +61,10 @@ export const useChatStore = defineStore('chatStore', {
this.inputText = content
},
setBussinessType(type: number) {
this.bussinessType = type
},
async addMessageToCurrentSession<T extends BaseMessage>(message: T): Promise<void> {
this.currentSession?.msgList.push(message)
@ -109,6 +115,7 @@ export const useChatStore = defineStore('chatStore', {
* @param message
*/
addMessageToConversation<T extends BaseMessage>(message: T): void {
// 无论是converstionNo1还是converstionNo2都都需要试一下
const converstionNo1 = generateConversationNo(
message.senderId,
@ -138,6 +145,11 @@ export const useChatStore = defineStore('chatStore', {
// 更新消息到indexeddb
addConversation(toRaw(msgConversation) as ChatConversation )
// 更新当前会话
if (conversationIndex === this.currentSessionIndex) {
this.setCurrentConversation()
}
},
/**
@ -236,7 +248,60 @@ export const useChatStore = defineStore('chatStore', {
} catch (error) {
return error
}
},
/**
*
*/
async createConversation(targetId, type, avatar, nickname) {
try {
const param = {
targetId,
type
}
const res = await SessionApi.createConversation(param)
if (res) {
// 切换到聊天模式
this.bussinessType = MENU_LIST_ENUM.CONVERSATION
// 插入用户名和昵称
res.avatar = avatar;
res.nickname = nickname;
const localConversation = this.convertCoversationFromServer(res)
// 存入到数据库
addConversation(toRaw(localConversation) as ChatConversation)
// 从数据库同步到内存
await this.getConversationList()
// 设置当前的会话
const addIndex = this.sessionList.findIndex(item => item.conversationNo === localConversation.conversationNo)
this.setCurrentSessionIndex(addIndex)
this.setCurrentConversation()
}
} catch (error) {
console.log(error)
}
},
convertCoversationFromServer(item: any) {
return {
...item,
updateTime: item.updateTime,
targetId: item.targetId,
senderId: item.userId,
conversationNo: item.no,
unreadMessagesCount: item.unreadMessagesCount,
description: item.lastMessageDescription,
avatar: item.avatar,
name: item.name,
msgList: []
}
}
}
})

View File

@ -1,33 +1,23 @@
import { defineStore } from 'pinia'
import { store } from '@/store/index'
import BaseConversation from '../model/BaseConversation'
import Friend from '../model/Friend'
import { getAllUser, getDeptUser } from '@/api/system/user'
import * as DeptApi from '@/api/system/dept'
interface FriendStoreModel {
friendList: Array<Friend>
currentFriend: Friend | null
currentFriend: Friend | null,
selectedDepartmentId: number,
departmentList: DeptApi.DeptVO[]
}
export const useFriendStore = defineStore('friendStore', {
state: (): FriendStoreModel => ({
friendList: [
{
id: '1111',
name: 'Elon Musk',
avatar:
'https://img0.baidu.com/it/u=4211304696,1059959254&fm=253&fmt=auto&app=120&f=JPEG?w=800&h=1174',
description: 'cool boy',
createTime: 1695201147622
},
{
id: '2222',
name: 'Spider Man',
avatar:
'https://www.hottoys.com.cn/wp-content/uploads/2019/06/bloggerreview_spiderman_advanced_ben-9.jpg',
description: 'hero',
createTime: 1695201147622
}
],
currentFriend: null
friendList: [],
currentFriend: null,
selectedDepartmentId: 0,
departmentList: []
}),
getters: {
@ -42,6 +32,82 @@ export const useFriendStore = defineStore('friendStore', {
},
setCurrentFriend(friend: Friend) {
this.currentFriend = friend
},
setCurrentDepartmentId(id: number) {
this.selectedDepartmentId = id
},
resetFriendList() {
this.friendList = []
this.currentFriend = null
},
async fetchDepartment () {
try {
const result = await DeptApi.getSimpleDeptList()
this.departmentList = this.buildHierarchy(result)
} catch (e) {
console.log(e)
}
},
async fetchFriend() {
try {
const res = await getAllUser()
this.friendList = res
} catch (error) {
console.error(error)
}
},
async fetchDeptUser(id) {
try {
const res = await getDeptUser(id)
if (res) {
this.friendList = res.map(item => {
return {
...item,
name: item.nickname
}
})
} else {
this.friendList = []
}
} catch (error) {
console.error(error)
}
},
buildHierarchy(data: DeptApi.DeptVO[]): DeptApi.DeptVO[] {
const map = new Map<number, DeptApi.DeptVO>();
// 初始化 map确保每个 id 都有一条记录
data.forEach(item => map.set(item.id, { ...item, children: [] }));
const result: DeptApi.DeptVO[] = [];
data.forEach(item => {
if (item.parentId === 0) {
// 根节点
result.push(map.get(item.id)!);
} else {
// 子节点,放入父节点的 children 数组
const parent = map.get(item.parentId);
if (parent) {
parent.children!.push(map.get(item.id)!);
}
}
});
return result;
}
}
})
export const useFriendStoreWithOut = () => {
return useFriendStore(store)
}

View File

@ -11,6 +11,7 @@ import {
import TextMessage from '../model/TextMessage'
import { debug } from 'console'
import { useUserStore } from '@/store/modules/user'
import BaseConversation from '../model/BaseConversation'
interface Message {
type: string
@ -101,6 +102,13 @@ export const useWebSocketStore = defineStore('webSocket', () => {
}
} else if (websoketMessage.type === WEBSOCKET_MESSAGE_TYPE_ENUM.IM_CONVERSATION_ADD.toString()) {
const chatStore = useChatStore()
const conversation = JSON.parse(websoketMessage.content) as BaseConversation
chatStore.addSession(conversation)
// 同步到内存
chatStore.getConversationList()
} else {
// TODO:[dylan]
}

View File

@ -35,7 +35,8 @@ export const enum CONVERSATION_TYPE {
}
export enum WEBSOCKET_MESSAGE_TYPE_ENUM {
IM_MESSAGE_RECEIVE = 'im-message-receive'
IM_MESSAGE_RECEIVE = 'im-message-receive',
IM_CONVERSATION_ADD = 'im-conversation-add'
}