Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d599ca91c6 | |||
| 5c1bb25237 | |||
| e5b90372a6 | |||
| 5feb3e6815 | |||
| 755bf1bb08 | |||
| 0d347643ca | |||
| f3968db2e0 | |||
| 90461a8cdf | |||
| d8d3366687 | |||
| 90619542c8 | |||
| d2a212fb80 | |||
| aaba03d001 | |||
| 5b8b51a894 | |||
| e3f8a3a94b |
100
.vscode/settings.json
vendored
100
.vscode/settings.json
vendored
@ -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
26711
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
|
||||
// 查询部门(精简)列表
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
// 查询用户详情
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
75
src/store/indexedDB.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
57
src/views/chat/ChatPage/Index.vue
Normal file
57
src/views/chat/ChatPage/Index.vue
Normal 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>
|
||||
59
src/views/chat/api/messageApi.ts
Normal file
59
src/views/chat/api/messageApi.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
40
src/views/chat/api/sessionApi.ts
Normal file
40
src/views/chat/api/sessionApi.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
18
src/views/chat/components/ChatHeader/Index.vue
Normal file
18
src/views/chat/components/ChatHeader/Index.vue
Normal 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>
|
||||
60
src/views/chat/components/ChatMessage/Index.vue
Normal file
60
src/views/chat/components/ChatMessage/Index.vue
Normal 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 @dylan:msg 尽量使用 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>
|
||||
37
src/views/chat/components/Conversation/index.vue
Normal file
37
src/views/chat/components/Conversation/index.vue
Normal 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>
|
||||
85
src/views/chat/components/ConversationItem/index.vue
Normal file
85
src/views/chat/components/ConversationItem/index.vue
Normal 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>
|
||||
53
src/views/chat/components/Department/components/TreeNode.vue
Normal file
53
src/views/chat/components/Department/components/TreeNode.vue
Normal 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>
|
||||
28
src/views/chat/components/Department/components/TreeView.vue
Normal file
28
src/views/chat/components/Department/components/TreeView.vue
Normal 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>
|
||||
63
src/views/chat/components/Department/index.vue
Normal file
63
src/views/chat/components/Department/index.vue
Normal 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>
|
||||
41
src/views/chat/components/FriendDetail/Index.vue
Normal file
41
src/views/chat/components/FriendDetail/Index.vue
Normal 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>
|
||||
32
src/views/chat/components/FriendItem/Index.vue
Normal file
32
src/views/chat/components/FriendItem/Index.vue
Normal 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>
|
||||
36
src/views/chat/components/Friends/Index.vue
Normal file
36
src/views/chat/components/Friends/Index.vue
Normal 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>
|
||||
73
src/views/chat/components/InputSection/Index.vue
Normal file
73
src/views/chat/components/InputSection/Index.vue
Normal 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>
|
||||
32
src/views/chat/components/Message/BaseMsg.vue
Normal file
32
src/views/chat/components/Message/BaseMsg.vue
Normal 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>
|
||||
28
src/views/chat/components/Message/ImageMsg.vue
Normal file
28
src/views/chat/components/Message/ImageMsg.vue
Normal 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>
|
||||
24
src/views/chat/components/Message/TextMsg.vue
Normal file
24
src/views/chat/components/Message/TextMsg.vue
Normal 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>
|
||||
49
src/views/chat/components/ToolSection/Index.vue
Normal file
49
src/views/chat/components/ToolSection/Index.vue
Normal 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>
|
||||
45
src/views/chat/model/BaseConversation.ts
Normal file
45
src/views/chat/model/BaseConversation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
59
src/views/chat/model/BaseMessage.ts
Normal file
59
src/views/chat/model/BaseMessage.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
5
src/views/chat/model/BaseResponse.ts
Normal file
5
src/views/chat/model/BaseResponse.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface BaseResponse<T> {
|
||||
code: number // 0表示成功其他表示失败
|
||||
message: string // 返回的信息,可以是成功或错误信息
|
||||
data: T // 泛型数据,成功时返回数据,失败时为 null
|
||||
}
|
||||
36
src/views/chat/model/ChatConversation.ts
Normal file
36
src/views/chat/model/ChatConversation.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
19
src/views/chat/model/Friend.ts
Normal file
19
src/views/chat/model/Friend.ts
Normal 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
|
||||
}
|
||||
}
|
||||
37
src/views/chat/model/ImageMessage.ts
Normal file
37
src/views/chat/model/ImageMessage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
62
src/views/chat/model/TextMessage.ts
Normal file
62
src/views/chat/model/TextMessage.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
335
src/views/chat/store/chatstore.ts
Normal file
335
src/views/chat/store/chatstore.ts
Normal 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 => im;session => 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)
|
||||
}
|
||||
113
src/views/chat/store/friendstore.ts
Normal file
113
src/views/chat/store/friendstore.ts
Normal 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)
|
||||
}
|
||||
207
src/views/chat/store/websocketStore.ts
Normal file
207
src/views/chat/store/websocketStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
63
src/views/chat/types/types.ts
Normal file
63
src/views/chat/types/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user