✨ feat(im): 增加 im 的管理界面
parent
4b4c4fab11
commit
01fff53aaf
|
|
@ -748,11 +748,10 @@ const remainingRouter: AppRouteRecordRaw[] = [
|
|||
]
|
||||
},
|
||||
{
|
||||
// 统一 /im 分组:下分 home(聊天壳)+ manager(Layout 管理壳)
|
||||
path: '/im',
|
||||
name: 'Im',
|
||||
redirect: '/im/home/conversation',
|
||||
meta: { hidden: false, title: 'IM 即时通讯' },
|
||||
meta: { hidden: true, title: 'IM 即时通讯' },
|
||||
children: [
|
||||
{
|
||||
path: 'home',
|
||||
|
|
@ -774,132 +773,6 @@ const remainingRouter: AppRouteRecordRaw[] = [
|
|||
meta: { hidden: true, title: '通讯录' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'manager/message/private',
|
||||
component: Layout,
|
||||
name: 'ImManagerPrivateMessage',
|
||||
redirect: '/im/manager/message/private/index',
|
||||
meta: { hidden: false },
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: () => import('@/views/im/manager/message/private/index.vue'),
|
||||
name: 'ImManagerPrivateMessageIndex',
|
||||
meta: {
|
||||
canTo: true,
|
||||
hidden: false,
|
||||
noTagsView: false,
|
||||
icon: 'ep:user',
|
||||
title: '私聊消息'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'manager/message/group',
|
||||
component: Layout,
|
||||
name: 'ImManagerGroupMessage',
|
||||
redirect: '/im/manager/message/group/index',
|
||||
meta: { hidden: false },
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: () => import('@/views/im/manager/message/group/index.vue'),
|
||||
name: 'ImManagerGroupMessageIndex',
|
||||
meta: {
|
||||
canTo: true,
|
||||
hidden: false,
|
||||
noTagsView: false,
|
||||
icon: 'ep:chat-line-round',
|
||||
title: '群聊消息'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'manager/friend',
|
||||
component: Layout,
|
||||
name: 'ImManagerFriend',
|
||||
redirect: '/im/manager/friend/index',
|
||||
meta: { hidden: false },
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: () => import('@/views/im/manager/friend/index.vue'),
|
||||
name: 'ImManagerFriendIndex',
|
||||
meta: {
|
||||
canTo: true,
|
||||
hidden: false,
|
||||
noTagsView: false,
|
||||
icon: 'ep:user',
|
||||
title: '好友管理'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'manager/group',
|
||||
component: Layout,
|
||||
name: 'ImManagerGroup',
|
||||
redirect: '/im/manager/group/index',
|
||||
meta: { hidden: false },
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: () => import('@/views/im/manager/group/index.vue'),
|
||||
name: 'ImManagerGroupIndex',
|
||||
meta: {
|
||||
canTo: true,
|
||||
hidden: false,
|
||||
noTagsView: false,
|
||||
icon: 'ep:user-filled',
|
||||
title: '群管理'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'manager/sensitive-word',
|
||||
component: Layout,
|
||||
name: 'ImManagerSensitiveWord',
|
||||
redirect: '/im/manager/sensitive-word/index',
|
||||
meta: { hidden: false },
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: () => import('@/views/im/manager/sensitive-word/index.vue'),
|
||||
name: 'ImManagerSensitiveWordIndex',
|
||||
meta: {
|
||||
canTo: true,
|
||||
hidden: false,
|
||||
noTagsView: false,
|
||||
icon: 'ep:warning',
|
||||
title: '敏感词管理'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'manager/statistics',
|
||||
component: Layout,
|
||||
name: 'ImManagerStatistics',
|
||||
redirect: '/im/manager/statistics/index',
|
||||
meta: { hidden: false },
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: () => import('@/views/im/manager/statistics/index.vue'),
|
||||
name: 'ImManagerStatisticsIndex',
|
||||
meta: {
|
||||
canTo: true,
|
||||
hidden: false,
|
||||
noTagsView: false,
|
||||
icon: 'ep:trend-charts',
|
||||
title: '数据看板'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,5 +250,12 @@ export enum DICT_TYPE {
|
|||
IOT_OTA_TASK_STATUS = 'iot_ota_task_status', // IoT OTA 任务状态
|
||||
IOT_OTA_TASK_RECORD_STATUS = 'iot_ota_task_record_status', // IoT OTA 记录状态
|
||||
IOT_MODBUS_MODE = 'iot_modbus_mode', // IoT Modbus 工作模式
|
||||
IOT_MODBUS_FRAME_FORMAT = 'iot_modbus_frame_format' // IoT Modbus 帧格式
|
||||
IOT_MODBUS_FRAME_FORMAT = 'iot_modbus_frame_format', // IoT Modbus 帧格式
|
||||
|
||||
// ========== IM - 即时通讯模块 ==========
|
||||
IM_MESSAGE_TYPE = 'im_message_type', // IM 消息类型
|
||||
IM_MESSAGE_STATUS = 'im_message_status', // IM 消息状态
|
||||
IM_GROUP_MESSAGE_RECEIPT_STATUS = 'im_group_message_receipt_status', // IM 群消息回执状态
|
||||
IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态
|
||||
IM_GROUP_STATUS = 'im_group_status' // IM 群状态
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="80px"
|
||||
>
|
||||
<!-- TODO @AI:使用 userselectv2 组件 -->
|
||||
<el-form-item label="用户编号" prop="userId">
|
||||
<el-input
|
||||
v-model="queryParams.userId"
|
||||
placeholder="请输入用户编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<!-- TODO @AI:使用 userselectv2 组件 -->
|
||||
<el-form-item label="好友编号" prop="friendUserId">
|
||||
<el-input
|
||||
v-model="queryParams.friendUserId"
|
||||
placeholder="请输入好友用户编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="好友状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择好友状态"
|
||||
clearable
|
||||
class="!w-160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IM_FRIEND_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="免打扰" prop="muted">
|
||||
<el-select
|
||||
v-model="queryParams.muted"
|
||||
placeholder="请选择免打扰状态"
|
||||
clearable
|
||||
class="!w-160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
|
||||
:key="String(dict.value)"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加时间" prop="addTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.addTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list">
|
||||
<el-table-column label="编号" align="center" prop="id" width="100" />
|
||||
<!-- TODO @AI:宽度调整下 -->
|
||||
<el-table-column label="用户" align="center" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.userNickname || '-' }}</span>
|
||||
<span class="text-gray-400 ml-5px">({{ row.userId }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- TODO @AI:宽度调整下 -->
|
||||
<el-table-column label="好友" align="center" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.friendNickname || '-' }}</span>
|
||||
<span class="text-gray-400 ml-5px">({{ row.friendUserId }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" align="center" prop="displayName" width="120" />
|
||||
<el-table-column label="免打扰" align="center" prop="muted" width="80">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.muted" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center" prop="status" width="100">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :type="DICT_TYPE.IM_FRIEND_STATUS" :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="添加时间"
|
||||
align="center"
|
||||
prop="addTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column
|
||||
label="删除时间"
|
||||
align="center"
|
||||
prop="deleteTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { DICT_TYPE, getIntDictOptions, getBoolDictOptions } from '@/utils/dict'
|
||||
import * as ManagerFriendApi from '@/api/im/manager/friend'
|
||||
|
||||
defineOptions({ name: 'ImFriend' })
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const total = ref(0) // 列表的总页数
|
||||
const list = ref<ManagerFriendApi.ImManagerFriendVO[]>([]) // 列表的数据
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined as number | undefined,
|
||||
friendUserId: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
muted: undefined as boolean | undefined,
|
||||
addTime: [] as string[]
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询好友关系分页 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ManagerFriendApi.getManagerFriendPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="600px" :destroy-on-close="true">
|
||||
<el-table v-loading="loading" :data="memberList" border>
|
||||
<el-table-column label="头像" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :src="row.avatar" :size="40">
|
||||
{{ row.nickname?.charAt(0) ?? '?' }}
|
||||
</el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户编号" prop="userId" width="120" align="center" />
|
||||
<el-table-column label="昵称" prop="nickname" min-width="120" />
|
||||
<el-table-column
|
||||
label="入群时间"
|
||||
prop="joinTime"
|
||||
width="180"
|
||||
align="center"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import * as ManagerGroupApi from '@/api/im/manager/group'
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberDrawer' })
|
||||
|
||||
const drawerVisible = ref(false) // 抽屉的显示
|
||||
const drawerTitle = ref('群成员列表') // 抽屉的标题
|
||||
const loading = ref(false) // 列表的加载中
|
||||
const memberList = ref<ManagerGroupApi.ImManagerGroupMemberVO[]>([]) // 群成员列表
|
||||
|
||||
/** 打开抽屉,加载指定群的成员 */
|
||||
const open = async (groupId: number, groupName?: string) => {
|
||||
drawerVisible.value = true
|
||||
drawerTitle.value = groupName ? `群成员 - ${groupName}` : '群成员列表'
|
||||
loading.value = true
|
||||
try {
|
||||
memberList.value = await ManagerGroupApi.getManagerGroupMemberList(groupId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="群名称" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入群名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="群主编号" prop="ownerUserId">
|
||||
<el-input
|
||||
v-model="queryParams.ownerUserId"
|
||||
placeholder="请输入群主用户编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="群状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择群状态"
|
||||
clearable
|
||||
class="!w-160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IM_GROUP_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否封禁" prop="banned">
|
||||
<el-select
|
||||
v-model="queryParams.banned"
|
||||
placeholder="请选择封禁状态"
|
||||
clearable
|
||||
class="!w-160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
|
||||
:key="String(dict.value)"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list">
|
||||
<el-table-column label="编号" align="center" prop="id" width="100" />
|
||||
<el-table-column label="头像" align="center" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :src="row.avatar" :size="40">
|
||||
{{ row.name?.charAt(0) ?? '?' }}
|
||||
</el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="群名称" align="center" prop="name" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="群主" align="center" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.ownerNickname || '-' }}</span>
|
||||
<span class="text-gray-400 ml-5px">({{ row.ownerUserId }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成员数" align="center" prop="memberCount" width="90" />
|
||||
<el-table-column label="群状态" align="center" prop="status" width="100">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :type="DICT_TYPE.IM_GROUP_STATUS" :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="封禁状态" align="center" prop="banned" width="120">
|
||||
<template #default="{ row }">
|
||||
<!-- 已封禁需要 tooltip 展示封禁原因,故 wrap 一层 -->
|
||||
<el-tooltip v-if="row.banned" :content="row.bannedReason" placement="top">
|
||||
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.banned" />
|
||||
</el-tooltip>
|
||||
<dict-tag v-else :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.banned" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openMemberDrawer(row)"
|
||||
v-hasPermi="['im:manager:group:query']"
|
||||
>
|
||||
成员
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!row.banned"
|
||||
link
|
||||
type="danger"
|
||||
@click="openBanDialog(row)"
|
||||
v-hasPermi="['im:manager:group:ban']"
|
||||
>
|
||||
封禁
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
link
|
||||
type="primary"
|
||||
@click="handleUnban(row)"
|
||||
v-hasPermi="['im:manager:group:ban']"
|
||||
>
|
||||
解封
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 群成员抽屉 -->
|
||||
<GroupMemberDrawer ref="memberDrawerRef" />
|
||||
|
||||
<!-- 封禁原因弹窗 -->
|
||||
<el-dialog v-model="banDialogVisible" title="封禁群" width="500" :close-on-click-modal="false">
|
||||
<el-form :model="banForm" :rules="banFormRules" ref="banFormRef" label-width="80px">
|
||||
<el-form-item label="群名称">
|
||||
<span>{{ banForm.groupName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="封禁原因" prop="reason">
|
||||
<el-input
|
||||
v-model="banForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入封禁原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :loading="banSubmitting" type="primary" @click="submitBan">确 定</el-button>
|
||||
<el-button @click="banDialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE, getIntDictOptions, getBoolDictOptions } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import * as ManagerGroupApi from '@/api/im/manager/group'
|
||||
import GroupMemberDrawer from './components/GroupMemberDrawer.vue'
|
||||
|
||||
defineOptions({ name: 'ImGroup' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const total = ref(0) // 列表的总页数
|
||||
const list = ref<ManagerGroupApi.ImManagerGroupVO[]>([]) // 列表的数据
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: undefined as string | undefined,
|
||||
ownerUserId: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
banned: undefined as boolean | undefined,
|
||||
createTime: [] as string[]
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询群分页 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ManagerGroupApi.getManagerGroupPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
const memberDrawerRef = ref<InstanceType<typeof GroupMemberDrawer>>() // 群成员抽屉 Ref
|
||||
|
||||
/** 打开群成员抽屉 */
|
||||
const openMemberDrawer = (row: ManagerGroupApi.ImManagerGroupVO) => {
|
||||
memberDrawerRef.value?.open(row.id, row.name)
|
||||
}
|
||||
|
||||
const banDialogVisible = ref(false) // 封禁弹窗的显示
|
||||
const banSubmitting = ref(false) // 封禁提交的加载中
|
||||
const banForm = reactive({ id: 0, groupName: '', reason: '' }) // 封禁表单
|
||||
const banFormRef = ref() // 封禁表单 Ref
|
||||
const banFormRules = {
|
||||
reason: [{ required: true, message: '封禁原因不能为空', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
/** 打开封禁弹窗 */
|
||||
const openBanDialog = (row: ManagerGroupApi.ImManagerGroupVO) => {
|
||||
banForm.id = row.id
|
||||
banForm.groupName = row.name
|
||||
banForm.reason = ''
|
||||
banDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 提交封禁 */
|
||||
const submitBan = async () => {
|
||||
await banFormRef.value.validate()
|
||||
banSubmitting.value = true
|
||||
try {
|
||||
// 发起封禁
|
||||
await ManagerGroupApi.banManagerGroup({ id: banForm.id, reason: banForm.reason })
|
||||
message.success('封禁成功')
|
||||
banDialogVisible.value = false
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} finally {
|
||||
banSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 解封按钮操作 */
|
||||
const handleUnban = async (row: ManagerGroupApi.ImManagerGroupVO) => {
|
||||
try {
|
||||
// 解封的二次确认
|
||||
await message.confirm(`确认解封群「${row.name}」吗?`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 发起解封
|
||||
await ManagerGroupApi.unbanManagerGroup(row.id)
|
||||
message.success('解封成功')
|
||||
// 刷新列表
|
||||
await getList()
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="群编号" prop="groupId">
|
||||
<el-input
|
||||
v-model="queryParams.groupId"
|
||||
placeholder="请输入群编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="发送人编号" prop="senderId">
|
||||
<el-input
|
||||
v-model="queryParams.senderId"
|
||||
placeholder="请输入发送人用户编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="消息类型" prop="type">
|
||||
<el-select
|
||||
v-model="queryParams.type"
|
||||
placeholder="请选择消息类型"
|
||||
clearable
|
||||
class="!w-160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IM_MESSAGE_TYPE)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="消息状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择消息状态"
|
||||
clearable
|
||||
class="!w-160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IM_MESSAGE_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="发送时间" prop="sendTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.sendTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list">
|
||||
<el-table-column label="编号" align="center" prop="id" width="100" />
|
||||
<el-table-column label="群" align="center" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.groupName || '-' }}</span>
|
||||
<span class="text-gray-400 ml-5px">({{ row.groupId }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送人" align="center" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.senderNickname || '-' }}</span>
|
||||
<span class="text-gray-400 ml-5px">({{ row.senderId }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" align="center" prop="type" width="100">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="row.type" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容预览" align="left" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ getContentPreview(row.content) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="@" align="center" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.atUserIds?.length ? row.atUserIds.length : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回执" align="center" prop="receiptStatus" width="100">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :type="DICT_TYPE.IM_GROUP_MESSAGE_RECEIPT_STATUS" :value="row.receiptStatus" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center" prop="status" width="100">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="发送时间"
|
||||
align="center"
|
||||
prop="sendTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openDetail(row)"
|
||||
v-hasPermi="['im:manager:message:query']"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="群聊消息详情" width="700">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="编号">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户端编号">{{ detail.clientMessageId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="群">{{ detail.groupName }} ({{ detail.groupId }})</el-descriptions-item>
|
||||
<el-descriptions-item label="发送人">{{ detail.senderNickname }} ({{ detail.senderId }})</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="@ 用户" :span="2">
|
||||
{{ detail.atUserIds?.length ? detail.atUserIds.join(', ') : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发送时间">{{ formatDate(detail.sendTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(detail.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="消息内容(原始 JSON)" :span="2">
|
||||
<pre class="content-pre">{{ formatJson(detail.content) }}</pre>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { dateFormatter, formatDate } from '@/utils/formatTime'
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import * as ManagerGroupMessageApi from '@/api/im/manager/message/group'
|
||||
import { getContentPreview, formatJson } from '../utils'
|
||||
|
||||
defineOptions({ name: 'ImGroupMessage' })
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const total = ref(0) // 列表的总页数
|
||||
const list = ref<ManagerGroupMessageApi.ImManagerGroupMessageVO[]>([]) // 列表的数据
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
groupId: undefined as number | undefined,
|
||||
senderId: undefined as number | undefined,
|
||||
type: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
sendTime: [] as string[]
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询群聊消息分页 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ManagerGroupMessageApi.getManagerGroupMessagePage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 详情弹窗 */
|
||||
const detailVisible = ref(false) // 详情弹窗的显示
|
||||
const detail = ref<ManagerGroupMessageApi.ImManagerGroupMessageVO>(
|
||||
{} as ManagerGroupMessageApi.ImManagerGroupMessageVO
|
||||
) // 当前详情数据
|
||||
|
||||
/** 打开详情弹窗 */
|
||||
const openDetail = (row: ManagerGroupMessageApi.ImManagerGroupMessageVO) => {
|
||||
detail.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: #f5f5f5;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="发送人编号" prop="senderId">
|
||||
<el-input
|
||||
v-model="queryParams.senderId"
|
||||
placeholder="请输入发送人用户编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="接收人编号" prop="receiverId">
|
||||
<el-input
|
||||
v-model="queryParams.receiverId"
|
||||
placeholder="请输入接收人用户编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="消息类型" prop="type">
|
||||
<el-select
|
||||
v-model="queryParams.type"
|
||||
placeholder="请选择消息类型"
|
||||
clearable
|
||||
class="!w-160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IM_MESSAGE_TYPE)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="消息状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择消息状态"
|
||||
clearable
|
||||
class="!w-160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IM_MESSAGE_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="发送时间" prop="sendTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.sendTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list">
|
||||
<el-table-column label="编号" align="center" prop="id" width="100" />
|
||||
<el-table-column label="发送人" align="center" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.senderNickname || '-' }}</span>
|
||||
<span class="text-gray-400 ml-5px">({{ row.senderId }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接收人" align="center" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.receiverNickname || '-' }}</span>
|
||||
<span class="text-gray-400 ml-5px">({{ row.receiverId }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" align="center" prop="type" width="100">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="row.type" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容预览" align="left" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ getContentPreview(row.content) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center" prop="status" width="100">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="发送时间"
|
||||
align="center"
|
||||
prop="sendTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openDetail(row)"
|
||||
v-hasPermi="['im:manager:message:query']"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="私聊消息详情" width="700">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="编号">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户端编号">{{ detail.clientMessageId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发送人">{{ detail.senderNickname }} ({{ detail.senderId }})</el-descriptions-item>
|
||||
<el-descriptions-item label="接收人">{{ detail.receiverNickname }} ({{ detail.receiverId }})</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发送时间">{{ formatDate(detail.sendTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(detail.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="消息内容(原始 JSON)" :span="2">
|
||||
<pre class="content-pre">{{ formatJson(detail.content) }}</pre>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { dateFormatter, formatDate } from '@/utils/formatTime'
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import * as ManagerPrivateMessageApi from '@/api/im/manager/message/private'
|
||||
import { getContentPreview, formatJson } from '../utils'
|
||||
|
||||
defineOptions({ name: 'ImPrivateMessage' })
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const total = ref(0) // 列表的总页数
|
||||
const list = ref<ManagerPrivateMessageApi.ImManagerPrivateMessageVO[]>([]) // 列表的数据
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
senderId: undefined as number | undefined,
|
||||
receiverId: undefined as number | undefined,
|
||||
type: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
sendTime: [] as string[]
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询私聊消息分页 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ManagerPrivateMessageApi.getManagerPrivateMessagePage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 详情弹窗 */
|
||||
const detailVisible = ref(false) // 详情弹窗的显示
|
||||
const detail = ref<ManagerPrivateMessageApi.ImManagerPrivateMessageVO>(
|
||||
{} as ManagerPrivateMessageApi.ImManagerPrivateMessageVO
|
||||
) // 当前详情数据
|
||||
|
||||
/** 打开详情弹窗 */
|
||||
const openDetail = (row: ManagerPrivateMessageApi.ImManagerPrivateMessageVO) => {
|
||||
detail.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: #f5f5f5;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// TODO @AI:应该放到 utils 里
|
||||
// IM 消息管理共享工具:消息内容预览 / JSON 美化
|
||||
//
|
||||
// 消息类型 / 消息状态都走字典:
|
||||
// DICT_TYPE.IM_MESSAGE_TYPE - 对应后端 ImMessageTypeEnum
|
||||
// DICT_TYPE.IM_MESSAGE_STATUS - 对应后端 ImMessageStatusEnum
|
||||
|
||||
/** 消息内容(JSON)取首层 content 字段做列表预览,解析失败时回退原文 */
|
||||
export const getContentPreview = (content?: string): string => {
|
||||
if (!content) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (typeof parsed === 'object' && parsed.content) return String(parsed.content)
|
||||
return content
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情弹窗里把 content JSON 美化成 2 缩进 */
|
||||
export const formatJson = (content?: string): string => {
|
||||
if (!content) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, 2)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<template>
|
||||
<Dialog v-model="dialogVisible" :title="dialogTitle" width="500">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
v-loading="formLoading"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="敏感词" prop="word">
|
||||
<el-input v-model="formData.word" placeholder="请输入敏感词" maxlength="64" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import * as ManagerSensitiveWordApi from '@/api/im/manager/sensitiveWord'
|
||||
|
||||
defineOptions({ name: 'ImSensitiveWordForm' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的显示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create / update
|
||||
const formData = ref({
|
||||
id: undefined as number | undefined,
|
||||
word: '',
|
||||
status: CommonStatusEnum.ENABLE
|
||||
})
|
||||
const formRules = reactive({
|
||||
word: [{ required: true, message: '敏感词不能为空', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (type: string, id?: number) => {
|
||||
dialogVisible.value = true
|
||||
dialogTitle.value = type === 'create' ? '新增敏感词' : '修改敏感词'
|
||||
formType.value = type
|
||||
resetForm()
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await ManagerSensitiveWordApi.getManagerSensitiveWord(id)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
await formRef.value.validate()
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as ManagerSensitiveWordApi.ImManagerSensitiveWordVO
|
||||
if (formType.value === 'create') {
|
||||
await ManagerSensitiveWordApi.createManagerSensitiveWord(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else {
|
||||
await ManagerSensitiveWordApi.updateManagerSensitiveWord(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
word: '',
|
||||
status: CommonStatusEnum.ENABLE
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="敏感词" prop="word">
|
||||
<el-input
|
||||
v-model="queryParams.word"
|
||||
placeholder="请输入敏感词"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
class="!w-160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['im:manager:sensitive-word:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" class="mr-5px" />新增
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="checkedIds.length === 0"
|
||||
@click="handleDeleteBatch"
|
||||
v-hasPermi="['im:manager:sensitive-word:delete']"
|
||||
>
|
||||
<Icon icon="ep:delete" class="mr-5px" />批量删除
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" @selection-change="handleRowCheckboxChange">
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column label="编号" align="center" prop="id" width="100" />
|
||||
<el-table-column label="敏感词" align="center" prop="word" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="状态" align="center" prop="status" width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建人" align="center" prop="creator" width="120" />
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" width="160" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['im:manager:sensitive-word:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['im:manager:sensitive-word:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<SensitiveWordForm ref="formRef" @success="getList" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import * as ManagerSensitiveWordApi from '@/api/im/manager/sensitiveWord'
|
||||
import SensitiveWordForm from './SensitiveWordForm.vue'
|
||||
|
||||
defineOptions({ name: 'ImSensitiveWord' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const total = ref(0) // 列表的总页数
|
||||
const list = ref<ManagerSensitiveWordApi.ImManagerSensitiveWordVO[]>([]) // 列表的数据
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
word: undefined as string | undefined,
|
||||
status: undefined as number | undefined,
|
||||
createTime: [] as string[]
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询敏感词分页 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ManagerSensitiveWordApi.getManagerSensitiveWordPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加 / 修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 发起删除
|
||||
await ManagerSensitiveWordApi.deleteManagerSensitiveWord(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
}
|
||||
|
||||
const checkedIds = ref<number[]>([]) // 当前勾选的编号集合
|
||||
|
||||
/** 表格选中变化 */
|
||||
const handleRowCheckboxChange = (rows: ManagerSensitiveWordApi.ImManagerSensitiveWordVO[]) => {
|
||||
checkedIds.value = rows.map((row) => row.id)
|
||||
}
|
||||
|
||||
/** 批量删除按钮操作 */
|
||||
const handleDeleteBatch = async () => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 发起批量删除
|
||||
await ManagerSensitiveWordApi.deleteManagerSensitiveWordList(checkedIds.value)
|
||||
checkedIds.value = []
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header>群规模分布</template>
|
||||
<div ref="chartRef" style="width: 100%; height: 320px"></div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
defineOptions({ name: 'ImStatisticsGroupSizeChart' })
|
||||
|
||||
const props = defineProps<{ data: { range: string; count: number }[] }>()
|
||||
|
||||
const chartRef = ref<HTMLElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const render = () => {
|
||||
if (!chart) return
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', top: 30, containLabel: true },
|
||||
xAxis: { type: 'category', data: props.data.map((d) => d.range) },
|
||||
yAxis: { type: 'value', name: '群组数' },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: props.data.map((d) => d.count),
|
||||
itemStyle: { color: '#67C23A', borderRadius: [4, 4, 0, 0] },
|
||||
barMaxWidth: 48
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (chartRef.value) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
render()
|
||||
}
|
||||
})
|
||||
watch(() => props.data, render, { deep: true })
|
||||
onUnmounted(() => chart?.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card { border-radius: 8px; margin-bottom: 16px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header>
|
||||
<div class="chart-header">
|
||||
<span>消息趋势(私聊 + 群聊)</span>
|
||||
<el-select v-model="days" @change="loadData" style="width: 100px" size="small">
|
||||
<el-option label="近 7 天" :value="7" />
|
||||
<el-option label="近 15 天" :value="15" />
|
||||
<el-option label="近 30 天" :value="30" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="chartRef" style="width: 100%; height: 320px"></div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as echarts from 'echarts'
|
||||
import * as StatisticsApi from '@/api/im/manager/statistics'
|
||||
|
||||
defineOptions({ name: 'ImStatisticsMessageTrendChart' })
|
||||
|
||||
const chartRef = ref<HTMLElement>()
|
||||
const days = ref(7)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const buildOption = (dates: string[], priv: number[], grp: number[]): echarts.EChartsCoreOption => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['私聊消息', '群聊消息'], top: 0 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', top: 40, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { formatter: (v: string) => v.substring(5) }
|
||||
},
|
||||
yAxis: { type: 'value', name: '消息量' },
|
||||
series: [
|
||||
{
|
||||
name: '私聊消息',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: priv,
|
||||
itemStyle: { color: '#409EFF' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(64,158,255,0.35)' },
|
||||
{ offset: 1, color: 'rgba(64,158,255,0.05)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '群聊消息',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: grp,
|
||||
itemStyle: { color: '#67C23A' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(103,194,58,0.35)' },
|
||||
{ offset: 1, color: 'rgba(103,194,58,0.05)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
const data = await StatisticsApi.getMessageTrend(days.value)
|
||||
chart?.setOption(buildOption(data.dates, data.series.private || [], data.series.group || []))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (chartRef.value) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
await loadData()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => chart?.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card { border-radius: 8px; margin-bottom: 16px; }
|
||||
.chart-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header>消息类型分布</template>
|
||||
<div ref="chartRef" style="width: 100%; height: 320px"></div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
defineOptions({ name: 'ImStatisticsMessageTypeChart' })
|
||||
|
||||
const props = defineProps<{ data: { name: string; value: number }[] }>()
|
||||
|
||||
const chartRef = ref<HTMLElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const render = () => {
|
||||
if (!chart) return
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { orient: 'vertical', right: 8, top: 'middle' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: props.data
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (chartRef.value) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
render()
|
||||
}
|
||||
})
|
||||
watch(() => props.data, render, { deep: true })
|
||||
onUnmounted(() => chart?.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card { border-radius: 8px; margin-bottom: 16px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col v-for="card in cards" :key="card.title" :xl="6" :lg="6" :md="12" :sm="24" :xs="24">
|
||||
<el-card shadow="never" class="kpi-card">
|
||||
<div class="kpi-row">
|
||||
<div class="kpi-icon" :style="{ backgroundColor: card.color }">
|
||||
<Icon :icon="card.icon" :size="24" color="#fff" />
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-title">{{ card.title }}</div>
|
||||
<div class="kpi-value">
|
||||
<CountTo :start-val="0" :end-val="card.value" :duration="1500" />
|
||||
<span v-if="card.suffix" class="kpi-suffix">{{ card.suffix }}</span>
|
||||
</div>
|
||||
<div class="kpi-meta">{{ card.metaLabel }}:
|
||||
<span :class="card.metaClass">{{ card.metaValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ImStatisticsOverviewVO } from '@/api/im/manager/statistics'
|
||||
|
||||
defineOptions({ name: 'ImStatisticsOverviewCards' })
|
||||
|
||||
const props = defineProps<{ overview: ImStatisticsOverviewVO }>()
|
||||
|
||||
const calcRatio = (today: number, yesterday: number): { label: string; cls: string } => {
|
||||
if (!yesterday) return { label: '无昨日数据', cls: 'text-gray-400' }
|
||||
const diff = ((today - yesterday) / yesterday) * 100
|
||||
const sign = diff >= 0 ? '+' : ''
|
||||
return {
|
||||
label: `${sign}${diff.toFixed(1)}%`,
|
||||
cls: diff >= 0 ? 'text-green-500' : 'text-red-500'
|
||||
}
|
||||
}
|
||||
|
||||
const cards = computed(() => {
|
||||
const o = props.overview
|
||||
const totalMsgToday = (o.privateMessageToday ?? 0) + (o.groupMessageToday ?? 0)
|
||||
const totalMsgYesterday = (o.privateMessageYesterday ?? 0) + (o.groupMessageYesterday ?? 0)
|
||||
const msgRatio = calcRatio(totalMsgToday, totalMsgYesterday)
|
||||
return [
|
||||
{
|
||||
title: '总用户',
|
||||
value: o.totalUser ?? 0,
|
||||
icon: 'ep:user',
|
||||
color: '#409EFF',
|
||||
metaLabel: '今日新增',
|
||||
metaValue: `+${o.newUserToday ?? 0}`,
|
||||
metaClass: 'text-green-500'
|
||||
},
|
||||
{
|
||||
title: '总群组',
|
||||
value: o.totalGroup ?? 0,
|
||||
icon: 'ep:chat-dot-round',
|
||||
color: '#67C23A',
|
||||
metaLabel: '今日新增',
|
||||
metaValue: `+${o.newGroupToday ?? 0}`,
|
||||
metaClass: 'text-green-500'
|
||||
},
|
||||
{
|
||||
title: '日活用户',
|
||||
value: o.activeUserDaily ?? 0,
|
||||
icon: 'ep:timer',
|
||||
color: '#E6A23C',
|
||||
metaLabel: '周/月活',
|
||||
metaValue: `${o.activeUserWeekly ?? 0} / ${o.activeUserMonthly ?? 0}`,
|
||||
metaClass: 'text-gray-500'
|
||||
},
|
||||
{
|
||||
title: '今日消息',
|
||||
value: totalMsgToday,
|
||||
suffix: ` (P ${o.privateMessageToday}/G ${o.groupMessageToday})`,
|
||||
icon: 'ep:message',
|
||||
color: '#909399',
|
||||
metaLabel: '环比昨日',
|
||||
metaValue: msgRatio.label,
|
||||
metaClass: msgRatio.cls
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card {
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.kpi-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.kpi-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kpi-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.kpi-title {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
line-height: 1;
|
||||
}
|
||||
.kpi-suffix {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-left: 6px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.kpi-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header>消息发送 TOP 10</template>
|
||||
<div ref="chartRef" style="width: 100%; height: 320px"></div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
defineOptions({ name: 'ImStatisticsTopSendersChart' })
|
||||
|
||||
const props = defineProps<{
|
||||
data: { userId: number; nickname: string; messageCount: number }[]
|
||||
}>()
|
||||
|
||||
const chartRef = ref<HTMLElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const render = () => {
|
||||
if (!chart) return
|
||||
// 横向条形图:从下到上排名
|
||||
const sorted = [...props.data].sort((a, b) => a.messageCount - b.messageCount)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', top: 20, containLabel: true },
|
||||
xAxis: { type: 'value', name: '消息数' },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: sorted.map((d) => `${d.nickname}(${d.userId})`),
|
||||
axisLabel: { width: 110, overflow: 'truncate' }
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: sorted.map((d) => d.messageCount),
|
||||
itemStyle: { color: '#409EFF', borderRadius: [0, 4, 4, 0] },
|
||||
barMaxWidth: 18
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (chartRef.value) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
render()
|
||||
}
|
||||
})
|
||||
watch(() => props.data, render, { deep: true })
|
||||
onUnmounted(() => chart?.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card { border-radius: 8px; margin-bottom: 16px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header>
|
||||
<div class="chart-header">
|
||||
<span>用户趋势(新增注册 + 日活)</span>
|
||||
<el-select v-model="days" @change="loadData" style="width: 100px" size="small">
|
||||
<el-option label="近 7 天" :value="7" />
|
||||
<el-option label="近 15 天" :value="15" />
|
||||
<el-option label="近 30 天" :value="30" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="chartRef" style="width: 100%; height: 320px"></div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as echarts from 'echarts'
|
||||
import * as StatisticsApi from '@/api/im/manager/statistics'
|
||||
|
||||
defineOptions({ name: 'ImStatisticsUserTrendChart' })
|
||||
|
||||
const chartRef = ref<HTMLElement>()
|
||||
const days = ref(7)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const buildOption = (dates: string[], reg: number[], act: number[]): echarts.EChartsCoreOption => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新增注册', '日活'], top: 0 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', top: 40, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { formatter: (v: string) => v.substring(5) }
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '新增注册', position: 'left' },
|
||||
{ type: 'value', name: '日活', position: 'right' }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '新增注册', type: 'bar', yAxisIndex: 0, data: reg,
|
||||
itemStyle: { color: '#E6A23C' }, barMaxWidth: 24
|
||||
},
|
||||
{
|
||||
name: '日活', type: 'line', yAxisIndex: 1, smooth: true, data: act,
|
||||
itemStyle: { color: '#F56C6C' }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
const data = await StatisticsApi.getUserTrend(days.value)
|
||||
chart?.setOption(buildOption(data.dates, data.series.register || [], data.series.active || []))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (chartRef.value) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
await loadData()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => chart?.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card { border-radius: 8px; margin-bottom: 16px; }
|
||||
.chart-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- KPI 卡片 -->
|
||||
<OverviewCards v-if="overview" :overview="overview" />
|
||||
|
||||
<!-- 趋势 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :xl="12" :lg="12" :md="24" :sm="24" :xs="24">
|
||||
<MessageTrendChart />
|
||||
</el-col>
|
||||
<el-col :xl="12" :lg="12" :md="24" :sm="24" :xs="24">
|
||||
<UserTrendChart />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 分布 -->
|
||||
<el-row v-if="distribution" :gutter="16">
|
||||
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24">
|
||||
<MessageTypeChart :data="distribution.messageTypeDistribution" />
|
||||
</el-col>
|
||||
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24">
|
||||
<GroupSizeChart :data="distribution.groupSizeDistribution" />
|
||||
</el-col>
|
||||
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24">
|
||||
<TopSendersChart :data="distribution.topSenders" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as StatisticsApi from '@/api/im/manager/statistics'
|
||||
import OverviewCards from './components/OverviewCards.vue'
|
||||
import MessageTrendChart from './components/MessageTrendChart.vue'
|
||||
import UserTrendChart from './components/UserTrendChart.vue'
|
||||
import MessageTypeChart from './components/MessageTypeChart.vue'
|
||||
import GroupSizeChart from './components/GroupSizeChart.vue'
|
||||
import TopSendersChart from './components/TopSendersChart.vue'
|
||||
|
||||
defineOptions({ name: 'ImStatistics' })
|
||||
|
||||
const overview = ref<StatisticsApi.ImStatisticsOverviewVO>()
|
||||
const distribution = ref<StatisticsApi.ImStatisticsDistributionVO>()
|
||||
|
||||
onMounted(async () => {
|
||||
// 父页只拉 KPI + 分布;趋势组件自己内部拉,避免父组件维护 days 状态
|
||||
const [o, d] = await Promise.all([
|
||||
StatisticsApi.getStatisticsOverview(),
|
||||
StatisticsApi.getStatisticsDistribution()
|
||||
])
|
||||
overview.value = o
|
||||
distribution.value = d
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue