14 Commits

Author SHA1 Message Date
d599ca91c6 接口变更 2025-02-10 14:34:29 +08:00
5c1bb25237 feat: 联系人 2024-12-12 22:19:14 +08:00
e5b90372a6 数据持久化 2024-11-12 09:12:10 +08:00
5feb3e6815 socket接入 2024-11-12 09:11:08 +08:00
755bf1bb08 去掉conversatNo的使用 2024-10-28 16:31:04 +08:00
0d347643ca 【代码评审】IM:会话、消息相关的接口 2024-10-28 09:41:29 +08:00
f3968db2e0 会话和消息处理 2024-10-26 19:45:08 +08:00
90461a8cdf Merge branch 'im' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into im 2024-10-20 13:52:05 +08:00
d8d3366687 Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into im 2024-10-20 13:51:15 +08:00
90619542c8 单聊对接 2024-10-19 16:06:29 +08:00
d2a212fb80 Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into im
# Conflicts:
#	src/router/modules/remaining.ts
2024-10-14 12:34:09 +08:00
aaba03d001 update friend 2023-09-22 16:30:43 +08:00
5b8b51a894 feat: friends 2023-09-20 17:51:50 +08:00
e3f8a3a94b feat: chat 2023-09-08 17:36:37 +08:00
37 changed files with 28675 additions and 41 deletions

100
.vscode/settings.json vendored
View File

@ -1,5 +1,7 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.tsdk": "./node_modules/typescript/lib",
"volar.tsPlugin": true,
"volar.tsPluginStatus": false,
"npm.packageManager": "pnpm",
"editor.tabSize": 2,
"prettier.printWidth": 100, // 超过最大值换行
@ -62,7 +64,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
@ -83,53 +85,74 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
"source.fixAll.eslint": "explicit"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
}
},
"i18n-ally.localesPaths": ["src/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.sortKeys": true,
"i18n-ally.namespace": false,
"i18n-ally.namespace": true,
"i18n-ally.enabledParsers": ["ts"],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "zh-CN",
"i18n-ally.enabledFrameworks": ["vue", "react"],
"cSpell.words": [
"brotli",
"browserslist",
"codemirror",
"commitlint",
"cropperjs",
"echart",
"echarts",
"esnext",
"esno",
"iconify",
"INTLIFY",
"lintstagedrc",
"logicflow",
"nprogress",
"pinia",
"pnpm",
"qrcode",
"sider",
"sortablejs",
"stylelint",
"svgs",
"unocss",
"unplugin",
"unref",
"videojs",
"VITE",
"vitejs",
"vueuse",
"wangeditor",
"xingyu",
"yudao",
"zxcvbn"
"unocss",
"browserslist",
"esnext",
"unplugin",
"qrcode",
"sider",
"pinia",
"sider",
"nprogress",
"INTLIFY",
"stylelint",
"esno",
"vitejs",
"sortablejs",
"codemirror",
"iconify",
"commitlint",
"videojs",
"echarts",
"wangeditor",
"cropperjs",
"logicflow",
"vueuse",
"zxcvbn",
"lintstagedrc",
"brotli",
"sider",
"pnpm"
],
"vetur.format.scriptInitialIndent": true,
"vetur.format.styleInitialIndent": true,
"vetur.validation.script": false,
"MicroPython.executeButton": [
{
"text": "▶",
"tooltip": "运行",
"alignment": "left",
"command": "extension.executeFile",
"priority": 3.5
}
],
"MicroPython.syncButton": [
{
"text": "$(sync)",
"tooltip": "同步",
"alignment": "left",
"command": "extension.execute",
"priority": 4
}
],
// 控制相关文件嵌套展示
"explorer.fileNesting.enabled": true,
@ -138,8 +161,7 @@
"*.ts": "$(capture).test.ts, $(capture).test.tsx",
"*.tsx": "$(capture).test.ts, $(capture).test.tsx",
"*.env": "$(capture).env.*",
"package.json": "pnpm-lock.yaml,yarn.lock,LICENSE,README*,CHANGELOG*,CNAME,.gitattributes,.eslintrc-auto-import.json,.gitignore,prettier.config.js,stylelint.config.js,commitlint.config.js,.stylelintignore,.prettierignore,.gitpod.yml,.eslintrc.js,.eslintignore"
"package.json": "pnpm-lock.yaml,yarn.lock,LICENSE,README*,CHANGELOG*,CNAME,.gitattributes,.gitignore,prettier.config.js,stylelint.config.js,commitlint.config.js,.stylelintignore,.prettierignore,.gitpod.yml,.eslintrc.js,.eslintignore"
},
"terminal.integrated.scrollback": 10000,
"nuxt.isNuxtApp": false
"terminal.integrated.scrollback": 10000
}

26711
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,7 @@
"element-plus": "2.8.4",
"fast-xml-parser": "^4.3.2",
"highlight.js": "^11.9.0",
"idb": "^8.0.0",
"jsencrypt": "^3.3.2",
"lodash-es": "^4.17.21",
"markdown-it": "^14.1.0",

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

@ -24,7 +24,16 @@ export const getUserPage = (params: PageParam) => {
// 查询所有用户列表
export const getAllUser = () => {
return request.get({ url: '/system/user/all' })
return request.get({ url: '/system/user/simple-list' })
}
/**
* 获取部门成员
* @param id
* @returns
*/
export const getDeptUser = (id: number): Promise<UserVO[]> => {
return request.get({ url: '/system/user/simple-list?id=' + id })
}
// 查询用户详情

View File

@ -1,5 +1,6 @@
<script lang="tsx">
import { defineComponent, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Message } from '@/layout/components//Message'
import { Collapse } from '@/layout/components/Collapse'
import { UserInfo } from '@/layout/components/UserInfo'
@ -10,6 +11,7 @@ import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
import RouterSearch from '@/components/RouterSearch/index.vue'
import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@/components/Icon'
const { getPrefixCls, variables } = useDesign()
@ -41,6 +43,10 @@ const locale = computed(() => appStore.getLocale)
// 消息图标
const message = computed(() => appStore.getMessage)
const goToChat = () => {
window.open(window.location.host + '/chat', '_blank')
}
export default defineComponent({
name: 'ToolHeader',
setup() {
@ -62,6 +68,13 @@ export default defineComponent({
</div>
) : undefined}
<div class="h-full flex items-center">
<div onClick={goToChat}>
<Icon
icon="ep:chat-dot-round"
color="var(--top-header-text-color)"
class="custom-hover"
/>
</div>
{screenfull.value ? (
<Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
) : undefined}

View File

@ -1,4 +1,5 @@
import { Layout } from '@/utils/routerHelper'
import ChatPage from '../../views/chat/ChatPage/Index.vue'
const { t } = useI18n()
/**
@ -641,6 +642,11 @@ const remainingRouter: AppRouteRecordRaw[] = [
component: () => import('@/views/iot/device/detail/index.vue')
}
]
}, {
path: '/chat',
component: ChatPage,
name: 'chat',
meta: { hidden: false }
}
]

75
src/store/indexedDB.ts Normal file
View File

@ -0,0 +1,75 @@
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import { ConversationModelType } from '@/views/chat/types/types'
import { openDB, DBSchema, IDBPDatabase } from 'idb'
// Define your database schema
interface MyDB extends DBSchema {
Conversations: {
key: string
value: ConversationModelType
}
}
let dbPromise: Promise<IDBPDatabase<MyDB>>
export const initDB = () => {
if (!dbPromise) {
try {
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' })
}
})
} catch (error) {
console.log(error)
}
}
return dbPromise
}
export const addConversation = async (conversation: ConversationModelType) => {
try {
const db = await initDB()
await db.put('Conversations', conversation)
} catch (error) {
console.error(conversation)
console.error(error)
}
}
export const getConversation = async (conversationNo: string) => {
try {
const db = await initDB()
return await db.get('Conversations', conversationNo)
} catch (error) {
console.error(error)
}
}
export const deleteConversation = async (conversationNo: string) => {
try {
const db = await initDB()
await db.delete('Conversations', conversationNo)
} catch (error) {
console.error(error)
}
}
export const getAllConversations = async () => {
try {
const db = await initDB()
return await db.getAll('Conversations')
} catch (error) {
console.log(error)
}
}

View File

@ -0,0 +1,57 @@
<template>
<view class="flex h-full flex-1">
<ToolSection @menu-select-change="toolMenuSelectChange" />
<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="chatStore.bussinessType === MENU_LIST_ENUM.FRIENDS && useFriendStore.currentFriend" />
</view>
</template>
<script lang="ts" setup>
/**
* TODO: Replace me with comment, and tell the main subject of this page
*/
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 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) => {
setBussinessType(value)
}
</script>

View File

@ -0,0 +1,59 @@
/*
* @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
*/
import request from '@/config/axios'
import { MessageModelType } from '../types/types'
export interface SendMsg {
clientMessageId: string
receiverId: number
conversationType: number
contentType: number
content: string
}
export interface SessionMsgReq {
sendTime: string
receiverId: number
userId: number
conversationType: number
}
/**
* 消息接口
*/
export default class MessageApi {
/**
* 发送消息
* @param data SendMsg
* @returns Promise<{ id: number; sendTime: number }>
*/
static send(data: SendMsg): Promise<{ id: number; sendTime: number }> {
return request.post({ url: '/im/message/send', data })
}
/**
* 获取会话消息
* @param data SessionMsgReq
* @returns Promise<Array<MessageModelType>>
*/
static getSessionMsg(params: SessionMsgReq): Promise<Array<MessageModelType>> {
return request.get({ url: '/im/message/list', params })
}
/**
* 获取所有消息
* @param data { sequence: number; size: number }
* @returns Promise<Array<MessageModelType>>
*/
static getMessageForAllSession(params: {
sequence: number
size: number
}): Promise<Array<MessageModelType>> {
return request.get({ url: '/im/message/pull', params })
}
}

View File

@ -0,0 +1,40 @@
/*
* @Author: dylan.may@qq.com
* @Date: 2024-10-16 11:30:31
* @Last Modified by: dylan.may@qq.com
* @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
}
/**
* 会话接口
*/
export default class SessionApi {
/**
* 获取会话列表
* @returns Promise<Array<ChatConversation>>
*/
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

@ -0,0 +1,18 @@
<template>
<view
class="flex items-center w-full border-b-1 border-b-gray border-b-solid"
style="height: 60px; min-height: 60px"
>
<label class="text-black text-size-xl font-medium mx-4">{{
chatStore.currentSession?.nickname || chatStore.currentSession?.name
}}</label>
</view>
</template>
<script lang="ts" setup>
import { useChatStore } from '../../store/chatstore'
defineOptions({ name: 'ChatHeader' })
const chatStore = useChatStore()
</script>

View File

@ -0,0 +1,60 @@
<template>
<view
class="flex flex-col items-start w-full border-b-1 border-b-gray border-b-solid flex-1 border-b-1 border-b-gray border-b-solid py-2 overflow-scroll"
ref="listBoxRef"
>
<template v-for="item in chatStore.currentSession?.msgList">
<TextMsg
v-if="item.contentType === ContentType.TEXT"
:key="item.clientMessageId"
:message="item"
class="py-1"
/>
<ImageMsg
v-if="item.contentType === ContentType.IMAGE"
:key="item.clientMessageId"
:message="item"
class="py-1"
/>
</template>
</view>
</template>
<script lang="ts" setup>
import { useChatStore } from '../../store/chatstore'
import TextMsg from '@/views/chat/components/Message/TextMsg.vue'
import ImageMsg from '@/views/chat/components/Message/ImageMsg.vue'
import { ContentType } from '../../types/types'
defineOptions({ name: 'ChatMessage' })
const chatStore = useChatStore()
const listBoxRef = ref<HTMLElement | null>(null)
// TODO @dylanmsg 尽量使用 message 哈。非必要不缩写
const msgListLength = computed(() => {
return chatStore.currentSession ? chatStore.currentSession.msgList.length : 0
})
const scrollToBottom = () => {
nextTick(() => {
if (listBoxRef.value) {
console.log('scrollToBottom')
listBoxRef.value.scrollTop = listBoxRef.value.scrollHeight
}
})
}
watch(msgListLength, (newLength, oldLength) => {
if (newLength > oldLength) {
scrollToBottom()
}
})
watch(
() => chatStore.currentSessionIndex,
() => {
scrollToBottom()
}
)
</script>

View File

@ -0,0 +1,37 @@
<template>
<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"
:key="item.id"
:index="index"
:conversation="item"
@click="() => onSessionItemClick(index)"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import SessionItem from '@/views/chat/components/ConversationItem/index.vue'
import { useChatStoreWithOut } from '../../store/chatstore'
import { onMounted } from 'vue'
defineOptions({ name: 'Session' })
const chatStore = useChatStoreWithOut()
const { setCurrentConversation, setCurrentSessionIndex, getConversationList } = useChatStoreWithOut()
onMounted(() => {
getConversationList()
// set default conversation
nextTick(() => {
setCurrentConversation()
})
})
const onSessionItemClick = (index: number) => {
setCurrentSessionIndex(index)
setCurrentConversation()
}
</script>

View File

@ -0,0 +1,85 @@
<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="props.conversation.avatar" />
<view class="flex flex-col flex-1 tems-end h-full">
<label class="text-black-c text-size-sm font-medium text-ellipsis text-nowrap" :class="namefontColor()">{{
props.conversation.nickname || '' }}</label>
<label class="text-gray-f text-size-sm text-ellipsis text-nowrap mr-1" :class="timefontColor()">{{ lastMessage
}}</label>
</view>
<view class="flex items-end h-full flex-col">
<label class="text-gray-f text-size-xs text-nowrap" :class="timefontColor()">{{
formatPast(new Date(props.conversation.updateTime), 'YYYY/MM/DD')
}}</label>
</view>
</view>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { ContentType, ConversationModelType } from '../../types/types'
import { formatPast } from '@/utils/formatTime'
import { useChatStore } from '../../store/chatstore.js'
import TextMessage from '../../model/TextMessage.js';
defineOptions({ name: 'SessionItem' })
const props = defineProps({
conversation: {
type: Object as PropType<ConversationModelType>,
default: () => { }
},
index: Number
})
const chatStore = useChatStore()
const bgColor = () => {
return props.index === chatStore.currentSessionIndex ? 'bg-blue' : 'bg-white'
}
const namefontColor = () => {
return props.index === chatStore.currentSessionIndex ? 'text-white' : 'nameColor'
}
const timefontColor = () => {
return props.index === chatStore.currentSessionIndex ? 'text-white' : 'timeColor'
}
/**
* TODO: 修改为后端计算,否则在没有打开聊天窗口的时候,没有数据。
*/
const lastMessage = computed(() => {
if (!props.conversation.msgList || props.conversation.msgList.length === 0) {
return props.conversation.lastMessageDescription || ''
}
const lastIndex = props.conversation.msgList.length - 1
const lastMessage = props.conversation.msgList[lastIndex]
if (!lastMessage) {
return ''
}
if (lastMessage.contentType === ContentType.TEXT) {
return (lastMessage as TextMessage).content
} else if (lastMessage.contentType === ContentType.IMAGE) {
return '[图片]'
} else {
return '[其他]'
}
})
</script>
<style lang="scss" scoped>
.timeColor {
color: #999;
}
.nameColor {
color: black;
}
</style>

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

@ -0,0 +1,41 @@
<template>
<view
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
style="width: 8rem; height: 8rem"
class="rounded"
:src="friendStore.currentFriend.avatar"
/>
<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

@ -0,0 +1,32 @@
<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 :class="fontColor()">{{ friend.name }}</label>
</view>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { useFriendStore } from '../../store/friendstore'
import Friend from '../../model/Friend'
defineOptions({ name: 'FriendItem' })
const props = defineProps({
friend: {
type: Object as PropType<Friend>,
default: () => {}
}
})
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

@ -0,0 +1,36 @@
<template>
<view
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">
<FriendItem
v-for="(item, index) in friendStore.friendList"
:key="item.id"
:index="index"
:friend="item"
@click="() => onFriendClick(item)"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import FriendItem from '../FriendItem/Index.vue'
import { useFriendStore } from '../../store/friendstore'
import { onMounted } from 'vue'
import Friend from '../../model/Friend'
defineOptions({ name: 'Friends' })
const friendStore = useFriendStore()
onMounted(async () => {
// set default conversation
// await friendStore.fetchFriend()
})
const onFriendClick = (friend: Friend) => {
console.log('====>', friend)
friendStore.setCurrentFriend(friend)
}
</script>

View File

@ -0,0 +1,73 @@
<template>
<view
class="flex flex-col items-center w-full border-b-1 border-b-gray border-b-solid"
style="height: 248px; min-height: 248px"
>
<view class="flex p-2 w-full" style="height: 32px">
<Icon icon="ep:apple" color="var(--top-header-text-color)" class="custom-hover" />
<Icon icon="ep:folder" color="var(--top-header-text-color)" class="custom-hover" />
<Icon icon="ep:chat-line-square" color="var(--top-header-text-color)" class="custom-hover" />
</view>
<ElInput
type="textarea"
class="h-full"
clearable
v-model="chatStore.inputText"
input-style="border: none !important; box-shadow: none !important"
:autosize="{ minRows: 10, maxRows: 11 }"
placeholder="Press Enter to send"
@keydown.enter="onEnter"
/>
</view>
</template>
<script lang="ts" setup>
import TextMessage from '../../model/TextMessage'
import { useChatStoreWithOut } from '../../store/chatstore'
import { CONVERSATION_TYPE } from '../../types/types'
import { SendStatus, MessageRole, ContentType } from '../../types/types'
import { useUserStoreWithOut } from '../../../../store/modules/user';
import { ElNotification } from 'element-plus';
defineOptions({ name: 'InputSection' })
const chatStore = useChatStoreWithOut()
const onEnter = () => {
console.log('enter pressed')
if (!chatStore.inputText.trim()) {
ElNotification({
title: '温馨提示',
message: '请输入内容',
type: 'warning'
})
return
}
const msg = createTextMessage(chatStore.inputText.trim())
chatStore.addMessageToCurrentSession(msg)
}
const createTextMessage = (content: string): TextMessage => {
const userStore = useUserStoreWithOut()
// 部分信息从account信息里面获取
const msg = new TextMessage(
'',
userStore.user.avatar,
userStore.user.nickname,
new Date().getTime(),
false,
content,
MessageRole.SELF,
SendStatus.SENDING,
chatStore.currentSession?.id || '',
userStore.user.id,
chatStore.currentSession ? chatStore.currentSession.targetId : 0,
chatStore.currentSession?.type || CONVERSATION_TYPE.SINGLE,
chatStore.currentSession?.senderId || ''
)
return msg
}
</script>

View File

@ -0,0 +1,32 @@
<template>
<view
class="flex w-full"
:class="props.message.role === MessageRole.SELF ? 'flex-row-reverse' : 'flex-row'"
>
<el-avatar shape="square" size="default" class="mx-2" :src="props.message.avatar" />
<view class="flex flex-col" :class="props.message.role === MessageRole.SELF ? 'items-end' : 'items-start'">
<label class="text-xs text-gray-4 mb-1">{{ props.message.nickname }}</label>
<view class="flex items-center">
<el-icon v-if="props.message.sendStatus === SendStatus.SENDING" class="is-loading"
><Loading
/></el-icon>
<slot name="content"></slot>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { MessageModelType, MessageRole, SendStatus } from '../../types/types'
defineOptions({ name: 'BaseMessage' })
const props = defineProps({
message: {
type: Object as PropType<MessageModelType>,
default: () => {}
}
})
</script>

View File

@ -0,0 +1,28 @@
<template>
<BaseMesageLayout>
<template #content>
<view>
<label>{{ props.message.content }}</label>
</view>
</template>
</BaseMesageLayout>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { useChatStore } from '../../store/chatstore'
import { onMounted } from 'vue'
import { MessageModelType } from '../../types/types'
import BaseMesageLayout from './BaseMsg.vue'
defineOptions({ name: 'ImageMessage' })
const props = defineProps({
message: {
type: Object as PropType<MessageModelType>,
default: () => {}
}
})
const { sessionList, setCurrentConversation, setCurrentSessionIndex } = useChatStore()
</script>

View File

@ -0,0 +1,24 @@
<template>
<BaseMesageLayout :message="props.message">
<template #content>
<view class="p-3 bg-gray-2 rounded">
<label>{{ props.message.content }}</label>
</view>
</template>
</BaseMesageLayout>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import BaseMesageLayout from './BaseMsg.vue'
import TextMessage from '../../model/TextMessage'
defineOptions({ name: 'TextMessage' })
const props = defineProps({
message: {
type: Object as PropType<TextMessage>,
default: () => {}
}
})
</script>

View File

@ -0,0 +1,49 @@
<template>
<view class="flex flex-col items-center bg-gray-2 h-full py-2" style="width: 80px; min-width: 80px">
<el-avatar shape="square" />
<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(
() => selectItem.value,
(newValue) => {
emit('menuSelectChange', newValue)
}
)
const onConversatonClicked = () => {
setBussinessType(MENU_LIST_ENUM.CONVERSATION)
}
const onFriendsClicked = () => {
setBussinessType(MENU_LIST_ENUM.FRIENDS)
}
</script>

View File

@ -0,0 +1,45 @@
import { ConversationType, MessageModelType } from '../types/types'
export default class BaseConversation {
public id: string
public avatar: string
public name: string
public description: string
public createTime: number
public updateTime: number
public unreadMessagesCount: number
public msgList: Array<MessageModelType>
public type: ConversationType
public targetId: number
public senderId: number
public conversationNo: string
public lastMessageDescription: string
constructor(
id: string,
avatar: string,
name: string,
lastMessageDescription: string,
createTime: number,
updateTime: number,
unreadMessagesCount: number,
msgList: Array<MessageModelType>,
type: ConversationType,
targetId: number,
senderId: number,
conversationNo: string
) {
this.id = id
this.avatar = avatar
this.name = name
this.lastMessageDescription = lastMessageDescription
this.createTime = createTime
this.updateTime = updateTime
this.unreadMessagesCount = unreadMessagesCount
this.msgList = msgList
this.type = type
this.targetId = targetId
this.senderId = senderId
this.conversationNo = conversationNo
}
}

View File

@ -0,0 +1,59 @@
import { MessageRole, ContentType, SendStatus } from '../types/types'
export default class BaseMessage {
id?: string
avatar?: string
nickname?: string
createTime: number
isRead: boolean
role: MessageRole
sendStatus: SendStatus
contentType: ContentType
conversationId: string
clientMessageId: string
senderId: number
receiverId: number
conversationType: number
conversationUserId: number
constructor(
id: string,
avatar: string,
nickname: string,
createTime: number,
isRead: boolean,
role: MessageRole,
sendStauts: SendStatus,
contentType: ContentType,
conversationId: string,
senderId: number,
receiverId: number,
conversationType: number,
conversationUserId: number
) {
this.id = id
this.avatar = avatar
this.nickname = nickname
this.createTime = createTime
this.isRead = isRead
this.role = role
this.sendStatus = sendStauts
this.contentType = contentType
this.conversationId = conversationId
this.senderId = senderId
this.receiverId = receiverId
this.clientMessageId = this.generateClientMessageId()
this.conversationType = conversationType
this.conversationUserId = conversationUserId
}
private generateClientMessageId() {
const timestamp = Date.now().toString() // 获取当前时间戳
const randomPart = 'xxxx-xxxx-4xxx-yxxx-xxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
return `${timestamp}-${randomPart}`
}
}

View File

@ -0,0 +1,5 @@
export interface BaseResponse<T> {
code: number // 0表示成功其他表示失败
message: string // 返回的信息,可以是成功或错误信息
data: T // 泛型数据,成功时返回数据,失败时为 null
}

View File

@ -0,0 +1,36 @@
import BaseConversation from './BaseConversation'
import BaseMessage from './BaseMessage'
// TODO @dylan这些 ts 类,是不是可以搞个 types.ts然后放到 api/im 目录下?放在一个文件里
export class ChatConversation extends BaseConversation {
constructor(
id: string,
avatar: string,
name: string,
lastMessageDescription: string,
createTime: number,
updateTime: number,
unreadMessagesCount: number,
msgList: Array<BaseMessage>,
type: number,
targetId: number,
senderId: number,
conversationNo: string
) {
super(
id,
avatar,
name,
lastMessageDescription,
createTime,
updateTime,
unreadMessagesCount,
msgList,
type,
targetId,
senderId,
conversationNo
)
}
}

View File

@ -0,0 +1,19 @@
export default class Friend {
public id: string
public avatar: string
public name: string
public description: string
public createTime: number
public deptId: number
public deptName: string
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

@ -0,0 +1,37 @@
import { MessageRole, ContentType, SendStatus } from '@/views/chat/types/types'
import BaseMessage from './BaseMessage'
export default class ImageMessage extends BaseMessage {
content: string
constructor(
id: string,
avatar: string,
nickname: string,
createTime: number,
isRead: boolean,
content: string,
role: MessageRole,
sendStatus: SendStatus,
conversationId: string,
receiverId: number,
conversationType: number,
conversationUserId: number
) {
super(
id,
avatar,
nickname,
createTime,
isRead,
role,
sendStatus,
ContentType.IMAGE,
conversationId,
receiverId,
conversationType,
conversationUserId
)
this.content = content
}
}

View File

@ -0,0 +1,62 @@
import { MessageRole, ContentType, SendStatus, ImMessageContent } from '@/views/chat/types/types'
import BaseMessage from './BaseMessage'
export default class TextMessage extends BaseMessage {
content: string
constructor(
id: string,
avatar: string,
nickname: string,
createTime: number,
isRead: boolean,
content: string,
role: MessageRole,
sendStatus: SendStatus,
conversationId: string,
senderId: number,
receiverId: number,
conversationType: number,
conversationUserId: number
) {
super(
id,
avatar,
nickname,
createTime,
isRead,
role,
sendStatus,
ContentType.TEXT,
conversationId,
senderId,
receiverId,
conversationType,
conversationUserId
)
this.content = content
}
/**
* 消息转换
* @param websocketMessage
* @returns
*/
static fromWebsocket(websocketMessage: ImMessageContent): TextMessage {
return new TextMessage(
websocketMessage.id.toString(), // 服务端也应该返回一个clientMessageId
websocketMessage.senderAvatar,
websocketMessage.senderNickname,
new Date().getTime(), // TODO: 是否合理
false,
websocketMessage.content,
MessageRole.OTHER, // 可以去掉在使用的时候依据逻辑判断
SendStatus.SUCCESS,
'', // TODO: [dylan]
websocketMessage.senderId,
websocketMessage.receiverId,
websocketMessage.conversationType,
0
)
}
}

View File

@ -0,0 +1,335 @@
import { store } from '@/store/index'
import { defineStore } from 'pinia'
import BaseConversation from '../model/BaseConversation'
import BaseMessage from '../model/BaseMessage'
import { ConversationModelType, MessageRole, ContentType, SendStatus, MENU_LIST_ENUM, CONVERSATION_TYPE } from '../types/types'
import SessionApi from '../api/sessionApi'
import MessageApi, { SendMsg } from '../api/messageApi'
import { useUserStore, useUserStoreWithOut } from '@/store/modules/user'
import { formatDate } from '@/utils/formatTime'
import { addConversation, getAllConversations } from '@/store/indexedDB'
import { ChatConversation } from '../model/ChatConversation'
import { generateConversationNo } from './websocketStore'
// TODO @dylan是不是 chat => imsession => conversation这样统一一点哈。
interface ChatStoreModel {
sessionList: Array<ConversationModelType>
currentSession: ConversationModelType | null
currentSessionIndex: number
inputText: string,
bussinessType: number // conversation 1, friends 2
}
export const useChatStore = defineStore('chatStore', {
state: (): ChatStoreModel => ({
sessionList: [],
currentSession: null,
currentSessionIndex: 0,
inputText: '',
bussinessType: 1,
}),
getters: {
getSessionList(state: ChatStoreModel): Array<ConversationModelType> {
return state.sessionList
},
getCurrentSession(state: ChatStoreModel): ConversationModelType | null {
return state.currentSession
},
getCurrentSessionIndex(state: ChatStoreModel): number {
return state.currentSessionIndex
}
},
actions: {
addSession(session: BaseConversation) {
this.sessionList.push(session)
},
setCurrentConversation() {
this.currentSession = this.sessionList[this.currentSessionIndex]
this.fetchSessionMsg()
},
setCurrentSessionIndex(index: number) {
this.currentSessionIndex = index
},
setInputText(content: string) {
this.inputText = content
},
setBussinessType(type: number) {
this.bussinessType = type
},
async addMessageToCurrentSession<T extends BaseMessage>(message: T): Promise<void> {
this.currentSession?.msgList.push(message)
try {
const res = await MessageApi.send(message as unknown as SendMsg)
console.log(res)
if (res.id) {
// 更新发送状态
const updateMsg = {
...message,
id: res.id,
sendTime: res.sendTime,
sendStatus: SendStatus.SUCCESS
}
this.updateMsgToCurrentSession(updateMsg)
}
} catch (error) {
console.log(error)
const updateMsg = {
...message,
sendStatus: SendStatus.SUCCESS
}
this.updateMsgToCurrentSession(updateMsg)
} finally {
this.setInputText('')
}
},
addMessageToSesstion<T extends BaseMessage>(message: T): void {
// get the conversation from list
const conversationIndex = this.sessionList.findIndex((item) => {
return item.id === message.conversationId
})
const msgConversation = this.sessionList[conversationIndex]
// add the message
msgConversation?.msgList.push(message)
// replace the old Conversation
this.sessionList.splice(conversationIndex, 1, msgConversation)
},
/**
* 添加消息到会话
* @param message
*/
addMessageToConversation<T extends BaseMessage>(message: T): void {
const converstionNo = generateConversationNo(
message.senderId,
message.receiverId,
message.conversationType
)
const conversationIndex = this.sessionList.findIndex(
(item) => item.conversationNo === converstionNo
)
if (conversationIndex < 0) {
console.log('conversation not exist, create it')
// Todo
return
}
const msgConversation = this.sessionList[conversationIndex]
msgConversation.msgList.push(message)
// replace the old Conversation
this.sessionList.splice(conversationIndex, 1, msgConversation)
// 更新消息到indexeddb
addConversation(toRaw(msgConversation) as ChatConversation )
// 更新当前会话
if (conversationIndex === this.currentSessionIndex) {
this.setCurrentConversation()
}
},
/**
* 根据消息创建会话
* @param BaseMessage
* @param msg
*/
createConversation(BaseMessage: msg) {
},
/**
* 生成会话No
* @param id1
* @param id2
* @param conversationType
* @returns
*/
generateConversationNo(id1: string, id2: string, conversationType: CONVERSATION_TYPE): string {
const SINGLE_PREFIX = 's_'
const GROUP_PREFIX = 'g_'
const [smallId, largeId] = id1 < id2 ? [id1, id2] : [id2, id1];
if (conversationType === CONVERSATION_TYPE.SINGLE) {
return SINGLE_PREFIX + smallId + "_" + largeId
} else if (conversationType === CONVERSATION_TYPE.GROUP) {
return GROUP_PREFIX + smallId + "_" + largeId
}
return ''
},
/**
* 更新消息到当前会话
* @param updatedMsg
*/
updateMsgToCurrentSession<T extends BaseMessage>(updatedMsg: T): void {
if (this.currentSession) {
this.currentSession.msgList = this.currentSession?.msgList.map((item) => {
if (item.clientMessageId === updatedMsg.clientMessageId) {
return updatedMsg
} else {
return item
}
})
const rawCurrentSesstion = toRaw(this.currentSession)
rawCurrentSesstion.msgList =this.currentSession.msgList.map(item => toRaw(item))
console.log("raw", rawCurrentSesstion)
addConversation(rawCurrentSesstion as ChatConversation)
}
},
async getConversationList() {
try {
// 从数据库获取数据
const _conversationList = await getAllConversations()
if (_conversationList) {
// 加载到内存
// TODO:[dylan]处理排序
this.sessionList = _conversationList
}
} catch (error) {
console.log(error)
} finally{
// 本地没有数据的时候才请求接口
if (this.sessionList.length === 0) {
this.getSessionFromServer()
}
}
},
async getSessionFromServer() {
try {
const res = await SessionApi.getSessionList()
this.sessionList = res.map((item) => ({
...item,
updateTime: item.lastReadTime,
name: item.targetId,
targetId: item.targetId,
senderId: item.userId,
conversationNo: item.no,
unreadMessagesCount: item.unreadMessagesCount,
description: item.lastMessageDescription,
msgList: []
}))
// 同步到数据库
this.sessionList.forEach((item) => {
console.log(item)
addConversation(toRaw(item) as ChatConversation)
})
} catch (error) {
return error
}
},
async fetchSessionMsg() {
if (!this.currentSession) {
return
}
const userStore = useUserStoreWithOut()
try {
const res = await MessageApi.getSessionMsg({
receiverId: this.currentSession.targetId,
userId: this.currentSession.senderId,
// sendTime: formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')
conversationType: this.currentSession.type
})
this.currentSession.msgList = res.map((item) => {
return {
...item,
role: item.senderId === userStore.user.id ? MessageRole.SELF : MessageRole.OTHER,
nickname: item.senderNickname,
avatar: item.senderAvatar
}
})
addConversation(toRaw(this.currentSession) as ChatConversation)
} 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: []
}
}
}
})
export const useChatStoreWithOut = () => {
return useChatStore(store)
}

View File

@ -0,0 +1,113 @@
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,
selectedDepartmentId: number,
departmentList: DeptApi.DeptVO[]
}
export const useFriendStore = defineStore('friendStore', {
state: (): FriendStoreModel => ({
friendList: [],
currentFriend: null,
selectedDepartmentId: 0,
departmentList: []
}),
getters: {
getFriendList(state: FriendStoreModel): Array<Friend> {
return state.friendList
}
},
actions: {
addFriend(session: BaseConversation) {
this.friendList.push(session)
},
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

@ -0,0 +1,207 @@
import { defineStore } from 'pinia'
import { ref, onUnmounted } from 'vue'
import { getRefreshToken } from '@/utils/auth'
import { useChatStore } from './chatstore'
import {
ContentType,
ImMessageContent,
ImMessageReceiveResponse,
WEBSOCKET_MESSAGE_TYPE_ENUM
} from '../types/types'
import TextMessage from '../model/TextMessage'
import { debug } from 'console'
import { useUserStore } from '@/store/modules/user'
import BaseConversation from '../model/BaseConversation'
interface Message {
type: string
data: any
}
enum ImConversationTypeEnum {
SINGLE = 1,
GROUP = 2
// Add other conversation types if needed
}
export function generateConversationNo(
fromUserId: number,
receiverId: number,
conversationType: number
): string | null {
if (conversationType === ImConversationTypeEnum.SINGLE) {
return `s_${fromUserId}_${receiverId}`
} else if (conversationType === ImConversationTypeEnum.GROUP) {
return `g_${receiverId}`
}
return null
}
export const useWebSocketStore = defineStore('webSocket', () => {
const socket = ref<WebSocket | null>(null)
const messages = ref<Message[]>([])
const isConnected = ref(false)
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
// 初始化 WebSocket 连接
function connect() {
const refreshToken = getRefreshToken()
// 设置 WebSocket URL
if (refreshToken) {
console.log('refreshToken null')
}
const url = `ws://localhost:48080/infra/ws?token=${refreshToken}`
socket.value = new WebSocket(url)
socket.value.onopen = () => {
isConnected.value = true
console.log('WebSocket connected')
startHeartbeat()
}
// {"type":"im-message-receive",
// "content":
// "{\
// "id\":239,\
// "conversationType\":1,\
// "senderId\":144,\
// "senderNickname\":\"dylan\",\
// "senderAvatar\":\"http://192.168.0.208:48083/admin-api/infra/file/4/get/c34f9521ce6a2e21148f16b73ab652e578b5bb572dbc259a5043f754c19c8a3f.png\",\
// "receiverId\":1,\
// "contentType\":101,\
// "content\":\"111\",\
// "sendTime\":1731139168438,\
// "sequence\":2}"
// }
socket.value.onmessage = (event) => {
if (event.data === 'pong') {
return
}
try {
const websoketMessage = JSON.parse(event.data) as ImMessageReceiveResponse
if (websoketMessage.type === WEBSOCKET_MESSAGE_TYPE_ENUM.IM_MESSAGE_RECEIVE.toString()) {
const socketChatMessage = JSON.parse(websoketMessage.content) as ImMessageContent
// 暂不处理自己发送的消息
if (socketChatMessage.senderId === useUserStore().user.id) {
return
}
if (socketChatMessage.contentType === ContentType.TEXT) {
const chatStore = useChatStore()
const localTextMessage = TextMessage.fromWebsocket(socketChatMessage)
chatStore.addMessageToConversation(localTextMessage)
} else if (socketChatMessage.contentType === ContentType.IMAGE) {
} else if (socketChatMessage.contentType === ContentType.AUDIO) {
}
} 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]
}
console.log('Received message:', websoketMessage)
} catch (error) {
console.info(error)
}
// messages.value.push(message)
}
socket.value.onclose = () => {
isConnected.value = false
console.log('WebSocket disconnected')
reconnect()
}
socket.value.onerror = (error) => {
console.error('WebSocket error:', error)
isConnected.value = false
reconnect()
}
}
// 发送消息
function sendMessage(message: Message) {
if (socket.value && isConnected.value) {
socket.value.send(JSON.stringify(message))
}
}
/**
* 发送心跳消息
*/
function sendHeartBeat() {
if (socket.value && isConnected.value) {
socket.value.send('ping')
}
}
// 断开 WebSocket 连接
function disconnect() {
if (socket.value) {
socket.value.close()
socket.value = null
}
stopHeartbeat()
clearTimeout(reconnectTimer!)
}
// 自动重连逻辑
function reconnect() {
stopHeartbeat()
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
console.log('Reconnecting WebSocket...')
connect()
}, 3000) // 3秒后重连
}
// 启动心跳
function startHeartbeat() {
if (heartbeatTimer) clearInterval(heartbeatTimer)
heartbeatTimer = setInterval(() => {
if (socket.value && isConnected.value) {
sendHeartBeat()
console.log('Heartbeat sent')
}
}, 5000) // 每5秒发送一次心跳
}
// 停止心跳
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
// 自动断开连接,清理资源
onUnmounted(() => {
disconnect()
})
return {
connect,
disconnect,
sendMessage,
messages,
isConnected,
generateConversationNo
}
})

View File

@ -0,0 +1,63 @@
import BaseConversation from '../model/BaseConversation'
import BaseMessage from '../model/BaseMessage'
import { ChatConversation } from '../model/ChatConversation'
import ImageMessage from '../model/ImageMessage'
import TextMessage from '../model/TextMessage'
export enum MessageRole {
SELF = 1,
SYSTEM = 2,
OTHER = 3
}
export enum SendStatus {
FAILURE = 1,
SENDING = 2,
SUCCESS = 3
}
export enum ContentType {
TEXT = 101,
IMAGE = 102,
AUDIO = 103,
SYSTEM = 1400
}
export const enum MENU_LIST_ENUM {
CONVERSATION = 1,
FRIENDS = 2
}
export const enum CONVERSATION_TYPE {
SINGLE = 1,
GROUP = 3,
NOTIFICATION = 4
}
export enum WEBSOCKET_MESSAGE_TYPE_ENUM {
IM_MESSAGE_RECEIVE = 'im-message-receive',
IM_CONVERSATION_ADD = 'im-conversation-add'
}
export type MessageModelType = BaseMessage | TextMessage | ImageMessage
export type ConversationModelType = BaseConversation | ChatConversation
export type ConversationType = CONVERSATION_TYPE
export type ImMessageReceiveResponse = {
type: WEBSOCKET_MESSAGE_TYPE_ENUM
content: string
};
export type ImMessageContent = {
id: number;
conversationType: number;
senderId: number;
senderNickname: string;
senderAvatar: string;
receiverId: number;
contentType: number;
content: string;
sendTime: number; // Use `Date` if you'd prefer the time to be a `Date` object
sequence: number;
}