feat(sdk): 添加即时通讯和推送功能
- 新增 ApiClient 类用于处理 API 请求和响应 - 实现 ImClient 类支持 WebSocket 连接和消息收发 - 添加 ImSDK 类提供完整的即时通讯功能接口 - 定义 ImTypes.swift 包含聊天类型、消息类型等相关数据结构 - 实现 PushSDK 类支持推送通知令牌注册 - 添加基础的 UpdateSDK 框架结构 - 集成登录认证和聊天室订阅功能 - 实现群组管理、好友关系和会话功能 - 支持多种消息类型包括文本、图片、视频、音频等 - 提供历史消息查询和黑名单管理功能
这个提交包含在:
父节点
491a1ce8d3
当前提交
a6920641ad
@ -19,7 +19,7 @@ export interface ImUser {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
status: 'ACTIVE' | 'BANNED'
|
status: 'ACTIVE' | 'BANNED'
|
||||||
gender: 'UNKNOWN' | 'MALE' | 'FEMALE'
|
gender: 'UNKNOWN' | 'MALE' | 'FEMALE'
|
||||||
createdAt: string
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImGroup {
|
export interface ImGroup {
|
||||||
@ -27,9 +27,33 @@ export interface ImGroup {
|
|||||||
appId: string
|
appId: string
|
||||||
name: string
|
name: string
|
||||||
creatorId: string
|
creatorId: string
|
||||||
memberIds: string[]
|
groupType?: string | null
|
||||||
adminIds: string[]
|
memberIds: string
|
||||||
createdAt: string
|
adminIds: string
|
||||||
|
announcement?: string | null
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImMessage {
|
||||||
|
id: string
|
||||||
|
appId: string
|
||||||
|
fromUserId: string
|
||||||
|
toId: string
|
||||||
|
chatType: 'SINGLE' | 'GROUP'
|
||||||
|
msgType: string
|
||||||
|
content: string
|
||||||
|
status: string
|
||||||
|
mentionedUserIds?: string | null
|
||||||
|
groupReadCount?: number | null
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
content: T[]
|
||||||
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
|
size: number
|
||||||
|
number: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImStats {
|
export interface ImStats {
|
||||||
@ -41,13 +65,13 @@ export interface ImStats {
|
|||||||
|
|
||||||
export const imAdminApi = {
|
export const imAdminApi = {
|
||||||
listUsers(appId: string, page = 0, size = 20) {
|
listUsers(appId: string, page = 0, size = 20) {
|
||||||
return imClient.get<{ data: { content: ImUser[]; totalElements: number; totalPages: number } }>(
|
return imClient.get<{ data: PagedResult<ImUser> }>(
|
||||||
'/api/im/admin/users', { params: { appId, page, size } },
|
'/api/im/admin/users', { params: { appId, page, size } },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUserStatus(appId: string, userId: string, status: 'ACTIVE' | 'BANNED') {
|
updateUserStatus(appId: string, userId: string, status: 'ACTIVE' | 'BANNED') {
|
||||||
return imClient.put(`/api/im/admin/users/${userId}/status`, { status }, { params: { appId } })
|
return imClient.put(`/api/im/admin/users/${encodeURIComponent(userId)}/status`, { status }, { params: { appId } })
|
||||||
},
|
},
|
||||||
|
|
||||||
listGroups(appId: string) {
|
listGroups(appId: string) {
|
||||||
@ -58,6 +82,46 @@ export const imAdminApi = {
|
|||||||
return imClient.get<{ data: ImStats }>('/api/im/admin/stats', { params: { appId } })
|
return imClient.get<{ data: ImStats }>('/api/im/admin/stats', { params: { appId } })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getMessages(
|
||||||
|
appId: string,
|
||||||
|
userA: string,
|
||||||
|
userB: string,
|
||||||
|
page = 0,
|
||||||
|
size = 20,
|
||||||
|
filters?: {
|
||||||
|
msgType?: string
|
||||||
|
keyword?: string
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return imClient.get<{ data: PagedResult<ImMessage> }>('/api/im/admin/messages', {
|
||||||
|
params: {
|
||||||
|
appId,
|
||||||
|
userA,
|
||||||
|
userB,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
...(filters?.msgType ? { msgType: filters.msgType } : {}),
|
||||||
|
...(filters?.keyword ? { keyword: filters.keyword } : {}),
|
||||||
|
...(filters?.startTime ? { startTime: filters.startTime } : {}),
|
||||||
|
...(filters?.endTime ? { endTime: filters.endTime } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
revokeMessage(appId: string, messageId: string) {
|
||||||
|
return imClient.post<{ data: ImMessage }>(
|
||||||
|
`/api/im/admin/messages/${encodeURIComponent(messageId)}/revoke`,
|
||||||
|
{},
|
||||||
|
{ params: { appId } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissGroup(appId: string, groupId: string) {
|
||||||
|
return imClient.delete<{ data: null }>(`/api/im/admin/groups/${encodeURIComponent(groupId)}`, { params: { appId } })
|
||||||
|
},
|
||||||
|
|
||||||
registerUser(appId: string, userId: string, nickname?: string, avatar?: string) {
|
registerUser(appId: string, userId: string, nickname?: string, avatar?: string) {
|
||||||
return imClient.post<{ data: ImUser }>(
|
return imClient.post<{ data: ImUser }>(
|
||||||
'/api/im/admin/users',
|
'/api/im/admin/users',
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
<div>
|
<div>
|
||||||
<el-page-header @back="$router.back()" :content="`即时通讯管理 — ${appId}`" style="margin-bottom:20px" />
|
<el-page-header @back="$router.back()" :content="`即时通讯管理 — ${appId}`" style="margin-bottom:20px" />
|
||||||
|
|
||||||
<!-- Stats -->
|
|
||||||
<el-row :gutter="16" style="margin-bottom:20px">
|
<el-row :gutter="16" style="margin-bottom:20px">
|
||||||
<el-col :span="6" v-for="item in statCards" :key="item.label">
|
<el-col :span="6" v-for="item in statCards" :key="item.label">
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
@ -14,20 +13,20 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-card>
|
<el-card shadow="never">
|
||||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||||
<!-- Users Tab -->
|
|
||||||
<el-tab-pane label="注册用户" name="users">
|
<el-tab-pane label="注册用户" name="users">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="showRegisterUser = true">注册用户</el-button>
|
<el-button type="primary" @click="showRegisterUser = true">注册用户</el-button>
|
||||||
<el-button @click="loadUsers" :loading="loadingUsers">刷新</el-button>
|
<el-button @click="loadUsers" :loading="loadingUsers">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="users" v-loading="loadingUsers" border stripe>
|
<el-table :data="users" v-loading="loadingUsers" border stripe>
|
||||||
<el-table-column prop="userId" label="用户ID" width="160" />
|
<el-table-column prop="userId" label="用户ID" width="180" />
|
||||||
<el-table-column prop="nickname" label="昵称" width="120" />
|
<el-table-column prop="nickname" label="昵称" width="140" />
|
||||||
<el-table-column prop="gender" label="性别" width="80">
|
<el-table-column prop="gender" label="性别" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ { UNKNOWN: '未知', MALE: '男', FEMALE: '女' }[row.gender] }}
|
{{ genderLabel[row.gender] ?? '未知' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
@ -38,20 +37,24 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="注册时间" width="180">
|
<el-table-column prop="createdAt" label="注册时间" width="180">
|
||||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.createdAt) }}
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120" fixed="right">
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
:type="row.status === 'ACTIVE' ? 'danger' : 'success'"
|
:type="row.status === 'ACTIVE' ? 'danger' : 'success'"
|
||||||
size="small"
|
size="small"
|
||||||
@click="toggleUserStatus(row)">
|
@click="toggleUserStatus(row)"
|
||||||
|
>
|
||||||
{{ row.status === 'ACTIVE' ? '封禁' : '解封' }}
|
{{ row.status === 'ACTIVE' ? '封禁' : '解封' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-if="userTotal > userPageSize"
|
v-if="userTotal > userPageSize"
|
||||||
style="margin-top:16px"
|
style="margin-top:16px"
|
||||||
@ -59,36 +62,125 @@
|
|||||||
:total="userTotal"
|
:total="userTotal"
|
||||||
:page-size="userPageSize"
|
:page-size="userPageSize"
|
||||||
:current-page="userPage + 1"
|
:current-page="userPage + 1"
|
||||||
@current-change="p => { userPage = p - 1; loadUsers() }"
|
@current-change="handleUserPageChange"
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- Groups Tab -->
|
|
||||||
<el-tab-pane label="群组列表" name="groups">
|
<el-tab-pane label="群组列表" name="groups">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="showCreateGroup = true">创建群组</el-button>
|
|
||||||
<el-button @click="loadGroups" :loading="loadingGroups">刷新</el-button>
|
<el-button @click="loadGroups" :loading="loadingGroups">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="groups" v-loading="loadingGroups" border stripe>
|
<el-table :data="groups" v-loading="loadingGroups" border stripe>
|
||||||
<el-table-column prop="id" label="群组ID" width="240" />
|
<el-table-column prop="id" label="群组ID" width="240" />
|
||||||
<el-table-column prop="name" label="群名称" />
|
<el-table-column prop="name" label="群名称" min-width="160" />
|
||||||
<el-table-column prop="creatorId" label="创建者" width="160" />
|
<el-table-column prop="groupType" label="类型" width="110">
|
||||||
<el-table-column label="成员数" width="100">
|
<template #default="{ row }">
|
||||||
<template #default="{row}">{{ (row.memberIds ?? []).length }}</template>
|
{{ row.groupType || 'WORK' }}
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="成员数" width="100">
|
||||||
|
<template #default="{ row }">{{ parseIdList(row.memberIds).length }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="管理员" width="100">
|
||||||
|
<template #default="{ row }">{{ parseIdList(row.adminIds).length }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="creatorId" label="群主" width="160" />
|
||||||
|
<el-table-column prop="announcement" label="公告" min-width="200" show-overflow-tooltip />
|
||||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" size="small" @click="dismissGroup(row)">
|
||||||
|
解散
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="消息历史" name="messages">
|
||||||
|
<el-form :inline="true" :model="historyForm" class="toolbar" label-width="0">
|
||||||
|
<el-form-item>
|
||||||
|
<el-input v-model="historyForm.userA" placeholder="用户A" clearable style="width:160px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-input v-model="historyForm.userB" placeholder="用户B" clearable style="width:160px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-input v-model="historyForm.keyword" placeholder="关键词" clearable style="width:180px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-select v-model="historyForm.msgType" placeholder="消息类型" clearable style="width:140px">
|
||||||
|
<el-option v-for="item in msgTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="historyForm.timeRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
style="width:340px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loadingMessages" @click="searchMessages">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button @click="resetMessageSearch">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="messages" v-loading="loadingMessages" border stripe>
|
||||||
|
<el-table-column prop="createdAt" label="时间" width="180">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="fromUserId" label="发送者" width="160" />
|
||||||
|
<el-table-column prop="toId" label="会话ID" width="180" />
|
||||||
|
<el-table-column prop="chatType" label="类型" width="90" />
|
||||||
|
<el-table-column prop="msgType" label="消息类型" width="110" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100" />
|
||||||
|
<el-table-column prop="content" label="内容" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="content-preview">{{ formatContent(row.content) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="110" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" size="small" @click="revokeMessage(row)">
|
||||||
|
撤回
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-if="historyTotal > historyPageSize"
|
||||||
|
style="margin-top:16px"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:total="historyTotal"
|
||||||
|
:page-size="historyPageSize"
|
||||||
|
:current-page="historyPage + 1"
|
||||||
|
@current-change="handleHistoryPageChange"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- Register User Dialog -->
|
<el-dialog v-model="showRegisterUser" title="注册用户" width="420px">
|
||||||
<el-dialog v-model="showRegisterUser" title="注册用户" width="400px">
|
<el-form :model="registerForm" label-width="72px">
|
||||||
<el-form :model="registerForm" label-width="80px">
|
<el-form-item label="用户ID">
|
||||||
<el-form-item label="用户ID"><el-input v-model="registerForm.userId" placeholder="全局唯一标识" /></el-form-item>
|
<el-input v-model="registerForm.userId" placeholder="全局唯一标识" />
|
||||||
<el-form-item label="昵称"><el-input v-model="registerForm.nickname" /></el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="昵称">
|
||||||
|
<el-input v-model="registerForm.nickname" />
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="showRegisterUser = false">取消</el-button>
|
<el-button @click="showRegisterUser = false">取消</el-button>
|
||||||
@ -96,11 +188,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Create Group Dialog -->
|
<el-dialog v-model="showCreateGroup" title="创建群组" width="520px" @closed="resetCreateGroupForm">
|
||||||
<el-dialog v-model="showCreateGroup" title="创建群组" width="480px" @closed="resetCreateGroupForm">
|
|
||||||
<el-form :model="createGroupForm" label-width="90px">
|
<el-form :model="createGroupForm" label-width="90px">
|
||||||
<el-form-item label="群名称"><el-input v-model="createGroupForm.name" /></el-form-item>
|
<el-form-item label="群名称">
|
||||||
<el-form-item label="创建者ID"><el-input v-model="createGroupForm.creatorId" /></el-form-item>
|
<el-input v-model="createGroupForm.name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="创建者ID">
|
||||||
|
<el-input v-model="createGroupForm.creatorId" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="搜索成员">
|
<el-form-item label="搜索成员">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="memberSearchKeyword"
|
v-model="memberSearchKeyword"
|
||||||
@ -109,7 +204,6 @@
|
|||||||
clearable
|
clearable
|
||||||
@input="onMemberSearchInput"
|
@input="onMemberSearchInput"
|
||||||
/>
|
/>
|
||||||
<!-- Search results list -->
|
|
||||||
<div v-if="memberSearchResults.length > 0" class="search-results">
|
<div v-if="memberSearchResults.length > 0" class="search-results">
|
||||||
<div
|
<div
|
||||||
v-for="user in memberSearchResults"
|
v-for="user in memberSearchResults"
|
||||||
@ -121,8 +215,12 @@
|
|||||||
<span class="result-userid">{{ user.userId }}</span>
|
<span class="result-userid">{{ user.userId }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-else-if="memberSearchKeyword && !searchingMembers && memberSearchResults.length === 0"
|
<el-empty
|
||||||
description="未找到匹配用户" :image-size="48" style="padding:8px 0" />
|
v-else-if="memberSearchKeyword && !searchingMembers && memberSearchResults.length === 0"
|
||||||
|
description="未找到匹配用户"
|
||||||
|
:image-size="48"
|
||||||
|
style="padding:8px 0"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="已选成员">
|
<el-form-item label="已选成员">
|
||||||
<div class="selected-members">
|
<div class="selected-members">
|
||||||
@ -149,15 +247,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { imAdminApi, type ImUser, type ImGroup, type ImStats } from '@/api/im'
|
import { imAdminApi, type ImGroup, type ImMessage, type ImStats, type ImUser } from '@/api/im'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const appId = route.params.appId as string
|
const appId = route.params.appId as string
|
||||||
|
|
||||||
|
const genderLabel: Record<string, string> = {
|
||||||
|
UNKNOWN: '未知',
|
||||||
|
MALE: '男',
|
||||||
|
FEMALE: '女',
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgTypeOptions = [
|
||||||
|
'TEXT', 'IMAGE', 'VIDEO', 'AUDIO', 'FILE', 'CUSTOM', 'LOCATION', 'NOTIFY',
|
||||||
|
'RICH_TEXT', 'CALL_AUDIO', 'CALL_VIDEO', 'FORWARD', 'QUOTE', 'MERGE', 'REVOKED',
|
||||||
|
].map(value => ({ label: value, value }))
|
||||||
|
|
||||||
const activeTab = ref('users')
|
const activeTab = ref('users')
|
||||||
const stats = ref<ImStats | null>(null)
|
const stats = ref<ImStats | null>(null)
|
||||||
|
|
||||||
@ -170,6 +279,20 @@ const userTotal = ref(0)
|
|||||||
const groups = ref<ImGroup[]>([])
|
const groups = ref<ImGroup[]>([])
|
||||||
const loadingGroups = ref(false)
|
const loadingGroups = ref(false)
|
||||||
|
|
||||||
|
const messages = ref<ImMessage[]>([])
|
||||||
|
const loadingMessages = ref(false)
|
||||||
|
const historyPage = ref(0)
|
||||||
|
const historyPageSize = 20
|
||||||
|
const historyTotal = ref(0)
|
||||||
|
|
||||||
|
const historyForm = ref({
|
||||||
|
userA: '',
|
||||||
|
userB: '',
|
||||||
|
keyword: '',
|
||||||
|
msgType: '',
|
||||||
|
timeRange: [] as string[] | null,
|
||||||
|
})
|
||||||
|
|
||||||
const showRegisterUser = ref(false)
|
const showRegisterUser = ref(false)
|
||||||
const submittingRegister = ref(false)
|
const submittingRegister = ref(false)
|
||||||
const registerForm = ref({ userId: '', nickname: '' })
|
const registerForm = ref({ userId: '', nickname: '' })
|
||||||
@ -178,27 +301,58 @@ const showCreateGroup = ref(false)
|
|||||||
const submittingCreateGroup = ref(false)
|
const submittingCreateGroup = ref(false)
|
||||||
const createGroupForm = ref({ name: '', creatorId: '' })
|
const createGroupForm = ref({ name: '', creatorId: '' })
|
||||||
|
|
||||||
// Member fuzzy search
|
|
||||||
const memberSearchKeyword = ref('')
|
const memberSearchKeyword = ref('')
|
||||||
const memberSearchResults = ref<ImUser[]>([])
|
const memberSearchResults = ref<ImUser[]>([])
|
||||||
const searchingMembers = ref(false)
|
const searchingMembers = ref(false)
|
||||||
const selectedMembers = ref<ImUser[]>([])
|
const selectedMembers = ref<ImUser[]>([])
|
||||||
let memberSearchTimer: ReturnType<typeof setTimeout> | null = null
|
let memberSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const statCards = computed(() => [
|
||||||
|
{ label: '注册用户', value: stats.value?.totalUsers ?? '-' },
|
||||||
|
{ label: '群组数', value: stats.value?.totalGroups ?? '-' },
|
||||||
|
{ label: '消息总量', value: stats.value?.totalMessages ?? '-' },
|
||||||
|
{ label: '今日消息', value: stats.value?.todayMessages ?? '-' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function formatTime(value: number | string | null | undefined) {
|
||||||
|
if (value === null || value === undefined || value === '') return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContent(content: string) {
|
||||||
|
if (!content) return '-'
|
||||||
|
return content.length > 120 ? `${content.slice(0, 120)}...` : content
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIdList(value: string) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDateTime(value: string) {
|
||||||
|
if (!value) return ''
|
||||||
|
if (value.includes('T')) return value
|
||||||
|
return value.replace(' ', 'T')
|
||||||
|
}
|
||||||
|
|
||||||
function onMemberSearchInput() {
|
function onMemberSearchInput() {
|
||||||
if (memberSearchTimer) clearTimeout(memberSearchTimer)
|
if (memberSearchTimer) clearTimeout(memberSearchTimer)
|
||||||
const kw = memberSearchKeyword.value.trim()
|
const keyword = memberSearchKeyword.value.trim()
|
||||||
if (!kw) {
|
if (!keyword) {
|
||||||
memberSearchResults.value = []
|
memberSearchResults.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
memberSearchTimer = setTimeout(async () => {
|
memberSearchTimer = setTimeout(async () => {
|
||||||
searchingMembers.value = true
|
searchingMembers.value = true
|
||||||
try {
|
try {
|
||||||
const res = await imAdminApi.searchUsers(appId, kw)
|
const res = await imAdminApi.searchUsers(appId, keyword)
|
||||||
// Exclude already-selected members from results
|
const selectedIds = new Set(selectedMembers.value.map(item => item.userId))
|
||||||
const selectedIds = new Set(selectedMembers.value.map(m => m.userId))
|
memberSearchResults.value = res.data.data.filter(user => !selectedIds.has(user.userId))
|
||||||
memberSearchResults.value = res.data.data.filter(u => !selectedIds.has(u.userId))
|
|
||||||
} catch {
|
} catch {
|
||||||
memberSearchResults.value = []
|
memberSearchResults.value = []
|
||||||
} finally {
|
} finally {
|
||||||
@ -208,15 +362,15 @@ function onMemberSearchInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addMember(user: ImUser) {
|
function addMember(user: ImUser) {
|
||||||
if (!selectedMembers.value.find(m => m.userId === user.userId)) {
|
if (!selectedMembers.value.find(item => item.userId === user.userId)) {
|
||||||
selectedMembers.value.push(user)
|
selectedMembers.value.push(user)
|
||||||
}
|
}
|
||||||
memberSearchResults.value = memberSearchResults.value.filter(u => u.userId !== user.userId)
|
memberSearchResults.value = memberSearchResults.value.filter(item => item.userId !== user.userId)
|
||||||
memberSearchKeyword.value = ''
|
memberSearchKeyword.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMember(userId: string) {
|
function removeMember(userId: string) {
|
||||||
selectedMembers.value = selectedMembers.value.filter(m => m.userId !== userId)
|
selectedMembers.value = selectedMembers.value.filter(item => item.userId !== userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCreateGroupForm() {
|
function resetCreateGroupForm() {
|
||||||
@ -226,22 +380,26 @@ function resetCreateGroupForm() {
|
|||||||
selectedMembers.value = []
|
selectedMembers.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const statCards = computed(() => [
|
function resetMessageSearch() {
|
||||||
{ label: '注册用户', value: stats.value?.totalUsers ?? '-' },
|
historyForm.value = {
|
||||||
{ label: '群组数', value: stats.value?.totalGroups ?? '-' },
|
userA: '',
|
||||||
{ label: '消息总量', value: stats.value?.totalMessages ?? '-' },
|
userB: '',
|
||||||
{ label: '今日消息', value: stats.value?.todayMessages ?? '-' },
|
keyword: '',
|
||||||
])
|
msgType: '',
|
||||||
|
timeRange: [],
|
||||||
function formatTime(t: string) {
|
}
|
||||||
return t ? new Date(t).toLocaleString('zh-CN') : '-'
|
messages.value = []
|
||||||
|
historyTotal.value = 0
|
||||||
|
historyPage.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const res = await imAdminApi.getStats(appId)
|
const res = await imAdminApi.getStats(appId)
|
||||||
stats.value = res.data.data
|
stats.value = res.data.data
|
||||||
} catch {}
|
} catch {
|
||||||
|
stats.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
@ -250,7 +408,7 @@ async function loadUsers() {
|
|||||||
const res = await imAdminApi.listUsers(appId, userPage.value, userPageSize)
|
const res = await imAdminApi.listUsers(appId, userPage.value, userPageSize)
|
||||||
users.value = res.data.data.content
|
users.value = res.data.data.content
|
||||||
userTotal.value = res.data.data.totalElements
|
userTotal.value = res.data.data.totalElements
|
||||||
} catch (e) {
|
} catch {
|
||||||
ElMessage.error('加载用户失败')
|
ElMessage.error('加载用户失败')
|
||||||
} finally {
|
} finally {
|
||||||
loadingUsers.value = false
|
loadingUsers.value = false
|
||||||
@ -262,35 +420,86 @@ async function loadGroups() {
|
|||||||
try {
|
try {
|
||||||
const res = await imAdminApi.listGroups(appId)
|
const res = await imAdminApi.listGroups(appId)
|
||||||
groups.value = res.data.data
|
groups.value = res.data.data
|
||||||
} catch (e) {
|
} catch {
|
||||||
ElMessage.error('加载群组失败')
|
ElMessage.error('加载群组失败')
|
||||||
} finally {
|
} finally {
|
||||||
loadingGroups.value = false
|
loadingGroups.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMessages(page = historyPage.value) {
|
||||||
|
const userA = historyForm.value.userA.trim()
|
||||||
|
const userB = historyForm.value.userB.trim()
|
||||||
|
if (!userA || !userB) {
|
||||||
|
ElMessage.warning('请填写用户A和用户B')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingMessages.value = true
|
||||||
|
try {
|
||||||
|
const [startTime, endTime] = historyForm.value.timeRange ?? []
|
||||||
|
const res = await imAdminApi.getMessages(appId, userA, userB, page, historyPageSize, {
|
||||||
|
keyword: historyForm.value.keyword.trim() || undefined,
|
||||||
|
msgType: historyForm.value.msgType || undefined,
|
||||||
|
startTime: startTime ? toLocalDateTime(startTime) : undefined,
|
||||||
|
endTime: endTime ? toLocalDateTime(endTime) : undefined,
|
||||||
|
})
|
||||||
|
messages.value = res.data.data.content
|
||||||
|
historyTotal.value = res.data.data.totalElements
|
||||||
|
historyPage.value = page
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载消息历史失败')
|
||||||
|
} finally {
|
||||||
|
loadingMessages.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMessages() {
|
||||||
|
await loadMessages(0)
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleUserStatus(user: ImUser) {
|
async function toggleUserStatus(user: ImUser) {
|
||||||
const newStatus = user.status === 'ACTIVE' ? 'BANNED' : 'ACTIVE'
|
const nextStatus = user.status === 'ACTIVE' ? 'BANNED' : 'ACTIVE'
|
||||||
const action = newStatus === 'BANNED' ? '封禁' : '解封'
|
const action = nextStatus === 'BANNED' ? '封禁' : '解封'
|
||||||
await ElMessageBox.confirm(`确认${action}用户 ${user.userId}?`, `${action}用户`, {
|
await ElMessageBox.confirm(`确认${action}用户 ${user.userId}?`, `${action}用户`, {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: '确认',
|
confirmButtonText: '确认',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
await imAdminApi.updateUserStatus(appId, user.userId, newStatus)
|
await imAdminApi.updateUserStatus(appId, user.userId, nextStatus)
|
||||||
ElMessage.success(`已${action}`)
|
ElMessage.success(`已${action}`)
|
||||||
loadUsers()
|
loadUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTabChange(tab: string) {
|
async function revokeMessage(message: ImMessage) {
|
||||||
if (tab === 'groups' && groups.value.length === 0) loadGroups()
|
await ElMessageBox.confirm(`确认撤回消息 ${message.id}?`, '撤回消息', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
await imAdminApi.revokeMessage(appId, message.id)
|
||||||
|
ElMessage.success('消息已撤回')
|
||||||
|
loadMessages(historyPage.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissGroup(group: ImGroup) {
|
||||||
|
await ElMessageBox.confirm(`确认解散群组 ${group.name}?`, '解散群组', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
await imAdminApi.dismissGroup(appId, group.id)
|
||||||
|
ElMessage.success('群组已解散')
|
||||||
|
loadGroups()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitRegisterUser() {
|
async function submitRegisterUser() {
|
||||||
if (!registerForm.value.userId.trim()) return ElMessage.warning('请填写用户ID')
|
if (!registerForm.value.userId.trim()) {
|
||||||
|
ElMessage.warning('请填写用户ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
submittingRegister.value = true
|
submittingRegister.value = true
|
||||||
try {
|
try {
|
||||||
await imAdminApi.registerUser(appId, registerForm.value.userId, registerForm.value.nickname)
|
await imAdminApi.registerUser(appId, registerForm.value.userId.trim(), registerForm.value.nickname.trim())
|
||||||
ElMessage.success('用户注册成功')
|
ElMessage.success('用户注册成功')
|
||||||
showRegisterUser.value = false
|
showRegisterUser.value = false
|
||||||
registerForm.value = { userId: '', nickname: '' }
|
registerForm.value = { userId: '', nickname: '' }
|
||||||
@ -305,12 +514,13 @@ async function submitRegisterUser() {
|
|||||||
|
|
||||||
async function submitCreateGroup() {
|
async function submitCreateGroup() {
|
||||||
if (!createGroupForm.value.name.trim() || !createGroupForm.value.creatorId.trim()) {
|
if (!createGroupForm.value.name.trim() || !createGroupForm.value.creatorId.trim()) {
|
||||||
return ElMessage.warning('请填写群名称和创建者ID')
|
ElMessage.warning('请填写群名称和创建者ID')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
submittingCreateGroup.value = true
|
submittingCreateGroup.value = true
|
||||||
try {
|
try {
|
||||||
const memberIds = selectedMembers.value.map(m => m.userId)
|
const memberIds = selectedMembers.value.map(item => item.userId)
|
||||||
await imAdminApi.createGroup(appId, createGroupForm.value.name, createGroupForm.value.creatorId, memberIds)
|
await imAdminApi.createGroup(appId, createGroupForm.value.name.trim(), createGroupForm.value.creatorId.trim(), memberIds)
|
||||||
ElMessage.success('群组创建成功')
|
ElMessage.success('群组创建成功')
|
||||||
showCreateGroup.value = false
|
showCreateGroup.value = false
|
||||||
loadGroups()
|
loadGroups()
|
||||||
@ -322,6 +532,21 @@ async function submitCreateGroup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTabChange(tab: string) {
|
||||||
|
if (tab === 'groups' && groups.value.length === 0) {
|
||||||
|
loadGroups()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserPageChange(page: number) {
|
||||||
|
userPage.value = page - 1
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistoryPageChange(page: number) {
|
||||||
|
loadMessages(page - 1)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStats()
|
loadStats()
|
||||||
loadUsers()
|
loadUsers()
|
||||||
@ -329,10 +554,28 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stat-card { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 0; }
|
.stat-card {
|
||||||
.stat-value { font-size: 28px; font-weight: 800; color: #1f2933; }
|
display: flex;
|
||||||
.stat-label { font-size: 13px; color: #6b7280; }
|
flex-direction: column;
|
||||||
.toolbar { margin-bottom: 12px; }
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-results {
|
.search-results {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -343,6 +586,7 @@ onMounted(() => {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-result-item {
|
.search-result-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -351,9 +595,20 @@ onMounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.search-result-item:hover { background: #f0f4ff; }
|
|
||||||
.result-nickname { font-weight: 500; color: #303133; }
|
.search-result-item:hover {
|
||||||
.result-userid { font-size: 12px; color: #909399; }
|
background: #f0f4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-nickname {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-userid {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
.selected-members {
|
.selected-members {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -362,5 +617,15 @@ onMounted(() => {
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.no-members-hint { font-size: 13px; color: #c0c4cc; }
|
|
||||||
|
.no-members-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-preview {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户