This commit is contained in:
oneao committed 2025-10-14 17:30:21 +08:00
1 parent 35951777b0
commit 63f1d4d1dc
39 files changed
+880 -133

No files matched your search

+4 -7
View File
@@ -1,18 +1,15 @@
<script setup>
import { onBeforeUnmount, onMounted } from 'vue'
import { onBeforeUnmount } from 'vue'
import Layout from '@/layout/index.vue'
import { useWebSocket } from './stores/websocketStore'
const websocket = useWebSocket()
onMounted(() => {
websocket.connect('ws://127.0.0.1:8090/ws')
})
onBeforeUnmount(() => {
websocket.disconnect()
// websocket.disconnect()
})
</script>
<template>
<RouterView />
<Layout />
</template>
@@ -0,0 +1,97 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/userStore'
import { useWebSocket } from '../stores/websocketStore'
const websocket = useWebSocket()
const userStore = useUserStore()
const router = useRouter()
const activeIndex = ref('/home')
onMounted(() => {
router.afterEach((to) => {
activeIndex.value = to.path
})
})
function toPath(url) {
if (url === '/login') {
console.log(userStore.isLogined())
if (userStore.isLogined()) {
websocket.disconnect()
userStore.resetUserInfo()
}
router.push(url)
}
else {
router.push(url)
}
}
</script>
<template>
<div class="layout">
<el-menu
v-model="activeIndex"
:default-active="activeIndex"
mode="horizontal"
active-text-color="#1553f1"
>
<el-menu-item index="/home" @click="toPath('/home')">
首页
</el-menu-item>
<el-menu-item index="/im" @click="toPath('/im')">
IM即时通信
</el-menu-item>
<el-menu-item index="/login" @click="toPath('/login')">
登录
</el-menu-item>
</el-menu>
<div class="layout-main">
<router-view />
</div>
</div>
</template>
<style scoped>
.layout {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
& *::-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);
}
.el-menu--horizontal {
width: 100%;
}
.layout-main {
flex: 1;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable both-edges;
}
}
</style>
+42 -6
View File
@@ -1,14 +1,30 @@
import { ElMessage } from 'element-plus'
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/userStore'
import Login from '@/views/auth/index.vue'
import Home from '@/views/home/index.vue'
import Im from '@/views/im/index.vue'
import { useWebSocket } from '../stores/websocketStore'
// 新增登录页面
const routes = [
{
path: '/',
redirect: '/im',
redirect: '/home',
},
{
path: '/home',
component: Home,
meta: { title: '首页' },
},
{
path: '/im',
component: Im,
meta: { title: 'IM聊天' },
},
{
path: '/login',
component: Login,
meta: { title: '登录' },
},
]
@@ -17,16 +33,36 @@ const router = createRouter({
routes,
})
// 全局前置守卫
router.beforeEach((to, from, next) => {
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 系统名称` : '系统名称'
// 通过检查,继续跳转
const userStore = useUserStore()
const websocket = useWebSocket()
// 登录页直接放行,不主动断开
if (to.path === '/login') {
next()
return
}
if (!userStore.isLogined()) {
ElMessage.warning('请先登录')
userStore.resetUserInfo()
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// 登录状态下,确保连接
if (!websocket.isConnected.value) {
websocket.connect()
}
next()
})
router.afterEach(() => {
router.afterEach((to, from) => {
})
export default router
@@ -4,18 +4,19 @@ import { ref } from 'vue'
export const useUserStore = defineStore(
'userInfo',
() => {
const userInfo = ref({
orgid: '',
orgid: 'g3hd',
userid: '',
token: '',
username: '',
})
// 设置用户信息
function setUserInfo(orgid, userid, token) {
function setUserInfo(orgid, userid, username, token) {
userInfo.value.orgid = orgid
userInfo.value.userid = userid
userInfo.value.token = token
userInfo.value.username = username
}
// 重置用户信息
@@ -1,7 +1,15 @@
// stores/websocket.js
import { ElNotification } from 'element-plus'
import { onScopeDispose, ref } from 'vue'
import { ref } from 'vue'
import { useUserStore } from './userStore'
let instance = null // 单例缓存
export function useWebSocket() {
if (instance)
return instance // 已存在则直接返回
const userStore = useUserStore()
const socket = ref(null)
const isConnected = ref(false)
const listeners = ref(new Set())
@@ -9,25 +17,19 @@ export function useWebSocket() {
let reconnectAttempts = 0
let pingTimer = null
let pongTimeout = null
const PING_INTERVAL = 10000 // 收到PONG后间隔再发送下一次PING
const PONG_TIMEOUT = 15000 // PING发送后15秒未收到PONG就重连
const PING_INTERVAL = 10000
const PONG_TIMEOUT = 15000
const currentUrl = 'ws://127.0.0.1:8090/ws'
let reconnectNotification = null
let currentUrl = null
let isReconnecting = false
let manualClose = false // ✅ 标记是否为手动关闭
// --------------------
// 私有方法
// --------------------
// ---------------- 清理 ----------------
function cleanup() {
if (pingTimer) {
clearTimeout(pingTimer)
pingTimer = null
}
if (pongTimeout) {
clearTimeout(pongTimeout)
pongTimeout = null
}
clearTimeout(pingTimer)
clearTimeout(pongTimeout)
pingTimer = null
pongTimeout = null
}
function handleDisconnect() {
@@ -39,23 +41,14 @@ export function useWebSocket() {
listeners.value.forEach(cb => cb(message))
}
// --------------------
// 心跳逻辑(链式)
// --------------------
// ---------------- 心跳 ----------------
function sendPing() {
if (!socket.value || socket.value.readyState !== WebSocket.OPEN)
return
try {
sendMessage('PING')
}
catch (err) {
console.error('发送 PING 失败', err)
}
sendMessage({ type: 'ping' })
// 设置 PONG 超时
if (pongTimeout)
clearTimeout(pongTimeout)
clearTimeout(pongTimeout)
pongTimeout = setTimeout(() => {
console.warn('未收到PONG,触发重连')
handleReconnect()
@@ -63,15 +56,8 @@ export function useWebSocket() {
}
function handlePong() {
// 收到 PONG,清理超时
if (pongTimeout) {
clearTimeout(pongTimeout)
pongTimeout = null
}
// 等 PING_INTERVAL 后再发送下一次 PING
if (pingTimer)
clearTimeout(pingTimer)
clearTimeout(pongTimeout)
clearTimeout(pingTimer)
pingTimer = setTimeout(sendPing, PING_INTERVAL)
}
@@ -79,62 +65,57 @@ export function useWebSocket() {
sendPing()
}
// --------------------
// 重连逻辑
// --------------------
// ---------------- 重连 ----------------
function handleReconnect() {
if (isReconnecting)
return
if (isReconnecting || manualClose)
return // ✅ 如果是手动断开则不重连
isReconnecting = true
cleanup()
isConnected.value = false
socket.value?.close()
reconnectAttempts++
if (reconnectNotification)
reconnectNotification.close()
reconnectNotification?.close()
reconnectNotification = ElNotification({
title: '提示',
message: `WebSocket 连接失败,正在第 ${reconnectAttempts} 次重连...`,
type: 'warning',
duration: 0,
key: 'websocket-reconnect',
})
const delay = Math.min(3000 * 2 ** (reconnectAttempts - 1), 30000)
setTimeout(() => {
isReconnecting = false
connect(currentUrl)
connect()
}, delay)
}
function clearReconnectNotification() {
if (reconnectNotification) {
reconnectNotification.close()
reconnectNotification = null
}
reconnectNotification?.close()
reconnectNotification = null
}
// --------------------
// 公共方法
// --------------------
function connect(url) {
currentUrl = url
// ---------------- 公共方法 ----------------
function connect() {
if (isConnected.value)
return
if (socket.value)
disconnect()
const newSocket = new WebSocket(url)
manualClose = false // ✅ 每次连接前重置为 false
const newSocket = new WebSocket(currentUrl)
newSocket.onopen = () => {
isConnected.value = true
isReconnecting = false
clearReconnectNotification()
if (userStore.isLogined)
sendLogin(userStore.userInfo.userid)
ElNotification({
title: '提示',
message: 'WebSocket 连接成功',
message: '上线成功',
type: 'success',
duration: 2000,
})
@@ -144,26 +125,42 @@ export function useWebSocket() {
}
newSocket.onmessage = (event) => {
let message
let resData
try {
message = JSON.parse(event.data)
console.log(event.data)
if (message.type === 'PONG') {
handlePong()
return
}
notifyListeners(message)
resData = JSON.parse(event.data)
}
catch (err) {
console.error('消息解析失败:', err)
notifyListeners({ type: 'ERROR', data: '消息解析失败' })
return
}
if (resData.code !== 200) {
ElNotification({
title: '错误',
message: resData.message || '发生未知错误',
type: 'error',
showClose: true,
duration: 3000,
})
return
}
if (resData.type === 'pong') {
handlePong()
}
else {
notifyListeners(resData)
}
}
newSocket.onclose = () => {
handleDisconnect()
if (!isReconnecting)
// ✅ 非手动断开才重连
if (!manualClose && !isReconnecting)
handleReconnect()
}
@@ -175,14 +172,25 @@ export function useWebSocket() {
}
function disconnect() {
if (!socket.value)
return
manualClose = true
socket.value?.close()
cleanup()
clearReconnectNotification()
ElNotification({
type: 'warning',
title: '提示',
message: '已下线',
})
}
function sendMessage(type, data) {
function sendMessage(data) {
if (isConnected.value && socket.value) {
const message = { type, data }
socket.value.send(JSON.stringify(message))
data.orgId = userStore.userInfo.orgid || ''
socket.value.send(JSON.stringify(data))
}
}
@@ -191,13 +199,23 @@ export function useWebSocket() {
return () => listeners.value.delete(callback)
}
onScopeDispose(() => disconnect())
function sendLogin(userId) {
if (!isConnected.value)
return
sendMessage({
type: 'login',
from: userId,
})
}
return {
instance = {
isConnected,
connect,
disconnect,
sendMessage,
onMessage,
sendLogin,
}
return instance
}
+27 -10
View File
@@ -1,34 +1,51 @@
// src/utils/axios.js
import axios from 'axios'
import { useUserStore } from '@/stores/userStore'
const instance = axios.create({
baseURL: 'http://localhost:8089/api', // 后端接口地址
timeout: 5000
})
timeout: 5000,
})
// 请求拦截器:自动添加 token
instance.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
(config) => {
const userStore = useUserStore()
if (userStore.userInfo.token) {
config.headers.Authorization = `Bearer ${userStore.userInfo.token}`
}
return config
},
error => Promise.reject(error)
error => Promise.reject(error),
)
// 响应拦截器:统一处理错误或过期 token
instance.interceptors.response.use(
response => response,
error => {
(error) => {
const userStore = useUserStore()
if (error.response && error.response.status === 401) {
alert('登录失效,请重新登录')
window.localStorage.removeItem('token')
userStore.resetUserInfo()
window.location.href = '/login'
}
return Promise.reject(error)
}
},
)
export function axiosPost(url, data = {}, config = {}) {
return instance.post(url, data, {
headers: { 'Content-Type': 'application/json' },
...config,
}).then(res => res.data)
}
export function axiosGet(url, params = {}, config = {}) {
return instance.get(url, {
params,
...config,
}).then(res => res.data)
}
export default instance
@@ -0,0 +1,127 @@
<script setup>
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/userStore'
import { axiosPost } from '@/utils/axios'
const userStore = useUserStore()
const router = useRouter()
const form = ref({
orgid: userStore.userInfo.orgid,
userid: userStore.userInfo.userid,
password: '',
})
const loginLoading = ref(false)
async function onLogin() {
loginLoading.value = true
try {
const resData = await axiosPost(
'/login',
{
orgid: form.value.orgid,
userid: form.value.userid,
password: form.value.password,
},
)
if (resData.token) {
userStore.setUserInfo(form.value.orgid, form.value.userid, resData.userInfo.username, resData.token)
ElMessage.success(resData.message || '登录成功')
router.push('/home')
}
else {
ElMessage.error(resData.message || '登录失败')
}
}
catch (err) {
ElMessage.error(`请求错误:${err.response?.data?.message || err.message}`)
}
finally {
loginLoading.value = false
}
}
</script>
<template>
<div class="app">
<div class="login">
<div class="login-left">
<img src="/vite.svg" alt="Logo">
</div>
<div class="login-right">
<div class="login-right-title">
Online
</div>
<el-form :model="form" label-width="auto" label-position="top" size="large">
<el-form-item label="用户名" required>
<el-input v-model="form.userid" style="height: 36px !important" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password" type="password" show-password style="height: 36px !important"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary" :loading="loginLoading" style="width: 100%; margin-top: 16px"
@click="onLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<style lang="css">
.app {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.login {
display: flex;
width: 1000px;
height: 500px;
overflow: hidden;
border-radius: 12px;
box-shadow: 0px 6px 24px -8px rgba(0, 0, 0, 0.1);
}
.login-left {
display: flex;
align-items: center;
justify-content: center;
width: 60%;
padding: 32px;
border-right: 1px solid #e9e9e9;
}
.login-left img {
width: 100%;
height: 100%;
}
.login-right {
display: flex;
flex-direction: column;
gap: 24px;
justify-content: center;
width: 50%;
padding: 32px;
}
.login-right-title {
font-size: 20px;
font-weight: bold;
}
</style>
@@ -0,0 +1,67 @@
<script setup>
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { useUserStore } from '@/stores/userStore'
import { axiosGet, axiosPost } from '@/utils/axios'
import { useWebSocket } from '../../stores/websocketStore'
const userStore = useUserStore()
const websocket = useWebSocket()
const tableData = ref([])
onMounted(async () => {
try {
const res = await axiosGet('/loadCompanyList')
tableData.value = res.data
}
catch (err) {
ElMessage.error(`请求错误:${err.response?.data?.message || err.message}`)
}
})
</script>
<template>
<div class="home-container">
<div class="welcome">
{{ userStore.userInfo.username }},欢迎登录
</div>
<div class="home-header">
IM 聊天列表(支持多公司,单公司聊天)
</div>
<el-table :data="tableData" border stripe style="width: 50%;">
<el-table-column prop="b_name" label="用户名称" />
<el-table-column prop="_online" label="在线状态" />
<el-table-column fixed="right" label="操作">
<template #default="scope">
<el-button v-if="userStore.userInfo.userid !== scope.row.b_id" link type="primary" size="small">
聊天
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style lang="scss" scoped>
.home-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 40px 20px;
}
.welcome {
font-size: 16px;
color: #555;
margin-bottom: 16px;
}
.home-header {
font-size: 18px;
font-weight: 600;
margin-bottom: 24px;
color: #333;
}
</style>