460 lines
12 KiB
Vue
460 lines
12 KiB
Vue
<template>
|
||
<a-row justify="end" align="center">
|
||
<a-space size="medium">
|
||
<!-- 搜索 -->
|
||
<Search v-if="isDesktop" />
|
||
<!-- 项目配置 -->
|
||
<a-tooltip content="项目配置" position="bl">
|
||
<a-button size="mini" class="gi_hover_btn" @click="SettingDrawerRef?.open">
|
||
<template #icon>
|
||
<icon-settings :size="18" />
|
||
</template>
|
||
</a-button>
|
||
</a-tooltip>
|
||
|
||
<!-- 消息通知中心 -->
|
||
<NotificationCenter ref="notificationCenterRef" />
|
||
|
||
<!-- 全屏切换组件 -->
|
||
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
|
||
<a-button size="mini" class="gi_hover_btn" @click="toggle">
|
||
<template #icon>
|
||
<icon-fullscreen v-if="!isFullscreen" :size="18" />
|
||
<icon-fullscreen-exit v-else :size="18" />
|
||
</template>
|
||
</a-button>
|
||
</a-tooltip>
|
||
|
||
<!-- 暗黑模式切换 -->
|
||
<a-tooltip content="主题切换" position="bottom">
|
||
<GiThemeBtn></GiThemeBtn>
|
||
</a-tooltip>
|
||
|
||
<!-- 管理员账户 -->
|
||
<a-dropdown trigger="hover">
|
||
<a-row align="center" :wrap="false" class="user">
|
||
<!-- 管理员头像 -->
|
||
<Avatar :src="userStore.avatar" :name="userStore.nickname" :size="32" />
|
||
<span class="username">{{ userStore.nickname }}</span>
|
||
<icon-down />
|
||
</a-row>
|
||
<template #content>
|
||
<a-doption @click="router.push('/user/profile')">
|
||
<span>个人中心</span>
|
||
</a-doption>
|
||
<a-doption @click="router.push('/user/message')">
|
||
<span>消息中心</span>
|
||
</a-doption>
|
||
<a-divider :margin="0" />
|
||
<a-doption @click="logout">
|
||
<span>退出登录</span>
|
||
</a-doption>
|
||
</template>
|
||
</a-dropdown>
|
||
</a-space>
|
||
</a-row>
|
||
|
||
<SettingDrawer ref="SettingDrawerRef"></SettingDrawer>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Modal, Notification } from '@arco-design/web-vue'
|
||
import { useFullscreen } from '@vueuse/core'
|
||
import { onMounted, ref, nextTick, onBeforeUnmount } from 'vue'
|
||
import NotificationCenter from '@/components/NotificationCenter/index.vue'
|
||
import SettingDrawer from './SettingDrawer.vue'
|
||
import Search from './Search.vue'
|
||
|
||
import { useUserStore } from '@/stores'
|
||
import { getToken } from '@/utils/auth'
|
||
import { useBreakpoint, useDevice } from '@/hooks'
|
||
|
||
defineOptions({ name: 'HeaderRight' })
|
||
|
||
const { isDesktop } = useDevice()
|
||
const { breakpoint } = useBreakpoint()
|
||
const notificationCenterRef = ref()
|
||
let socket: WebSocket | null = null
|
||
|
||
// 清理函数
|
||
onBeforeUnmount(() => {
|
||
if (socket) {
|
||
socket.close()
|
||
socket = null
|
||
}
|
||
|
||
// 清理标题闪烁
|
||
if (titleFlashInterval) {
|
||
clearInterval(titleFlashInterval)
|
||
titleFlashInterval = null
|
||
}
|
||
})
|
||
|
||
const unreadMessageCount = ref(0)
|
||
|
||
// 语音提示功能
|
||
const playNotificationSound = () => {
|
||
try {
|
||
// 创建音频上下文
|
||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||
const oscillator = audioContext.createOscillator()
|
||
const gainNode = audioContext.createGain()
|
||
|
||
oscillator.connect(gainNode)
|
||
gainNode.connect(audioContext.destination)
|
||
|
||
// 设置音频参数
|
||
oscillator.frequency.setValueAtTime(800, audioContext.currentTime) // 800Hz
|
||
oscillator.type = 'sine'
|
||
|
||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime)
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5)
|
||
|
||
// 播放音频
|
||
oscillator.start(audioContext.currentTime)
|
||
oscillator.stop(audioContext.currentTime + 0.5)
|
||
|
||
console.log('播放语音提示')
|
||
} catch (error) {
|
||
console.error('播放语音提示失败:', error)
|
||
}
|
||
}
|
||
|
||
// 页面标题闪烁功能
|
||
let titleFlashInterval: NodeJS.Timeout | null = null
|
||
const flashPageTitle = () => {
|
||
const originalTitle = document.title
|
||
let flashCount = 0
|
||
const maxFlashes = 6 // 闪烁3次(开-关-开-关-开-关)
|
||
|
||
// 清除之前的闪烁
|
||
if (titleFlashInterval) {
|
||
clearInterval(titleFlashInterval)
|
||
}
|
||
|
||
titleFlashInterval = setInterval(() => {
|
||
if (flashCount >= maxFlashes) {
|
||
document.title = originalTitle
|
||
if (titleFlashInterval) {
|
||
clearInterval(titleFlashInterval)
|
||
titleFlashInterval = null
|
||
}
|
||
return
|
||
}
|
||
|
||
document.title = flashCount % 2 === 0 ? '🔔 新的采购申请' : originalTitle
|
||
flashCount++
|
||
}, 500)
|
||
}
|
||
|
||
// 暴露测试函数到全局,方便在控制台测试
|
||
if (typeof window !== 'undefined') {
|
||
(window as any).testNotification = {
|
||
playSound: playNotificationSound,
|
||
flashTitle: flashPageTitle,
|
||
showNotification: () => {
|
||
Notification.info({
|
||
title: '测试通知',
|
||
content: '这是一个测试通知,用于验证通知功能是否正常工作。',
|
||
duration: 5000,
|
||
closable: true,
|
||
position: 'topRight'
|
||
})
|
||
unreadMessageCount.value++
|
||
},
|
||
testAll: () => {
|
||
playNotificationSound()
|
||
flashPageTitle()
|
||
Notification.info({
|
||
title: '测试通知',
|
||
content: '这是一个测试通知,用于验证通知功能是否正常工作。',
|
||
duration: 5000,
|
||
closable: true,
|
||
position: 'topRight'
|
||
})
|
||
unreadMessageCount.value++
|
||
},
|
||
// 添加调试函数
|
||
debugWebSocket: () => {
|
||
console.log('=== WebSocket 调试信息 ===')
|
||
console.log('Socket对象:', socket)
|
||
console.log('Socket状态:', socket ? socket.readyState : '未连接')
|
||
console.log('Token:', getToken())
|
||
console.log('环境变量:', import.meta.env.VITE_API_WS_URL)
|
||
console.log('未读消息计数:', unreadMessageCount.value)
|
||
console.log('用户Token:', userStore.token)
|
||
},
|
||
// 手动触发WebSocket消息处理
|
||
simulateWebSocketMessage: () => {
|
||
const mockMessage = {
|
||
type: "PROCUREMENT_APPLICATION",
|
||
title: "新的采购申请",
|
||
content: "收到来自 测试用户 的设备采购申请:测试设备"
|
||
}
|
||
|
||
const event = new MessageEvent('message', {
|
||
data: JSON.stringify(mockMessage)
|
||
})
|
||
|
||
if (socket && socket.onmessage) {
|
||
console.log('模拟WebSocket消息:', mockMessage)
|
||
socket.onmessage(event)
|
||
} else {
|
||
console.error('WebSocket连接不存在或onmessage未设置')
|
||
}
|
||
},
|
||
// 强制重新连接WebSocket
|
||
reconnectWebSocket: () => {
|
||
console.log('强制重新连接WebSocket')
|
||
const token = getToken()
|
||
if (token) {
|
||
if (socket) {
|
||
socket.close()
|
||
socket = null
|
||
}
|
||
initWebSocket(token)
|
||
} else {
|
||
console.error('Token不存在,无法重新连接')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 暴露socket对象到全局,方便调试
|
||
;(window as any).socket = socket
|
||
;(window as any).unreadMessageCount = unreadMessageCount
|
||
}
|
||
|
||
// 初始化 WebSocket - 使用防抖避免重复连接
|
||
let initTimer: NodeJS.Timeout | null = null
|
||
const initWebSocket = (token: string) => {
|
||
if (initTimer) {
|
||
clearTimeout(initTimer)
|
||
}
|
||
|
||
initTimer = setTimeout(() => {
|
||
// 如果已有连接,先关闭
|
||
if (socket) {
|
||
socket.close()
|
||
socket = null
|
||
}
|
||
|
||
try {
|
||
// 修复WebSocket URL,确保使用正确的端口
|
||
const wsUrl = import.meta.env.VITE_API_WS_URL || 'ws://localhost:8888'
|
||
const wsEndpoint = wsUrl.replace('8000', '8888') // 确保使用8888端口
|
||
console.log('正在连接WebSocket:', `${wsEndpoint}/websocket?token=${token}`)
|
||
|
||
socket = new WebSocket(`${wsEndpoint}/websocket?token=${token}`)
|
||
|
||
socket.onopen = () => {
|
||
console.log('WebSocket连接成功')
|
||
}
|
||
|
||
socket.onmessage = (event) => {
|
||
console.log('收到WebSocket消息:', event.data)
|
||
try {
|
||
const data = JSON.parse(event.data)
|
||
|
||
// 处理通知消息
|
||
if (data.type && data.title && data.content) {
|
||
console.log('处理通知消息:', data)
|
||
|
||
// 播放语音提示
|
||
playNotificationSound()
|
||
|
||
// 显示通知
|
||
Notification.info({
|
||
title: data.title,
|
||
content: data.content,
|
||
duration: 5000,
|
||
closable: true,
|
||
position: 'topRight'
|
||
})
|
||
|
||
// 增加未读消息计数
|
||
unreadMessageCount.value++
|
||
|
||
// 触发页面标题闪烁
|
||
flashPageTitle()
|
||
} else {
|
||
// 处理简单的数字消息(兼容旧版本)
|
||
const count = Number.parseInt(event.data)
|
||
if (!isNaN(count)) {
|
||
unreadMessageCount.value = count
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('解析WebSocket消息失败:', error)
|
||
// 尝试解析为数字(兼容旧版本)
|
||
const count = Number.parseInt(event.data)
|
||
if (!isNaN(count)) {
|
||
unreadMessageCount.value = count
|
||
}
|
||
}
|
||
}
|
||
|
||
socket.onerror = (error) => {
|
||
console.error('WebSocket连接错误:', error)
|
||
}
|
||
|
||
socket.onclose = (event) => {
|
||
console.log('WebSocket连接关闭:', event.code, event.reason)
|
||
socket = null
|
||
}
|
||
} catch (error) {
|
||
console.error('创建WebSocket连接失败:', error)
|
||
}
|
||
|
||
initTimer = null
|
||
}, 100) // 100ms防抖
|
||
}
|
||
|
||
// 查询未读消息数量
|
||
const getMessageCount = async () => {
|
||
try {
|
||
const token = getToken()
|
||
console.log('获取到token:', token ? '存在' : '不存在')
|
||
|
||
if (token && !socket) {
|
||
console.log('准备初始化WebSocket连接')
|
||
nextTick(() => {
|
||
initWebSocket(token)
|
||
})
|
||
} else if (!token) {
|
||
console.warn('Token不存在,无法建立WebSocket连接')
|
||
} else if (socket) {
|
||
console.log('WebSocket连接已存在')
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to get message count:', error)
|
||
}
|
||
}
|
||
|
||
const { isFullscreen, toggle } = useFullscreen()
|
||
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const SettingDrawerRef = ref<InstanceType<typeof SettingDrawer>>()
|
||
|
||
// 退出登录
|
||
const logout = () => {
|
||
Modal.warning({
|
||
title: '提示',
|
||
content: '确认退出登录?',
|
||
hideCancel: false,
|
||
closable: true,
|
||
onBeforeOk: async () => {
|
||
try {
|
||
await userStore.logout()
|
||
await router.replace('/login')
|
||
return true
|
||
} catch (error) {
|
||
return false
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
nextTick(() => {
|
||
// 立即尝试初始化WebSocket
|
||
getMessageCount()
|
||
|
||
// 如果第一次失败,1秒后重试
|
||
setTimeout(() => {
|
||
if (!socket) {
|
||
console.log('首次连接失败,重试WebSocket连接')
|
||
getMessageCount()
|
||
}
|
||
}, 1000)
|
||
})
|
||
})
|
||
|
||
// 监听用户登录状态变化
|
||
watch(() => userStore.token, (newToken, oldToken) => {
|
||
console.log('Token变化:', { oldToken: oldToken ? '存在' : '不存在', newToken: newToken ? '存在' : '不存在' })
|
||
|
||
if (newToken && !socket) {
|
||
console.log('用户登录,初始化WebSocket连接')
|
||
getMessageCount()
|
||
} else if (!newToken && socket) {
|
||
console.log('用户登出,关闭WebSocket连接')
|
||
socket.close()
|
||
socket = null
|
||
}
|
||
}, { immediate: true })
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.arco-dropdown-open .arco-icon-down {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.user {
|
||
cursor: pointer;
|
||
color: var(--color-text-1);
|
||
|
||
.username {
|
||
margin-left: 10px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.arco-icon-down {
|
||
transition: all 0.3s;
|
||
margin-left: 2px;
|
||
}
|
||
}
|
||
|
||
// 通知徽章样式
|
||
.notification-badge {
|
||
.arco-badge-dot {
|
||
background-color: #f53f3f;
|
||
box-shadow: 0 0 0 2px rgba(245, 63, 63, 0.2);
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
.arco-badge-count {
|
||
background-color: #f53f3f;
|
||
font-weight: bold;
|
||
animation: bounce 0.6s ease-in-out;
|
||
}
|
||
}
|
||
|
||
.notification-btn {
|
||
transition: all 0.3s ease;
|
||
|
||
&:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
}
|
||
|
||
// 脉冲动画
|
||
@keyframes pulse {
|
||
0% {
|
||
box-shadow: 0 0 0 0 rgba(245, 63, 63, 0.7);
|
||
}
|
||
70% {
|
||
box-shadow: 0 0 0 10px rgba(245, 63, 63, 0);
|
||
}
|
||
100% {
|
||
box-shadow: 0 0 0 0 rgba(245, 63, 63, 0);
|
||
}
|
||
}
|
||
|
||
// 弹跳动画
|
||
@keyframes bounce {
|
||
0%, 20%, 53%, 80%, 100% {
|
||
transform: translate3d(0, 0, 0);
|
||
}
|
||
40%, 43% {
|
||
transform: translate3d(0, -8px, 0);
|
||
}
|
||
70% {
|
||
transform: translate3d(0, -4px, 0);
|
||
}
|
||
90% {
|
||
transform: translate3d(0, -2px, 0);
|
||
}
|
||
}
|
||
</style>
|