feat(im): 增加 im 的管理界面

im
YunaiV 2026-04-30 19:04:31 +08:00
parent 4b4c4fab11
commit 01fff53aaf
17 changed files with 1864 additions and 129 deletions

View File

@ -748,11 +748,10 @@ const remainingRouter: AppRouteRecordRaw[] = [
]
},
{
// 统一 /im 分组:下分 home聊天壳+ managerLayout 管理壳)
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: '数据看板'
}
}
]
}
]
}

View File

@ -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 群状态
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
}
}

View File

@ -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) // 12
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>