This commit is contained in:
oneao committed 2025-12-28 22:35:36 +08:00
1 parent e98ab295c4
commit 92fb1b74a7
22 files changed
+1204 -45

No files matched your search

+1 -1
View File
@@ -1,2 +1,2 @@
VITE_SERVER_BASEURL = 'http://127.0.0.1:8084'
VITE_WEBSOCKET_BASEURL = 'ws://127.0.0.1:8083/websocket'
VITE_WEBSOCKET_BASEURL = 'ws://127.0.0.1:8090/ws'
+15 -4
View File
@@ -1,20 +1,32 @@
<script lang="ts" setup>
import type { PostTag } from './types/base'
import { ElMessage } from 'element-plus'
import { onMounted } from 'vue'
import { onBeforeUnmount, onMounted } from 'vue'
import { initBaseApi } from '@/request/api/base'
import { useCommonStore } from '@/store/common'
import { getPostTagApi } from './request/api/post'
import { useUserStore } from './store/user'
import { useWebSocket } from './store/websocket'
const websocket = useWebSocket()
const commonOptions = useCommonStore()
const userStore = useUserStore()
onMounted(() => {
websocket.connect()
if (userStore.isLogined) {
websocket.setAuthToken(userStore.token)
}
handleInitData()
})
onBeforeUnmount(() => {
websocket.close()
})
function handleInitData() {
initBaseApi().then((res) => {
console.log(res)
if (res.isSuccess) {
commonOptions.areaOptions.splice(0, commonOptions.areaOptions.length, ...res.data.areas || [])
commonOptions.industryOptions.splice(0, commonOptions.industryOptions.length, ...res.data.industries || [])
@@ -29,7 +41,6 @@ function handleInitData() {
getPostTagApi()
.then((res) => {
console.log(res)
if (res.isSuccess) {
commonOptions.postTagOptions.splice(0, commonOptions.postTagOptions.length, ...res.data || [])
}
@@ -6,7 +6,9 @@ import {
import { useRoute, useRouter } from 'vue-router'
import SafeImage from '@/components/SafeImage.vue'
import { useUserStore } from '@/store/user'
import { useWebSocket } from '@/store/websocket'
const websocket = useWebSocket()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
@@ -25,6 +27,7 @@ function navigateTo(path: string) {
function exitLogin() {
userStore.clear()
websocket.logout()
router.push('/auth/login')
}
@@ -57,14 +60,16 @@ function isActive(path: string) {
<div class="action">
<i-tdesign:search class="action-item" />
<i-tdesign:notification v-if="userStore.isLogined" class="action-item" />
<i-tdesign:notification v-if="userStore.isLogined" class="action-item" @click="navigateTo('/im')" />
<template v-if="userStore.isLogined">
<el-divider direction="vertical" />
<el-dropdown>
<div class="user">
<SafeImage :url="userStore.user.avatar || userStore.company.logo" :size="36" round />
<el-badge v-if="websocket.connected.value" :value="12" is-dot>
<SafeImage :url="userStore.user.avatar || userStore.company.logo" :size="36" round />
</el-badge>
<div class="info">
<span class="name">{{ userStore.user.name }}</span>
<span class="company">{{ userStore.company.name }}</span>
@@ -183,6 +188,10 @@ function isActive(path: string) {
cursor: pointer;
outline: 0;
:deep(.el-badge__content.is-dot) {
width: 10px;
height: 10px;
}
.info {
display: flex;
flex-direction: column;
@@ -39,6 +39,10 @@ const router = createRouter({
path: '/company/detail',
component: () => import('@/views/company/detail.vue'),
},
{
path: '/im',
component: () => import('@/views/im/index.vue'),
},
{
path: '/settings',
component: () => import('@/views/settings/index.vue'),
@@ -0,0 +1,289 @@
import type { MessageRequest, MessageResponse } from '@/types/im'
import { readonly, ref } from 'vue'
import { MessageType } from '@/types/im'
interface UseWebSocketOptions {
url: string
heartbeatInterval?: number
pongTimeout?: number
reconnectInterval?: number
maxReconnectTimes?: number
}
/* ================== 单例 ================== */
let singleton: ReturnType<typeof createWebSocket> | null = null
function createWebSocket(options?: Partial<UseWebSocketOptions>) {
/* ================== 配置 ================== */
const config: Required<UseWebSocketOptions> = {
url:
options?.url
|| import.meta.env.VITE_WEBSOCKET_BASEURL
|| 'ws://127.0.0.1:8090/ws',
heartbeatInterval: options?.heartbeatInterval ?? 15_000,
pongTimeout: options?.pongTimeout ?? 5_000,
reconnectInterval: options?.reconnectInterval ?? 3_000,
maxReconnectTimes: options?.maxReconnectTimes ?? 5,
}
/* ================== 状态 ================== */
let ws: WebSocket | null = null
const connected = ref(false)
const authenticated = ref(false)
let cachedToken: string | null = null
let heartbeatTimer: number | null = null
let pongTimeoutTimer: number | null = null
let reconnectTimer: number | null = null
let reconnectTimes = 0
let manualClose = false
/* ================== 外部消息回调 ================== */
const messageHandlers: Array<(msg: MessageResponse) => void> = []
/* ================== 连接管理 ================== */
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
console.log('[WebSocket] 已存在连接或正在连接,跳过')
return
}
console.log('[WebSocket] 开始建立连接')
manualClose = false
ws = new WebSocket(config.url)
ws.onopen = () => {
connected.value = true
reconnectTimes = 0
console.log('[WebSocket] 连接成功')
startHeartbeat()
// 自动重新登录
if (cachedToken) {
console.log('[WebSocket] 自动重新发送 LOGIN')
login(cachedToken)
}
}
ws.onmessage = (evt) => {
const msg: MessageResponse = JSON.parse(evt.data)
handleMessage(msg)
}
ws.onclose = () => {
console.warn('[WebSocket] 连接关闭')
connected.value = false
authenticated.value = false
stopHeartbeat()
stopPongTimeout()
ws = null
if (!manualClose) {
tryReconnect()
}
}
ws.onerror = (err) => {
console.error('[WebSocket] 连接错误', err)
}
}
function close() {
console.log('[WebSocket] 主动关闭连接')
manualClose = true
stopHeartbeat()
stopPongTimeout()
clearReconnectTimer()
ws?.close()
ws = null
}
/* ================== 重连 ================== */
function tryReconnect() {
if (reconnectTimer || reconnectTimes >= config.maxReconnectTimes)
return
reconnectTimes++
console.warn(`[WebSocket] 第 ${reconnectTimes} 次重连中...`)
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null
connect()
}, config.reconnectInterval)
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
/* ================== 心跳 ================== */
function startHeartbeat() {
stopHeartbeat()
heartbeatTimer = window.setTimeout(() => {
sendRaw(MessageType.PING)
startPongTimeout()
startHeartbeat()
}, config.heartbeatInterval)
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearTimeout(heartbeatTimer)
heartbeatTimer = null
}
}
function startPongTimeout() {
stopPongTimeout()
pongTimeoutTimer = window.setTimeout(() => {
console.error('[WebSocket] 心跳超时,断开连接')
ws?.close()
}, config.pongTimeout)
}
function stopPongTimeout() {
if (pongTimeoutTimer) {
clearTimeout(pongTimeoutTimer)
pongTimeoutTimer = null
}
}
/* ================== 发送 ================== */
function sendRaw(type: MessageType, data?: any, to?: any) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn('[WebSocket] 发送失败,未连接')
return
}
const msg: MessageRequest = {
type,
from: 0,
to: to ?? null,
timestamp: Date.now(),
data: data ?? null,
}
ws.send(JSON.stringify(msg))
}
function send(type: MessageType, data?: any, to?: any) {
if (!authenticated.value && type !== MessageType.LOGIN) {
console.warn('[WebSocket] 未 LOGIN,禁止发送业务消息')
return
}
sendRaw(type, data, to)
}
/* ================== 登录相关 ================== */
function setAuthToken(token: string | null) {
cachedToken = token
if (token && connected.value) {
login(token)
}
}
function login(token: string) {
if (!connected.value || authenticated.value)
return
cachedToken = token
sendRaw(MessageType.LOGIN, { token })
}
function logout() {
cachedToken = null
authenticated.value = false
sendRaw(MessageType.LOGOUT)
}
/* ================== 消息处理(核心修改点) ================== */
function handleMessage(msg: MessageResponse) {
switch (msg.type) {
case MessageType.PONG:
stopPongTimeout()
return
case MessageType.LOGIN:
authenticated.value = true
console.log('[WebSocket] LOGIN 成功')
return
case MessageType.LOGOUT:
authenticated.value = false
console.log('[WebSocket] 已 LOGOUT')
return
default:
// 所有业务消息交给外部
messageHandlers.forEach(cb => cb(msg))
}
}
/* ================== 对外订阅 ================== */
function onMessage(handler: (msg: MessageResponse) => void) {
messageHandlers.push(handler)
}
/* ================== 业务 API ================== */
function sendPrivate(toUserId: number, content: string) {
send(MessageType.PRIVATE, { content }, toUserId)
}
function sendGroup(groupId: number, content: string) {
send(MessageType.GROUP, { groupId, content })
}
/* ================== 初始化 ================== */
connect()
return {
connect,
close,
setAuthToken,
login,
logout,
sendPrivate,
sendGroup,
onMessage,
connected: readonly(connected),
authenticated: readonly(authenticated),
}
}
/* ================== 对外 ================== */
export function useWebSocket(options?: Partial<UseWebSocketOptions>) {
if (!singleton) {
singleton = createWebSocket(options)
}
return singleton
}
@@ -0,0 +1,31 @@
export enum MessageType {
PING = 0,
PONG = 1,
LOGIN = 2,
LOGOUT = 3,
ONLINE = 4,
OFFLINE = 5,
PRIVATE = 6,
GROUP = 7,
}
export interface MessageRequest {
type: number
from: number
to: any
timestamp: number
data: any
}
export interface MessageResponse {
type: number
from: number
data: any
timestamp: number
code: number
message: string
}
@@ -5,11 +5,11 @@ import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import EmailCodeInput from '@/components/EmailCodeInput.vue'
import { loginByEmailCodeApi, loginByPasswordApi } from '@/request/api/auth'
import { authCodeSendApi } from '@/request/api/auth/code'
import { useUserStore } from '@/store/user'
import { useWebSocket } from '@/store/websocket'
import { AuthCodeChannel, AuthCodePurpose } from '@/types/auth/code'
import { validateEmail } from '@/utils/validate'
const websocket = useWebSocket()
const userStore = useUserStore()
const router = useRouter()
@@ -134,7 +134,7 @@ async function submitForm(formEl: FormInstance | undefined) {
ElMessage.success('登录成功')
userStore.setLoginInfo(res.data)
websocket.login(res.data.token)
router.push('/')
}
else {
@@ -0,0 +1,596 @@
<script lang="ts" setup>
import { Search } from '@element-plus/icons-vue'
import { useWebSocket } from '@/store/websocket'
const websocket = useWebSocket()
</script>
<template>
<div class="im">
<div class="im__container">
<div class="im__header">
<div class="im__desc">
<div class="im__logo">
<SvgIcon icon="solar:buildings-2-linear" :size="28" color="#fff" />
</div>
<div class="im__desc-extra">
<div class="im__title">
Link56 在线聊天
</div>
<div class="im__subtitle">
实时沟通 · 私聊 · 群聊
</div>
</div>
</div>
<div class="im__user">
<div class="im__user-info">
<div
class="im__user-status"
:class="websocket.connected.value ? 'im__user-status--online' : 'im__user-status--offline'"
>
<span>{{ websocket.connected.value ? '在线' : '离线' }}</span>
</div>
</div>
</div>
</div>
<div class="im__body">
<div class="im__list">
<div class="im__list-search">
<el-input placeholder="请根据名称搜索" :prefix-icon="Search" />
</div>
<div class="im__list-item">
<div class="im__list-header">
<SvgIcon icon="tabler:message-circle" :size="16" />
<span class="im__list-header-title">
单聊
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.im {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
background-color: #f5f6fa;
& *::-webkit-scrollbar {
width: 6px;
height: 6px;
}
& *::-webkit-scrollbar-track {
background-color: #fff;
border-radius: 3px;
}
& *::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
transition: background-color 0.2s ease;
}
& *::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.4);
}
.im__container {
width: 1300px;
height: 80%;
min-height: 700px;
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.im__header {
padding: 0 16px;
height: 64px;
border-bottom: 1px solid #e6e9ef;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
.im__desc {
display: flex;
align-items: center;
.im__logo {
background-color: #1553f1;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.im__desc-extra {
margin-left: 12px;
.im__title {
font-size: 16px;
font-weight: bold;
}
.im__subtitle {
font-size: 14px;
color: #666;
}
}
}
.im__user {
display: flex;
align-items: center;
height: 100%;
padding: 0px 8px;
&:hover {
cursor: pointer;
background-color: #f4f4f4;
}
.im__user-info {
margin-left: 8px;
display: flex;
flex-direction: column;
.im__user-name {
font-size: 14px;
}
.im__user-status {
display: flex;
align-items: center;
font-size: 16px;
color: #666;
}
.im__user-status--online {
color: #4caf50;
}
.im__user-status--offline {
color: #666;
}
}
}
}
.im__body {
display: flex;
flex: 1;
min-height: 0;
}
.im__list {
width: 300px;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
min-height: 0;
.im__list-search {
height: 56px;
padding: 10px;
border-bottom: 1px solid #e6e9ef;
}
.im__list-item {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
transition: background-color 0.25s ease;
.im__list-header {
font-size: 14px;
padding: 8px 10px;
background-color: #f8f9fc;
flex-shrink: 0;
display: flex;
align-items: center;
.im__list-header-title {
margin-left: 4px;
}
}
.im__list-body {
overflow-y: auto;
flex: 1;
min-height: 0;
.im__chat-item {
display: flex;
align-items: center;
height: 64px;
width: 100%;
padding: 0 10px;
overflow-x: hidden;
&:hover {
background-color: #f5f7fa;
cursor: pointer;
}
.im__chat-item-avatar {
flex-shrink: 0;
}
.im__chat-item-info {
display: flex;
flex-direction: column;
flex: 1;
margin-left: 10px;
min-width: 0;
.im__chat-item-main,
.im__chat-item-message {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.im__chat-item-message:has(.im__chat-item-last-message:not(:empty)) {
margin-top: 4px;
}
.im__chat-item-name {
font-size: 14px;
font-weight: 500;
}
.im__chat-item-last-time,
.im__chat-item-last-message {
flex-shrink: 0;
color: #666;
font-size: 12px;
}
.im__chat-item-last-message,
.im__chat-item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.im__chat-item-last-time {
margin-left: 8px;
line-height: 1;
}
.im__chat-item-unread-count {
display: flex;
margin-left: 8px;
}
}
}
.im__chat-item--selected {
position: relative;
background-color: #f0f6ff;
transition: background-color 0.25s ease;
&::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
background-color: #3b82f6;
border-radius: 2px;
box-shadow: 0 0 4px rgba(59, 130, 246, 0.4);
}
}
}
}
}
.im__window {
flex: 1;
background-color: #fff;
display: flex;
flex-direction: column;
.im__window-header {
height: 56px;
border-bottom: 1px solid #e6e9ef;
display: flex;
align-items: center;
flex-shrink: 0;
.im__window-peer {
display: flex;
align-items: center;
padding: 0px 8px;
.im__window-peer-info {
margin-left: 8px;
.im__window-peer-name {
font-size: 16px;
}
.im__window-peer-desc {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
.im__window-peer-status {
display: flex;
align-items: center;
margin-right: 4px;
.im__window-peer-status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin: 0px 4px;
}
}
.im__window-peer-status--online {
.im__window-peer-status-dot {
background-color: #24e68a;
}
}
.im__window-peer-status--offline {
.im__window-peer-status-dot {
background-color: #b3b7c2;
}
}
}
}
}
}
.im__window-body {
flex: 1;
display: flex;
min-height: 0;
:deep(.el-splitter-panel) {
scrollbar-width: auto;
}
.im__chat-main {
padding: 16px 8px;
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
scrollbar-gutter: stable both-edges;
overflow-y: auto;
min-width: 0;
background-color: #f8fafc;
height: 100%;
.im__chat-message-loading {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 14px;
}
.im__chat-message-wrapper {
width: 100%;
position: relative;
}
.im__chat-message-time {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #666;
}
.im__chat-message {
display: flex;
width: fit-content;
width: 100%;
.im__chat-message-avatar {
flex-shrink: 0;
}
.im__chat-message-body {
width: 100%;
.im__chat-message-name {
font-size: 14px;
line-height: 1;
}
.im__chat-message-content {
display: inline-flex;
align-items: center;
margin-top: 8px;
max-width: 70%;
}
.im__chat-message-content--text {
background-color: #ffffff;
color: #000000;
word-break: break-all;
padding: 6px 12px;
border-radius: 4px;
text-align: left;
}
}
}
.im__chat-message--from {
align-self: flex-end;
flex-direction: row-reverse;
.im__chat-message-body {
margin-right: 8px;
text-align: right;
.im__chat-message-content--text {
background-color: #3c73ff;
color: #fff;
}
.im__chat-message-name {
text-align: right;
}
.im__chat-message-status {
margin-right: 8px;
}
}
}
.im__chat-message--to {
text-align: left;
align-items: flex-start;
.im__chat-message-body {
margin-left: 8px;
.im__chat-message-status {
margin-left: 8px;
}
}
}
}
.im__chat-group {
width: 300px;
border-left: 1px solid #e6e9ef;
min-height: 0;
display: flex;
flex-direction: column;
flex-shrink: 0;
.im__group-section-header {
height: 46px;
border-bottom: 1px solid #e6e9ef;
display: flex;
align-items: center;
padding: 0px 8px;
justify-content: space-between;
.im__group-section-header-title {
display: flex;
align-items: center;
}
.im__group-section-header-text {
margin-left: 4px;
line-height: 1;
font-size: 14px;
}
.im__group-section-header--search {
cursor: pointer;
color: #666666;
&:hover {
color: #1553f1 !important;
}
}
}
.im__group-section-body {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
.im__group-item {
height: 64px;
padding: 0 10px;
display: flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
&:hover {
background-color: #f5f7fa;
}
.im__group-item-info {
margin-left: 8px;
.im__group-item-name {
line-height: 1;
font-size: 14px;
}
.im__group-item-desc {
margin-top: 8px;
font-size: 12px;
color: #666;
}
}
}
}
}
.im__chat-footer {
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
height: 100%;
.im__chat-toolbar {
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0px 8px;
gap: 8px;
.im__chat-toolbar-right {
margin-left: auto;
}
}
.im__chat-editor {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
flex: 1;
min-height: 0;
width: 100%;
scrollbar-gutter: stable both-edges;
.im__chat-editor-input {
padding: 4px;
flex: 1;
// overflow-y: auto;
box-sizing: border-box;
border: none;
resize: none;
background-color: transparent;
outline: none;
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
cursor: text;
}
}
.im__chat-sendarea {
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: auto;
padding: 0px 16px;
.im__chat-send {
background-color: #1553f1;
}
}
}
}
}
.im__window-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>