通知板块 及 功能调整

This commit is contained in:
浅念
2025-08-22 18:27:26 +08:00
parent b319944007
commit 329acaf68d
22 changed files with 1771 additions and 218 deletions
+135 -5
View File
@@ -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)
})
// 获取用户信息
+7
View File
@@ -198,4 +198,11 @@ export class manageService {
// showErrorMessage: false // 不显示错误消息
})
}
//回收站 恢复
static customerRestore(params: any) {
return request.get<any>({
url: '/admin/crm/customer/restore',
params
})
}
}
+38
View File
@@ -0,0 +1,38 @@
import request from '@/utils/http'
export class commonService {
//通知列表
static getNoticeList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/common/notice/list',
params
})
}
//通知详情
static getNoticeDetail(params: any) {
return request.get<Api.Common.CommonListData>({
url: '/common/notice/show',
params
})
}
//通知已读
static readNotice(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/notice/read',
params
})
}
//通知删除
static delNotice(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/common/notice/destroy',
params
})
}
//通知未读列表
static getUnReadNoticeList() {
return request.get<any>({
url: '/common/notice/unread'
})
}
}
+8
View File
@@ -191,4 +191,12 @@ export class CrmService {
// showErrorMessage: false // 不显示错误消息
})
}
//客户删除
static crmCustomerDelete(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/crm/customer/delete',
params
// showErrorMessage: false // 不显示错误消息
})
}
}
+28
View File
@@ -217,4 +217,32 @@ export class systemService {
params
})
}
//通知列表
static getNoticeList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/notice/list',
params
})
}
//通知详情
static getNoticeDetail(params: any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/notice/show',
params
})
}
//通知删除
static delNotice(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/notice/destroy',
params
})
}
//通知推送
static sendNotice(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/notice/push',
params
})
}
}
+8
View File
@@ -92,4 +92,12 @@ export class UserService {
params
})
}
//客户删除
static crmCustomerDelete(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/crm/customer/delete',
params
// showErrorMessage: false // 不显示错误消息
})
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -54,6 +54,18 @@
</div>
</div>
</div> -->
<!-- 通知 -->
<div class="btn-box notice-btn" @click="visibleNotice">
<div class="btn notice-button">
<i
class="iconfont-sys notice-btn"
:class="{ 'bell-shake': isBellShaking }"
:style="isBellShaking ? 'color: #5d87ff' : ''"
>&#xe6c2;</i
>
<span class="count notice-btn" v-if="isBellShaking"></span>
</div>
</div>
<div class="btn-box screen-box" @click="clearCachefn">
<div class="btn" title="清除缓存">
<i class="iconfont-sys">&#xe847;</i>
@@ -69,13 +81,6 @@
<i class="iconfont-sys">{{ isFullscreen ? '&#xe62d;' : '&#xe8ce;' }}</i>
</div>
</div>
<!-- 通知 -->
<!-- <div class="btn-box notice-btn" @click="visibleNotice">
<div class="btn notice-button">
<i class="iconfont-sys notice-btn">&#xe6c2;</i>
<span class="count notice-btn"></span>
</div>
</div> -->
<!-- 聊天 -->
<!-- <div class="btn-box chat-btn" @click="openChat">
<div class="btn chat-button">
@@ -248,6 +253,10 @@
const showNotice = ref(false)
const notice = ref(null)
const userMenuPopover = ref()
// 控制铃铛是否晃动
const isBellShaking = ref(false)
// 轮询定时器句柄,防止重复创建
let notificationPollTimer: number | undefined
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
@@ -269,10 +278,20 @@
onMounted(() => {
initLanguage()
document.addEventListener('click', bodyCloseNotice)
// 监听通知数据变化,控制铃铛晃动
watchNotificationData()
})
onUnmounted(() => {
document.removeEventListener('click', bodyCloseNotice)
// 清理事件监听器
mittBus.off('notification-data-changed')
mittBus.off('ws:message')
// 清理通知轮询
if (notificationPollTimer) {
clearInterval(notificationPollTimer)
notificationPollTimer = undefined
}
})
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
@@ -373,8 +392,6 @@
if (showSettingGuide.value) {
settingStore.hideSettingGuide()
}
// 打开设置引导
// settingStore.openSettingGuide()
}
const openSearchDialog = () => {
@@ -412,9 +429,72 @@
userMenuPopover.value.hide()
}, 100)
}
// 监听通知数据变化,控制铃铛晃动
const watchNotificationData = () => {
// 定期检查通知数据
// const checkNotificationData = async () => {
// try {
// const { commonService } = await import('@/api/commonApi')
// const res = await commonService.getUnReadNoticeList()
// const noticeCount = res?.list?.length || 0
// isBellShaking.value = noticeCount > 0
// } catch (error) {
// console.error('获取通知数据失败:', error)
// isBellShaking.value = false
// }
// }
// // 立即检查一次
// checkNotificationData()
// // 每30秒检查一次通知数据,先清理旧定时器,避免重复
// if (notificationPollTimer) {
// clearInterval(notificationPollTimer)
// notificationPollTimer = undefined
// }
// notificationPollTimer = window.setInterval(checkNotificationData, 30000)
// 监听通知数据变化事件
mittBus.on('notification-data-changed', (count: unknown) => {
isBellShaking.value = typeof count === 'number' ? count > 0 : false
})
// 监听WebSocket消息,处理实时通知
mittBus.on('ws:message', (data: any) => {
if (
data &&
data.event &&
(data.event.includes('notice') ||
data.event.includes('notification') ||
data.event.includes('message'))
) {
isBellShaking.value = true
}
})
}
</script>
<style lang="scss" scoped>
@use './style';
@use './mobile';
// 铃铛晃动动画
.bell-shake {
animation: bellShake 1.2s ease-in-out infinite;
}
@keyframes bellShake {
0%,
100% {
transform: rotate(0deg);
}
10%,
30%,
50%,
70%,
90% {
transform: rotate(-10deg);
}
20%,
40%,
60%,
80% {
transform: rotate(10deg);
}
}
</style>
@@ -9,43 +9,46 @@
@click.stop=""
>
<div class="header">
<span class="text">{{ $t('notice.title') }}</span>
<span class="btn">{{ $t('notice.btnRead') }}</span>
<!-- <span class="text">{{ $t('notice.title') }}</span> -->
<span class="text">消息</span>
<span class="btn" @click="readAll">{{ $t('notice.btnRead') }}</span>
</div>
<ul class="bar">
<li
v-for="(item, index) in barList"
:key="index"
:class="{ active: barActiveIndex === index }"
@click="changeBar(index)"
>
{{ item.name }} ({{ item.num }})
{{ item.name }} ({{ noticeList.length }})
</li>
</ul>
<div class="content">
<div class="scroll">
<!-- 通知 -->
<ul class="notice-list" v-show="barActiveIndex === 0">
<li v-for="(item, index) in noticeList" :key="index">
<ul class="notice-list">
<!-- <ul class="notice-list" v-show="barActiveIndex === 0"> -->
<li v-for="(item, index) in noticeList" :key="index" @click="showfn(item)" v-ripple>
<div
class="icon"
:style="{ background: getNoticeStyle(item.type).backgroundColor + '!important' }"
:style="{
background: getNoticeStyle(item.message.scene).backgroundColor + '!important'
}"
>
<i
class="iconfont-sys"
:style="{ color: getNoticeStyle(item.type).iconColor + '!important' }"
v-html="getNoticeStyle(item.type).icon"
:style="{ color: getNoticeStyle(item.message.scene).iconColor + '!important' }"
v-html="getNoticeStyle(item.message.scene).icon"
>
</i>
</div>
<div class="text">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
<h4>{{ item.message.title }}</h4>
<p>{{ item.message.created_at }}</p>
</div>
</li>
</ul>
<!-- 消息 -->
<ul class="user-list" v-show="barActiveIndex === 1">
<!-- <ul class="user-list" v-show="barActiveIndex === 1">
<li v-for="(item, index) in msgList" :key="index">
<div class="avatar">
<img :src="item.avatar" />
@@ -55,27 +58,27 @@
<p>{{ item.time }}</p>
</div>
</li>
</ul>
</ul> -->
<!-- 待办 -->
<ul class="base" v-show="barActiveIndex === 3">
<!-- <ul class="base" v-show="barActiveIndex === 3">
<li v-for="(item, index) in pendingList" :key="index">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
</li>
</ul>
</ul> -->
<div class="empty-tips" v-show="barActiveIndex === 0 && noticeList.length === 0">
<div class="empty-tips" v-show="noticeList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
</div>
<div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
<!-- <div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
</div>
<div class="empty-tips" v-show="barActiveIndex === 2 && pendingList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
</div>
</div> -->
</div>
<div class="btn-wrapper">
<el-button class="view-all" @click="handleViewAll" v-ripple>
@@ -86,6 +89,25 @@
<div style="height: 100px"></div>
</div>
<!-- 全局通知详情弹窗 -->
<Teleport to="body">
<ElDialog v-model="dialogVisible" title="通知详情" align-center :append-to-body="true">
<div class="detail-info-table">
<div class="row">
<div>发送人:{{ noticeDetail.sender_name }}</div>
</div>
<div class="row">
<div>通知标题:{{ noticeDetail.title }}</div>
</div>
<div class="row">
<div>
通知内容:
<div class="message-text" v-html="noticeDetail.content"></div>
</div>
</div>
</div>
</ElDialog>
</Teleport>
</template>
<script setup lang="ts">
@@ -97,7 +119,10 @@
import avatar6 from '@/assets/img/avatar/avatar6.webp'
import AppConfig from '@/config'
import { useI18n } from 'vue-i18n'
import { commonService } from '@/api/commonApi'
import { mittBus } from '@/utils/sys'
const dialogVisible = ref(false)
const router = useRouter()
const { t } = useI18n()
const props = defineProps({
@@ -123,85 +148,38 @@
{
name: computed(() => t('notice.bar[0]')),
num: 1
},
{
name: computed(() => t('notice.bar[1]')),
num: 1
},
{
name: computed(() => t('notice.bar[2]')),
num: 0
}
])
const noticeList = ref<any>([])
const getNoticeList = async () => {
await commonService.getUnReadNoticeList().then((res: any) => {
noticeList.value = res.list
// 通知头部组件更新铃铛状态
mittBus.emit('notification-data-changed', res.list?.length || 0)
})
}
onMounted(() => {
getNoticeList()
// 监听WebSocket消息:收到新通知实时插入到列表首位
mittBus.on('ws:message', onWsMessage)
})
onUnmounted(() => {
mittBus.off('ws:message')
})
const noticeList = [
{
title: '新增国际化',
time: '2024-6-13 0:10',
type: 'notice'
},
{
title: '冷月呆呆给你发了一条消息',
time: '2024-4-21 8:05',
type: 'message'
},
{
title: '小肥猪关注了你',
time: '2020-3-17 21:12',
type: 'collection'
},
{
title: '新增使用文档',
time: '2024-02-14 0:20',
type: 'notice'
},
{
title: '小肥猪给你发了一封邮件',
time: '2024-1-20 0:15',
type: 'email'
},
{
title: '菜单mock本地真实数据',
time: '2024-1-17 22:06',
type: 'notice'
const onWsMessage = (data: any) => {
if (data && data.event === 'notify.receive') {
// 将新通知插入到列表首位。后端数据结构需包含 type 与 message 字段
// 若结构不同,可在此处做一次字段映射
console.log(data)
noticeList.value.unshift(data.data.notice_data)
console.log(noticeList.value)
mittBus.emit('notification-data-changed', noticeList.value.length)
}
]
const msgList: any = [
{
title: '池不胖 关注了你',
time: '2021-2-26 23:50',
avatar: avatar1
},
{
title: '唐不苦 关注了你',
time: '2021-2-21 8:05',
avatar: avatar2
},
{
title: '中小鱼 关注了你',
time: '2020-1-17 21:12',
avatar: avatar3
},
{
title: '何小荷 关注了你',
time: '2021-01-14 0:20',
avatar: avatar4
},
{
title: '誶誶淰 关注了你',
time: '2020-12-20 0:15',
avatar: avatar5
},
{
title: '冷月呆呆 关注了你',
time: '2020-12-17 22:06',
avatar: avatar6
}
]
const changeBar = (index: number) => {
barActiveIndex.value = index
}
// const changeBar = (index: number) => {
// barActiveIndex.value = index
// }
const getRandomColor = () => {
const index = Math.floor(Math.random() * AppConfig.systemMainColor.length)
@@ -209,27 +187,27 @@
}
const noticeStyleMap = {
email: {
4: {
icon: '&#xe72e;',
iconColor: 'rgb(var(--art-warning))',
backgroundColor: 'rgb(var(--art-bg-warning))'
},
message: {
2: {
icon: '&#xe747;',
iconColor: 'rgb(var(--art-success))',
backgroundColor: 'rgb(var(--art-bg-success))'
},
collection: {
5: {
icon: '&#xe714;',
iconColor: 'rgb(var(--art-danger))',
backgroundColor: 'rgb(var(--art-bg-danger))'
},
user: {
3: {
icon: '&#xe608;',
iconColor: 'rgb(var(--art-info))',
backgroundColor: 'rgb(var(--art-bg-info))'
},
notice: {
1: {
icon: '&#xe6c2;',
iconColor: 'rgb(var(--art-primary))',
backgroundColor: 'rgb(var(--art-bg-primary))'
@@ -243,7 +221,11 @@
backgroundColor: getRandomColor()
}
const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
// 先转成 unknown,再转成 keyof 类型,避免 TS 报错
const key = type as unknown as keyof typeof noticeStyleMap
const style = noticeStyleMap[key] || defaultStyle
// const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
return {
...style,
@@ -264,27 +246,43 @@
}, 350)
}
}
const readAll = async () => {
const { message } = await commonService.readNotice({
msg_ids: 'all'
})
ElMessage.success(message)
getNoticeList()
}
// 查看全部
const handleViewAll = () => {
switch (barActiveIndex.value) {
case 0:
handleNoticeAll()
break
case 1:
handleMsgAll()
break
case 2:
handlePendingAll()
break
}
const noticeDetail = ref<any>({})
const showfn = async (row: any) => {
dialogVisible.value = true
await commonService.getNoticeDetail({ id: row.id }).then((res: any) => {
noticeDetail.value = res.message
showNotice(false) // 关闭通知组件
getNoticeList()
})
}
const handleNoticeAll = () => {
router.push('/common/notice')
showNotice(false) // 关闭通知组件
}
const handleNoticeAll = () => {}
const handleMsgAll = () => {}
const handlePendingAll = () => {}
</script>
<style lang="scss" scoped>
@use './style';
.el-button {
--el-button-border-color: #dcdfe6 !important;
--el-button-text-color: var(--el-text-color-regular);
}
.message-text {
padding: 10px 14px;
line-height: 1.4;
white-space: pre-line;
}
</style>
+151 -78
View File
@@ -3,56 +3,38 @@ import { useUserStore } from '@/store/modules/user'
type JsonValue = Record<string, any> | 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 {
// 忽略错误,使用fallback方案
}
} catch (_) {
randomPart = Math.random().toString(36).slice(2)
}
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
})
+25
View File
@@ -175,6 +175,15 @@
>
<i class="iconfont-sys"> &#xe64e;</i>跟进记录
</el-link>
<el-link
type="danger"
underline="never"
style="margin-right: 8px"
@click="delectfn(scope.row)"
v-if="hasAuth('customer@delete')"
>
<i class="iconfont-sys">&#xe6d4;</i>删除
</el-link>
</template>
</el-table-column>
</template>
@@ -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(() => {
// 用户点击了取消
})
}
</script>
<style scoped lang="scss">
+573
View File
@@ -0,0 +1,573 @@
<template>
<div class="customer-page">
<!-- 顶部操作栏 -->
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
</div>
<!-- <el-button type="primary" @click="exportfn" v-if="hasAuth('customer@export')">
<i class="iconfont-sys">&#xe89f;</i> 导出
</el-button> -->
</div>
<div class="boxshadow">
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
lass="customer-table"
>
<template #default>
<el-table-column type="selection" width="55px" align="center"></el-table-column>
<el-table-column
prop="code"
label="编号"
align="center"
width="170px"
show-overflow-tooltip
/>
<el-table-column prop="contacts" label="客户名称" align="center" />
<el-table-column prop="magnitude_txt" label="客户商机" align="center" />
<el-table-column prop="channel_name" label="渠道" align="center" />
<el-table-column prop="country_name" label="国家" align="center" />
<!-- <el-table-column prop="phone" label="电话" align="center" />
<el-table-column prop="WhatsApp" label="WhatsApp" align="center" />
<el-table-column prop="email" label="邮箱" align="center" /> -->
<el-table-column prop="follow" label="联系方式" width="240px" show-overflow-tooltip>
<template #default="scope">
<div
>WhatsApp:{{
scope.row.WhatsApp == '' ? ' --' : '【' + scope.row.WhatsApp + ' 】'
}}</div
>
<div>电话:{{ scope.row.phone == '' ? ' --' : '【' + scope.row.phone + ' 】' }}</div>
<div>邮箱:{{ scope.row.email == '' ? ' --' : '【' + scope.row.email + ' 】' }}</div>
</template>
</el-table-column>
<el-table-column prop="grade_txt" label="等级" align="center" />
<!-- <el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.follow)">
{{ scope.row.follow_txt }}
</span>
</template>
</el-table-column> -->
<el-table-column prop="enter_user_name" label="录单人"></el-table-column>
<el-table-column prop="user_name" label="跟单人"></el-table-column>
<el-table-column prop="del_source" label="来源"></el-table-column>
<el-table-column prop="follow_last" label="上次跟进"></el-table-column>
<el-table-column prop="follow_next" label="下次跟进"></el-table-column>
<el-table-column prop="created_at" label="创建时间"></el-table-column>
<el-table-column fixed="right" label="操作" width="200px" align="center">
<template #default="scope">
<el-link
type="success"
underline="never"
style="margin-right: 8px"
@click="onDetail(scope.row)"
v-if="hasAuth('recycle@detail')"
>
<i class="iconfont-sys"> &#xe70f;</i>详情
</el-link>
<el-link
type="primary"
underline="never"
style="margin-right: 8px"
@click="recoverfn(scope.row)"
v-if="hasAuth('recycle@recover')"
>
<i class="iconfont-sys"> &#xe794;</i>恢复
</el-link>
<el-link
type="danger"
underline="never"
style="margin-right: 8px"
@click="delectfn(scope.row)"
v-if="hasAuth('recycle@delete')"
>
<i class="iconfont-sys">&#xe6d4;</i>删除
</el-link>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="详情" width="800px">
<div class="detail-info-table">
<div class="row">
<div>客户名称:{{ detailRow.contacts }}</div>
<div>电话:{{ detailRow.phone }}</div>
<div>国家:{{ detailRow.country_name }}</div>
</div>
<div class="row">
<div>渠道:{{ detailRow.channel_name }}</div>
<div>邮箱:{{ detailRow.email }}</div>
<div>WhatsApp:{{ detailRow.WhatsApp }}</div>
</div>
<div class="row">
<div>发布人:{{ detailRow.enter_user_name }}</div>
<div>跟单人:{{ detailRow.user_name }}</div>
<div>录单时间:{{ detailRow.created_at }}</div>
</div>
<div class="row">
<div>
等级:
<span>
{{ detailRow.magnitude_txt }}
</span>
</div>
<div>
状态:<span>{{ detailRow.grade_txt }}</span>
</div>
<div>
跟进状态:<span>{{ detailRow.follow_txt }}</span>
</div>
</div>
<div class="row">
<div>备注:{{ detailRow.describe }}</div>
</div>
</div>
<div v-if="detailRow.desc_collect != ''" class="dtitle">详情内容</div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
</el-dialog>
<!-- 图片预览对话框 -->
<el-image-viewer
v-if="imageViewerVisible"
:url-list="imageList"
:initial-index="imageViewerIndex"
@close="closeImageViewer"
/>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
const selectValue = ref<string[]>([])
const loading = ref(false)
const detailDialogVisible = ref(false)
const zyDialogVisible = ref(false)
const detailRow = ref<any>(null)
const keyword = ref<string>('')
// 图片预览相关
const imageViewerVisible = ref(false)
const imageList = ref<string[]>([])
const imageViewerIndex = ref(0)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const zyForm = reactive({
customer_id: '',
user_id: ''
})
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
getuserList()
gettableList()
})
// tabs 切换联动
watch([keyword], () => {
pagination.currentPage = 1
gettableList()
})
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize,
scene: 'recyclea'
}
if (keyword.value) {
params.keyword = keyword.value
}
const { data, current, size, total } = await manageService.getcrmCustomerList(params)
tableData.value = data.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
//导出
function processImages(html: string) {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
return doc.body.innerHTML
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
detailDialogVisible.value = true
}
const recoverfn = (row: any) => {
ElMessageBox.confirm('确定要恢复此条数据吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await manageService.customerRestore({ id: row.id })
ElMessage.success(res?.message || '提交成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
}
const delectfn = (row: any) => {
ElMessageBox.confirm('确定要删除此客户吗?删除后将无法恢复!', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await manageService.crmCustomerDelete({ customer_id: row.id, scene: 'force' })
ElMessage.success(res?.message || '提交成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
}
const closeImageViewer = () => {
imageViewerVisible.value = false
imageList.value = []
imageViewerIndex.value = 0
}
// 选中的行数据
const selectedRows = ref<any[]>([])
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
</script>
<style scoped lang="scss">
.timeline-img {
width: 180px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
:deep(.el-badge) {
--el-badge-size: 16px;
}
.itemtag {
margin-top: -16px;
margin-left: -13px;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__empty-block) {
height: 450px !important;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.customer-tags {
background: var(--art-main-bg-color);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-label {
width: 120px;
color: #666;
margin-right: 8px;
flex-shrink: 0;
font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
}
.customer-tabs {
margin-bottom: 8px;
}
.status-following {
color: #00a43f;
font-weight: bold;
}
.status-dealed {
color: #3471ff;
font-weight: bold;
}
.status-lost {
color: #ff0000;
font-weight: bold;
}
.customer-table {
background: var(--art-main-bg-color);
border-radius: 8px;
margin-top: 0;
}
.filter-more-btn {
text-align: center;
margin-top: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.tag-items {
cursor: pointer;
border-radius: 4px;
padding: 5px 10px;
transition: background 0.2s;
min-width: 100px;
display: inline-block;
margin-right: 30px;
}
.tag-item {
border-radius: 4px;
padding: 5px 10px;
}
.tag-itemall {
border-radius: 4px;
padding: 5px 10px;
margin-right: 20px;
}
.tag-active {
background: #3471ff;
color: #fff;
}
.tag-active :deep(.el-badge__content) {
color: #3471ff !important; /* 文字颜色 */
background-color: #fff !important; /* 背景色 */
}
.detail-info-table {
padding-left: 15px;
.row {
display: flex;
justify-content: flex-start;
margin-bottom: 6px;
> div {
flex: 1;
min-width: 0;
margin-right: 16px;
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.dtitle {
line-height: 40px;
color: #333;
font-size: 16px;
font-weight: bold;
padding-left: 15px;
}
.detils-content {
padding-left: 15px;
img {
max-width: 100% !important;
}
}
.follow-record-header {
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
margin-bottom: 15px;
.customer-info {
display: flex;
align-items: center;
.label {
font-size: 14px;
color: #606266;
margin-right: 5px;
}
.value {
font-size: 14px;
color: #303133;
font-weight: bold;
}
}
}
.follow-record-list {
padding: 15px;
max-height: 500px;
overflow-y: auto;
.list-title {
font-size: 16px;
font-weight: bold;
color: #303133;
margin-bottom: 15px;
padding-left: 10px;
}
.record-item {
background-color: #f5f7fa;
border-radius: 4px;
padding: 10px 15px;
margin-bottom: 10px;
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
.record-type {
font-size: 14px;
color: #3471ff;
font-weight: bold;
background-color: #e6f7ff;
padding: 2px 8px;
border-radius: 4px;
}
.record-time {
font-size: 14px;
color: #909399;
}
.record-user {
font-size: 14px;
color: #3471ff;
font-weight: bold;
}
}
.record-content {
font-size: 14px;
color: #303133;
line-height: 1.6;
margin-bottom: 5px;
}
.record-footer {
.next-follow {
font-size: 14px;
color: #909399;
}
}
}
.no-records {
text-align: center;
color: #909399;
padding: 20px 0;
}
}
</style>
+11 -1
View File
@@ -15,6 +15,8 @@
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="ID" prop="id" />
@@ -123,7 +125,15 @@
onMounted(() => {
getWorkList()
})
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
// 获取列表数据
const getWorkList = async () => {
loading.value = true
+11 -1
View File
@@ -21,6 +21,8 @@
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="ID" prop="id" width="80" />
@@ -209,7 +211,6 @@
getcompanyList()
getsystemsList()
})
// 获取列表数据
const getWorkList = async () => {
loading.value = true
@@ -230,6 +231,15 @@
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
const dialogType = ref('add')
-1
View File
@@ -496,7 +496,6 @@
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
system_id: [{ required: true, message: '请选择系统', trigger: 'blur' }],
type: [{ required: true, message: '请选择资源类型', trigger: 'blur' }],
code: [{ required: true, message: '输入权限标识', trigger: 'blur' }]
})
+266
View File
@@ -0,0 +1,266 @@
<template>
<div class="customer-page">
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
</div>
</div>
<div class="boxshadow">
<ElRow>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('system@notice_add')"
>新增</ElButton
>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="编号" prop="msg_no" width="240px" />
<ElTableColumn label="通知标题" prop="title" />
<ElTableColumn label="通知内容" prop="content" />
<ElTableColumn label="推送类型" prop="push_method_name" />
<ElTableColumn label="场景" prop="scene_name" />
<ElTableColumn label="创建时间" prop="created_at" />
<ElTableColumn fixed="right" label="操作" width="200px">
<template #default="scope">
<ElRow>
<el-button link @click="showfn(scope.row)"> 详情 </el-button>
<el-button
type="danger"
link
@click="deleteRole(scope.row)"
v-if="hasAuth('system@notice_destroy')"
>
删除
</el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<ElDialog v-model="dialogVisible" title="新增通知" align-center>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" class="form">
<ElFormItem label="通知标题" prop="title">
<ElInput v-model="form.title" />
</ElFormItem>
<ElFormItem label="通知内容" prop="content">
<ElInput v-model="form.content" type="textarea" :rows="3" placeholder="请输入通知内容" />
</ElFormItem>
<ElFormItem label="通知用户" prop="recipient">
<el-select
v-model="form.recipient"
multiple
filterable
placeholder="请选择要通知的用户"
style="width: 100%"
>
<el-option
v-for="item in companyUserList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-text class="mx-1" type="warning">不选择用户,代表通知所有用户!</el-text>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
<ElDialog v-model="dialogVisiblexq" title="通知详情" align-center>
<div class="detail-info-table">
<div class="row">
<div>发送人:{{ noticeDetail.sender_name }}</div>
</div>
<div class="row">
<div>通知标题:{{ noticeDetail.title }}</div>
</div>
<div class="row">
<div>
通知内容:
<div class="message-text" v-html="noticeDetail.content"></div>
</div>
</div>
</div>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { systemService } from '@/api/systemApi'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'Type ' })
const loading = ref(false)
const dialogVisible = ref(false)
const dialogVisiblexq = ref(false)
const formRef = ref<FormInstance>()
const { width } = useWindowSize()
const keyword = ref<string>('')
const isMobile = computed(() => width.value < 500)
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
abbr: [{ required: true, message: '请输入公司简称', trigger: 'blur' }]
// tenant_key: [{ required: true, message: '请输入飞书标识', trigger: 'blur' }]
})
const companyUserList = ref<any>()
// 获取用户列表
const getCompanyUserList = async () => {
const response = await manageService.presetusers()
companyUserList.value = response || []
}
const form = reactive<any>({
title: '',
content: '',
recipient: []
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
onMounted(() => {
getWorkList()
})
watch([keyword], () => {
pagination.currentPage = 1
getWorkList()
})
const noticeDetail = ref<any>({})
const showfn = async (row: any) => {
dialogVisiblexq.value = true
await systemService.getNoticeDetail({ id: row.id }).then((res: any) => {
noticeDetail.value = res
console.log(res)
})
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
// 获取列表数据
const getWorkList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const { records, current, size, total } = await systemService.getNoticeList({
current: currentPage,
size: pageSize
})
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const dialogType = ref('add')
const showDialog = (type: string) => {
getCompanyUserList()
dialogVisible.value = true
dialogType.value = type
form.title = ''
form.content = ''
form.recipient = []
}
const deleteRole = (row?: any) => {
ElMessageBox.confirm('确定删除吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await systemService.delNotice({
id: row.id
})
ElMessage.success(message)
getWorkList()
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
const { message } = await systemService.sendNotice(form)
ElMessage.success(message)
dialogVisible.value = false
getWorkList()
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.message-text {
padding: 10px 14px;
line-height: 1.4;
white-space: pre-line;
}
</style>
+11 -1
View File
@@ -20,6 +20,8 @@
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="ID" prop="id" width="80" />
@@ -236,7 +238,15 @@
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
// 系统类型相关辅助函数
const getTypeText = (type: string) => {
const typeMap: { [key: string]: string } = {
+257
View File
@@ -0,0 +1,257 @@
<template>
<div class="customer-page">
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
</div>
</div>
<div class="boxshadow">
<el-button type="primary" @click="readfnpl" :disabled="selectedRows.length === 0">
<i class="iconfont-sys">&#xe62c;</i> 批量已读
</el-button>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<el-table-column type="selection" width="55px" align="center"></el-table-column>
<el-table-column prop="id" label="编号" align="center" width="240px">
<template #default="scope">
<span>
{{ scope.row.message.msg_no }}
</span>
</template>
</el-table-column>
<el-table-column prop="id" label="通知标题" align="center">
<template #default="scope">
<span>
{{ scope.row.message.title }}
</span>
</template>
</el-table-column>
<el-table-column prop="id" label="通知内容" align="center">
<template #default="scope">
<span>
{{ scope.row.message.content }}
</span>
</template>
</el-table-column>
<el-table-column prop="id" label="发送人" align="center">
<template #default="scope">
<span>
{{ scope.row.message.sender_name }}
</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" align="center">
<template #default="scope">
<el-text class="mx-1" :type="scope.row.status === 0 ? 'info' : 'success'">
{{ scope.row.status === 1 ? '已读' : '未读' }}
</el-text>
</template>
</el-table-column>
<el-table-column prop="id" label="创建时间" align="center">
<template #default="scope">
<span>
{{ scope.row.message.created_at }}
</span>
</template>
</el-table-column>
<ElTableColumn fixed="right" label="操作" width="200px">
<template #default="scope">
<ElRow>
<el-button link @click="showfn(scope.row)"> 详情 </el-button>
<!-- <el-button type="success" link @click="readfn(scope.row)"> 已读 </el-button> -->
<el-button type="danger" link @click="deleteRole(scope.row)"> 删除 </el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<ElDialog v-model="dialogVisible" title="通知详情" align-center>
<div class="detail-info-table">
<div class="row">
<div>发送人:{{ noticeDetail.sender_name }}</div>
</div>
<div class="row">
<div>通知标题:{{ noticeDetail.title }}</div>
</div>
<div class="row">
<div>
通知内容:
<div class="message-text" v-html="noticeDetail.content"></div>
</div>
</div>
</div>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { commonService } from '@/api/commonApi'
// import { useAuth } from '@/composables/useAuth'
// const { hasAuth } = useAuth()
defineOptions({ name: 'Type ' })
const loading = ref(false)
const dialogVisible = ref(false)
const keyword = ref<string>('')
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
onMounted(() => {
getWorkList()
})
watch([keyword], () => {
pagination.currentPage = 1
getWorkList()
})
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
// 选中的行数据
const selectedRows = ref<any[]>([])
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 获取列表数据
const getWorkList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const { records, current, size, total } = await commonService.getNoticeList({
current: currentPage,
size: pageSize
})
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const noticeDetail = ref<any>({})
const showfn = async (row: any) => {
dialogVisible.value = true
await commonService.getNoticeDetail({ id: row.id }).then((res: any) => {
noticeDetail.value = res.message
console.log(res)
})
}
const deleteRole = (row?: any) => {
ElMessageBox.confirm('确定删除吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await commonService.delNotice({
id: row.id
})
ElMessage.success(message)
getWorkList()
})
}
const readfnpl = async () => {
ElMessageBox.confirm('确定已读吗?', '确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await commonService.readNotice({
msg_ids: selectedRows.value
})
ElMessage.success(message)
getWorkList()
})
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.detail-info-table {
padding-left: 15px;
.row {
display: flex;
justify-content: flex-start;
margin-bottom: 6px;
> div {
flex: 1;
min-width: 0;
margin-right: 16px;
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.message-text {
padding: 10px 14px;
line-height: 1.4;
white-space: pre-line;
}
</style>
+2 -2
View File
@@ -46,8 +46,8 @@
<el-radio label="2">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="联系方式" prop="phone">
<el-input v-model="form.phone" placeholder="请输入联系方式" />
<el-form-item label="电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入电话" />
</el-form-item>
<el-form-item label="Email" prop="email">
<el-input v-model="form.email" placeholder="请输入Email" />
+2 -2
View File
@@ -46,8 +46,8 @@
<el-radio :label="2">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="联系方式" prop="phone">
<el-input v-model="form.phone" placeholder="请输入联系方式" />
<el-form-item label="电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入电话" />
</el-form-item>
<el-form-item label="Email" prop="email">
<el-input v-model="form.email" placeholder="请输入Email" />
+1 -1
View File
@@ -20,7 +20,7 @@
<span>渠道:{{ customerInfo.channel_name }}</span>
</el-col>
<el-col :sm="24" :md="6" :lg="5">
<span>联系方式:{{ customerInfo.phone }}</span>
<span>电话:{{ customerInfo.phone }}</span>
</el-col>
<el-col :sm="24" :md="6" :lg="4">
<span>客户偏好:{{ customerInfo.car_type_name }}</span>
+35 -2
View File
@@ -188,13 +188,27 @@
>
<i class="iconfont-sys">&#xe6bf;</i> 跟进
</el-link>
<el-link type="danger" underline="never" @click="reportfn(scope.row)">
<el-link
type="warning"
underline="never"
@click="reportfn(scope.row)"
style="margin-right: 8px"
>
<i class="iconfont-sys">&#xe701;</i> 标记无效
</el-link>
<el-link
type="danger"
underline="never"
@click="delectfn(scope.row)"
style="margin-right: 8px"
v-if="hasAuth('crm_customer@remove')"
>
<i class="iconfont-sys">&#xe893;</i> 移除
</el-link>
<el-link
type="danger"
underline="never"
@click="crmdelectfn(scope.row)"
v-if="hasAuth('crm_customer@delete')"
>
<i class="iconfont-sys">&#xe6d4;</i> 删除
@@ -453,7 +467,7 @@
}
//删除
const delectfn = (row: any) => {
ElMessageBox.confirm('确定要删除此用户吗?', '提示', {
ElMessageBox.confirm('确定要移除此客户到公海吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
@@ -468,6 +482,25 @@
// 用户点击了取消
})
}
const crmdelectfn = (row: any) => {
ElMessageBox.confirm(
'确定要删除此客户吗?删除后无法恢复,若存在疑问可标记为无效用户或操作移除,将客户移除到公海!',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await CrmService.crmCustomerDelete({ id: row.id, scene: 'force' })
ElMessage.success(res?.message || '提交成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
}
// 标记无效
const reportfn = (row: any) => {
reportForm.id = row.id