diff --git a/src/App.vue b/src/App.vue index a964565..6169ed4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,6 +12,8 @@ import { UserService } from './api/usersApi' import { setThemeTransitionClass } from './utils/theme/animation' import { checkStorageCompatibility } from './utils/storage' + import { mittBus } from '@/utils/sys' + import appLogo from '@/assets/img/common/logox.png' const userStore = useUserStore() const { language } = storeToRefs(userStore) @@ -32,14 +34,130 @@ zh: zh, en: en } + const isSupported = ref(false) + const permissionStatus = ref('default') + // 检查浏览器支持情况 + const checkSupport = () => { + if ('Notification' in window) { + isSupported.value = true + console.log('浏览器支持原生Notification API') + } else { + isSupported.value = false + console.log('⚠️ 浏览器不支持原生Notification API') + } + } + // 请求通知权限 + const requestPermission = async () => { + if (!('Notification' in window)) { + console.log('浏览器不支持原生Notification API') + return + } + try { + const permission = await Notification.requestPermission() + permissionStatus.value = permission + if (permission === 'granted') { + console.log('用户已授予通知权限') + } else if (permission === 'denied') { + console.log('用户拒绝了通知权限') + } else { + console.log('用户暂时未授予通知权限') + } + } catch (error) { + console.log(`请求权限时出错:`) + } + } + + // 显示基本通知(支持自定义标题与内容) + const showBasicNotification = ( + title: string = '基本通知', + body: string = '这是一个使用浏览器原生API的系统通知' + ) => { + if (!('Notification' in window)) { + ElMessage({ + message: '浏览器不支持原生Notification API', + type: 'warning' + }) + return + } + if (Notification.permission !== 'granted') { + ElMessage({ + message: '请先授予通知权限', + type: 'warning' + }) + requestPermission() + return + } + try { + const iconUrl = appLogo || '/favicon.ico' + const notification = new Notification(title, { + body, + requireInteraction: true, + icon: iconUrl + }) + notification.onclick = () => { + notification.close() + } + } catch (error) {} + } + const showEventNotification = () => { + if (Notification.permission !== 'granted') { + ElMessage({ + message: '请先授予通知权限', + type: 'warning' + }) + return + } + try { + const notification = new Notification('带事件监听的通知', { + body: '这个通知演示各种事件监听', + icon: 'https://element-plus.org/images/element-plus-logo.svg' + }) + notification.onshow = () => { + console.log('通知显示事件触发') + } + notification.onclick = () => { + console.log('通知点击事件触发') + notification.close() + } + notification.onclose = () => { + console.log('通知关闭事件触发') + } + notification.onerror = (error) => { + // console.log(`通知错误事件: ${error.message}`) + } + console.log('已发送带事件监听的通知') + } catch (error) { + // console.log(`发送通知失败: ${error.message}`) + } + } onBeforeMount(() => { setThemeTransitionClass(true) setCompanyFromUrl() }) + // 页面刷新时重置WebSocket重连次数,同时清理事件监听 + onBeforeUnmount(() => { + import('@/utils/sys/wsClient').then(({ resetWebSocketReconnect }) => { + resetWebSocketReconnect() + }) + mittBus.off('ws:message') + }) + + const handleWsMessageNotification = (data: any) => { + if (data && data.event === 'notify.receive') { + const notice = data?.data?.notice_data + const title = notice?.message?.title || '新通知' + const rawContent = notice?.message?.content || '' + const body = typeof rawContent === 'string' ? rawContent.replace(/<[^>]+>/g, '') : '' + showBasicNotification(title, body) + } + } + onMounted(() => { // 检查存储兼容性 checkStorageCompatibility() + checkSupport() + requestPermission() // 提升暗黑主题下页面刷新视觉体验 setThemeTransitionClass(false) // 系统升级 @@ -48,11 +166,23 @@ getUserInfo() // 初始化 WebSocket const { accessToken } = useUserStore() - // if (accessToken) { - // import('@/utils/sys/wsClient').then(({ initWebSocket }) => { - // initWebSocket(accessToken) - // }) - // } + // showEventNotification() + if (accessToken) { + import('@/utils/sys/wsClient').then( + ({ initWebSocket, resetWebSocketReconnect, getWebSocketStatus }) => { + // 页面刷新时重置重连次数 + resetWebSocketReconnect() + initWebSocket(accessToken) + // 添加调试功能,可以在控制台查看WebSocket状态 + // @ts-ignore + window.getWebSocketStatus = getWebSocketStatus + // console.log('WebSocket状态查看: window.getWebSocketStatus()') + } + ) + } + + // 监听ws消息,用于系统级通知 + mittBus.on('ws:message', handleWsMessageNotification) }) // 获取用户信息 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index d33b476..f118573 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -198,4 +198,11 @@ export class manageService { // showErrorMessage: false // 不显示错误消息 }) } + //回收站 恢复 + static customerRestore(params: any) { + return request.get({ + url: '/admin/crm/customer/restore', + params + }) + } } diff --git a/src/api/commonApi.ts b/src/api/commonApi.ts new file mode 100644 index 0000000..22a1cf8 --- /dev/null +++ b/src/api/commonApi.ts @@ -0,0 +1,38 @@ +import request from '@/utils/http' + +export class commonService { + //通知列表 + static getNoticeList(params: Api.Common.PaginatingParams) { + return request.get({ + url: '/common/notice/list', + params + }) + } + //通知详情 + static getNoticeDetail(params: any) { + return request.get({ + url: '/common/notice/show', + params + }) + } + //通知已读 + static readNotice(params: any) { + return request.post({ + url: '/common/notice/read', + params + }) + } + //通知删除 + static delNotice(params: any) { + return request.post({ + url: '/common/notice/destroy', + params + }) + } + //通知未读列表 + static getUnReadNoticeList() { + return request.get({ + url: '/common/notice/unread' + }) + } +} diff --git a/src/api/crmApi.ts b/src/api/crmApi.ts index 3727eed..e65c0f4 100644 --- a/src/api/crmApi.ts +++ b/src/api/crmApi.ts @@ -191,4 +191,12 @@ export class CrmService { // showErrorMessage: false // 不显示错误消息 }) } + //客户删除 + static crmCustomerDelete(params: any) { + return request.get({ + url: '/crm/customer/delete', + params + // showErrorMessage: false // 不显示错误消息 + }) + } } diff --git a/src/api/systemApi.ts b/src/api/systemApi.ts index 5284590..b9bbe95 100644 --- a/src/api/systemApi.ts +++ b/src/api/systemApi.ts @@ -217,4 +217,32 @@ export class systemService { params }) } + //通知列表 + static getNoticeList(params: Api.Common.PaginatingParams) { + return request.get({ + url: '/admin/notice/list', + params + }) + } + //通知详情 + static getNoticeDetail(params: any) { + return request.get({ + url: '/admin/notice/show', + params + }) + } + //通知删除 + static delNotice(params: any) { + return request.post({ + url: '/admin/notice/destroy', + params + }) + } + //通知推送 + static sendNotice(params: any) { + return request.post({ + url: '/admin/notice/push', + params + }) + } } diff --git a/src/api/usersApi.ts b/src/api/usersApi.ts index 44fac2e..62b9c20 100644 --- a/src/api/usersApi.ts +++ b/src/api/usersApi.ts @@ -92,4 +92,12 @@ export class UserService { params }) } + //客户删除 + static crmCustomerDelete(params: any) { + return request.get({ + url: '/admin/crm/customer/delete', + params + // showErrorMessage: false // 不显示错误消息 + }) + } } diff --git a/src/assets/img/common/logox.png b/src/assets/img/common/logox.png new file mode 100644 index 0000000..8bba14d Binary files /dev/null and b/src/assets/img/common/logox.png differ diff --git a/src/components/core/layouts/art-header-bar/index.vue b/src/components/core/layouts/art-header-bar/index.vue index c55d6b9..04600f3 100644 --- a/src/components/core/layouts/art-header-bar/index.vue +++ b/src/components/core/layouts/art-header-bar/index.vue @@ -54,6 +54,18 @@ --> + +
+
+ + +
+
@@ -69,13 +81,6 @@ {{ isFullscreen ? '' : '' }}
- - + 消息 + {{ $t('notice.btnRead') }}
  • - {{ item.name }} ({{ item.num }}) + {{ item.name }} ({{ noticeList.length }})
-
    -
  • +
      + +
    • -

      {{ item.title }}

      -

      {{ item.time }}

      +

      {{ item.message.title }}

      +

      {{ item.message.created_at }}

    -
      + -
        + -
        +

        {{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}

        -
        +
        @@ -86,6 +89,25 @@
        + + + +
        +
        +
        发送人:{{ noticeDetail.sender_name }}
        +
        +
        +
        通知标题:{{ noticeDetail.title }}
        +
        +
        +
        + 通知内容: +
        +
        +
        +
        +
        +
        diff --git a/src/utils/sys/wsClient.ts b/src/utils/sys/wsClient.ts index 75c2cc6..5929c62 100644 --- a/src/utils/sys/wsClient.ts +++ b/src/utils/sys/wsClient.ts @@ -3,56 +3,38 @@ import { useUserStore } from '@/store/modules/user' type JsonValue = Record | any[] -const WS_BASE = 'wss://mzl.dev.hnxinyouhe.com/ws?token=' +const WS_BASE = import.meta.env.VITE_API_URL_WSS + '/ws?token=' +const MAX_RECONNECT_ATTEMPTS = 10 +const MAX_RECONNECT_DELAY = 50000 +const HEARTBEAT_INTERVAL = 50000 let socket: WebSocket | null = null let reconnectTimer: any = null let heartbeatTimer: any = null let reconnectAttempts = 0 -const maxReconnectDelayMs = 30000 -const heartbeatIntervalMs = 30000 +let isInitialConnection = true -// 生成全局唯一的 trace_id(优先使用 crypto.randomUUID) +// 生成唯一ID const generateTraceId = (): string => { try { - // @ts-ignore - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - // @ts-ignore + if (typeof crypto !== 'undefined' && crypto.randomUUID) { return `req-${crypto.randomUUID()}` } - } catch (_) {} - // Fallback:基于时间戳 + 随机数 - const timestampPart = Date.now().toString(36) - let randomPart = '' - try { - // @ts-ignore - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - const buf = new Uint8Array(16) - // @ts-ignore - crypto.getRandomValues(buf) - randomPart = Array.from(buf) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') - } else { - randomPart = Array.from({ length: 16 }) - .map(() => - Math.floor(Math.random() * 256) - .toString(16) - .padStart(2, '0') - ) - .join('') - } - } catch (_) { - randomPart = Math.random().toString(36).slice(2) + } catch { + // 忽略错误,使用fallback方案 } - return `req-${timestampPart}-${randomPart}` + const timestamp = Date.now().toString(36) + const random = Math.random().toString(36).slice(2) + return `req-${timestamp}-${random}` } +// 计算重连延迟 const getReconnectDelay = () => { const base = 1000 * Math.pow(2, reconnectAttempts) - return Math.min(base, maxReconnectDelayMs) + return Math.min(base, MAX_RECONNECT_DELAY) } +// 清理定时器 const cleanup = () => { if (reconnectTimer) { clearTimeout(reconnectTimer) @@ -64,107 +46,198 @@ const cleanup = () => { } } +// 启动心跳 const startHeartbeat = () => { - // 清理上一次的心跳 if (heartbeatTimer) { clearTimeout(heartbeatTimer) - heartbeatTimer = null } - // 递归 setTimeout,确保固定间隔执行 heartbeatTimer = setTimeout(() => { try { - if (socket && socket.readyState === WebSocket.OPEN) { + if (socket?.readyState === WebSocket.OPEN) { const msg = { event: 'system.ping', data: {}, - trace_id: generateTraceId() // 可选,用于请求-响应匹配 + trace_id: generateTraceId() } socket.send(JSON.stringify(msg)) } - } catch (_) {} - // 下一次心跳 + } catch { + // 忽略心跳发送错误 + } startHeartbeat() - }, heartbeatIntervalMs) + }, HEARTBEAT_INTERVAL) } +// 发送心跳消息 +const sendPing = () => { + try { + if (socket?.readyState === WebSocket.OPEN) { + const msg = { + event: 'system.ping', + data: {}, + trace_id: generateTraceId() + } + socket.send(JSON.stringify(msg)) + } + } catch { + // 忽略发送错误 + } +} + +// 处理WebSocket消息 +const handleMessage = (event: MessageEvent) => { + let data: any = event.data + try { + data = JSON.parse(event.data) + } catch { + // 忽略解析错误 + } + console.log('WebSocket message:', data) + if (data.event == 'notify.receive') { + ElNotification({ + title: data.data.notice_data.message.title || '通知', + message: data.data.notice_data.message.content, + type: 'primary' + }) + mittBus.emit('notification-data-changed', 1) + } + mittBus.emit('ws:message', data) +} + +// 设置WebSocket事件处理器 +const setupWebSocketHandlers = (ws: WebSocket) => { + ws.onopen = () => { + isInitialConnection = false + startHeartbeat() + mittBus.emit('ws:open') + sendPing() + } + + ws.onmessage = handleMessage + + ws.onerror = (err) => { + mittBus.emit('ws:error', err) + } + + ws.onclose = () => { + mittBus.emit('ws:close') + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + scheduleReconnect() + } + } +} + +// 安排重连 const scheduleReconnect = () => { + if (!isInitialConnection) { + reconnectAttempts += 1 + } + + if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) { + console.warn(`WebSocket重连失败,已达到最大重连次数(${MAX_RECONNECT_ATTEMPTS}),停止重连`) + mittBus.emit('ws:max-reconnect-reached') + return + } + cleanup() const delay = getReconnectDelay() + console.log(`WebSocket重连尝试 ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS},延迟: ${delay}ms`) reconnectTimer = setTimeout(() => { const { accessToken } = useUserStore() - if (accessToken) initWebSocket(accessToken) + if (accessToken) { + try { + if (socket) { + try { + socket.close() + } catch { + // 忽略关闭错误 + } + socket = null + } + + const url = `${WS_BASE}${accessToken}` + socket = new WebSocket(url) + setupWebSocketHandlers(socket) + } catch (e) { + mittBus.emit('ws:error', e) + if (reconnectAttempts <= MAX_RECONNECT_ATTEMPTS) { + scheduleReconnect() + } + } + } }, delay) } +// 初始化WebSocket连接 export const initWebSocket = (token: string) => { try { if (!token) return if (socket) { try { socket.close() - } catch (_) {} + } catch { + // 忽略关闭错误 + } socket = null } cleanup() + reconnectAttempts = 0 + isInitialConnection = true const url = `${WS_BASE}${token}` socket = new WebSocket(url) - - socket.onopen = () => { - reconnectAttempts = 0 - startHeartbeat() - mittBus.emit('ws:open') - try { - const msg = { - event: 'system.ping', - data: {}, - trace_id: generateTraceId() // 可选,用于请求-响应匹配 - } - socket?.send(JSON.stringify(msg)) - } catch (_) {} - } - - socket.onmessage = (event) => { - const dataStr = event.data - let data: any = dataStr - try { - data = JSON.parse(dataStr) - } catch (_) {} - mittBus.emit('ws:message', data) - } - - socket.onerror = (err) => { - mittBus.emit('ws:error', err) - } - - socket.onclose = () => { - mittBus.emit('ws:close') - reconnectAttempts += 1 - scheduleReconnect() - } + setupWebSocketHandlers(socket) } catch (e) { mittBus.emit('ws:error', e) scheduleReconnect() } } +// 关闭WebSocket连接 export const closeWebSocket = () => { cleanup() try { socket?.close() - } catch (_) {} + } catch { + // 忽略关闭错误 + } socket = null + reconnectAttempts = 0 + isInitialConnection = true } +// 发送WebSocket消息 export const sendWebSocket = (payload: string | JsonValue) => { if (!socket || socket.readyState !== WebSocket.OPEN) return false try { const data = typeof payload === 'string' ? payload : JSON.stringify(payload) socket.send(data) return true - } catch (_) { + } catch { return false } } +// 重置重连次数 +export const resetWebSocketReconnect = () => { + reconnectAttempts = 0 + isInitialConnection = true + console.log('WebSocket重连次数已重置') +} +// 获取当前重连次数 +export const getWebSocketReconnectAttempts = () => reconnectAttempts + +// 获取最大重连次数 +export const getMaxReconnectAttempts = () => MAX_RECONNECT_ATTEMPTS + +// 获取WebSocket连接状态 +export const getWebSocketStatus = () => ({ + socket, + readyState: socket?.readyState, + reconnectAttempts, + maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS, + isReconnecting: reconnectTimer !== null, + isHeartbeating: heartbeatTimer !== null, + isInitialConnection +}) \ No newline at end of file diff --git a/src/views/admin/customer/index.vue b/src/views/admin/customer/index.vue index 9ea45d7..c16d1a5 100644 --- a/src/views/admin/customer/index.vue +++ b/src/views/admin/customer/index.vue @@ -175,6 +175,15 @@ > 跟进记录 + + 删除 + @@ -555,6 +564,22 @@ plzyDialogVisible.value = false gettableList() } + const delectfn = (row: any) => { + ElMessageBox.confirm('确定要删除此客户吗?删除后将无法恢复!', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + .then(async () => { + const res = await manageService.crmCustomerDelete({ customer_id: row.id, scene: 'list' }) + ElMessage.success(res?.message || '提交成功!') + gettableList() + // 你可以在这里调用接口或做后续处理 + }) + .catch(() => { + // 用户点击了取消 + }) + } diff --git a/src/views/admin/system/company.vue b/src/views/admin/system/company.vue index cf093da..9d965e3 100644 --- a/src/views/admin/system/company.vue +++ b/src/views/admin/system/company.vue @@ -15,6 +15,8 @@ :currentPage="pagination.currentPage" :pageSize="pagination.pageSize" :total="pagination.total" + @size-change="handleSizeChange" + @current-change="handleCurrentChange" >