feat: add im kafka admin integration

This commit is contained in:
richjohnrambo
2026-09-04 11:57:38 +08:00
parent a2abaef3a6
commit f7f8cc0ef0
74 changed files with 6254 additions and 47 deletions
+46
View File
@@ -0,0 +1,46 @@
import { http } from '@/utils/http/axios';
// 获取IM Kafka死信列表
export function List(params) {
return http.request({
url: '/imDeadLetter/list',
method: 'get',
params,
});
}
// 获取IM Kafka死信详情
export function View(params) {
return http.request({
url: '/imDeadLetter/view',
method: 'GET',
params,
});
}
// 重放IM Kafka死信
export function Replay(params) {
return http.request({
url: '/imDeadLetter/replay',
method: 'POST',
params,
});
}
// 忽略IM Kafka死信
export function Ignore(params) {
return http.request({
url: '/imDeadLetter/ignore',
method: 'POST',
params,
});
}
// 标记IM Kafka死信已处理
export function Resolve(params) {
return http.request({
url: '/imDeadLetter/resolve',
method: 'POST',
params,
});
}
+72
View File
@@ -80,3 +80,75 @@ export function AdminLogList(params) {
params,
});
}
// Kafka修改群资料
export function ProfileUpdate(params) {
return http.request({
url: '/imGroup/profileUpdate',
method: 'POST',
params,
});
}
// Kafka修改群治理配置
export function GovernanceUpdate(params) {
return http.request({
url: '/imGroup/governanceUpdate',
method: 'POST',
params,
});
}
// Kafka设置群管理员
export function AdminSet(params) {
return http.request({
url: '/imGroup/adminSet',
method: 'POST',
params,
});
}
// Kafka删除群管理员
export function AdminRemove(params) {
return http.request({
url: '/imGroup/adminRemove',
method: 'POST',
params,
});
}
// Kafka禁言群成员
export function MemberMute(params) {
return http.request({
url: '/imGroup/memberMute',
method: 'POST',
params,
});
}
// Kafka解除群成员禁言
export function MemberUnmute(params) {
return http.request({
url: '/imGroup/memberUnmute',
method: 'POST',
params,
});
}
// Kafka移出群成员
export function MemberRemove(params) {
return http.request({
url: '/imGroup/memberRemove',
method: 'POST',
params,
});
}
// Kafka转让群主
export function OwnerTransfer(params) {
return http.request({
url: '/imGroup/ownerTransfer',
method: 'POST',
params,
});
}
+17
View File
@@ -0,0 +1,17 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({
url: '/imKafkaAudit/list',
method: 'get',
params,
});
}
export function View(params) {
return http.request({
url: '/imKafkaAudit/view',
method: 'GET',
params,
});
}
+37
View File
@@ -0,0 +1,37 @@
import { http } from '@/utils/http/axios';
// 获取IM后台命令列表
export function List(params) {
return http.request({
url: '/imKafkaCommand/list',
method: 'get',
params,
});
}
// 获取IM后台命令详情
export function View(params) {
return http.request({
url: '/imKafkaCommand/view',
method: 'GET',
params,
});
}
// 发送IM缓存失效测试事件
export function CacheInvalidate(params) {
return http.request({
url: '/imKafkaCommand/cacheInvalidate',
method: 'POST',
params,
});
}
// 发送IM工作台测试事件
export function WorkbenchEvent(params) {
return http.request({
url: '/imKafkaCommand/workbenchEvent',
method: 'POST',
params,
});
}
+32
View File
@@ -17,3 +17,35 @@ export function View(params) {
params,
});
}
export function Ban(params) {
return http.request({ url: '/imUser/ban', method: 'POST', params });
}
export function Unban(params) {
return http.request({ url: '/imUser/unban', method: 'POST', params });
}
export function Freeze(params) {
return http.request({ url: '/imUser/freeze', method: 'POST', params });
}
export function Unfreeze(params) {
return http.request({ url: '/imUser/unfreeze', method: 'POST', params });
}
export function DeviceKick(params) {
return http.request({ url: '/imUser/deviceKick', method: 'POST', params });
}
export function LogoutAll(params) {
return http.request({ url: '/imUser/logoutAll', method: 'POST', params });
}
export function AccountCancel(params) {
return http.request({ url: '/imUser/accountCancel', method: 'POST', params });
}
export function AccountDelete(params) {
return http.request({ url: '/imUser/accountDelete', method: 'POST', params });
}
+88
View File
@@ -0,0 +1,88 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM Kafka死信"></n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.deadId"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<ViewModal ref="viewRef" @reload="reloadTable" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { List } from '@/api/imDeadLetter';
import { columns, loadOptions, loadTenantOptions, schemas, State } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const cursor = ref('');
const hasMore = ref(false);
const actionColumn = reactive({
width: 110,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record, currentTenantId.value), auth: ['/imDeadLetter/view'] }],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const currentTenantId = computed(() => searchFormRef.value?.formModel?.tenantId || 0);
const [register] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
if (!currentTenantId.value) {
return { list: [], totalCount: 0 };
}
const params = { ...searchFormRef.value?.formModel, limit: res.pageSize || res.perPage || 50, cursor: cursor.value };
const data = await List(params);
hasMore.value = !!data?.hasMore;
cursor.value = data?.nextCursor || '';
return { list: data?.list || [], totalCount: data?.list?.length || 0 };
};
function reloadTable() {
cursor.value = '';
hasMore.value = false;
actionRef.value?.reload();
}
onMounted(async () => {
loadOptions();
await loadTenantOptions();
});
</script>
<style lang="less" scoped></style>
+122
View File
@@ -0,0 +1,122 @@
import { computed, ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { FormSchema } from '@/components/Form';
import { useDictStore } from '@/store/modules/dict';
import { List as TenantList } from '@/api/imTenant';
const dict = useDictStore();
export const tenantOptions = ref<any[]>([]);
export class State {
public id = 0;
public tenantId = 0;
public deadId = '';
public eventId = '';
public parentEventId = '';
public sourceTopic = '';
public consumerGroup = '';
public eventType = '';
public bizType = '';
public bizId = '';
public retryCount = 0;
public lastReason = '';
public lastError = '';
public status = '';
public replayCount = 0;
public createdAt = '';
public updatedAt = '';
public envelope = '';
public envelopeRaw = '';
constructor(state?: Partial<State>) {
if (state) Object.assign(this, state);
}
}
export function newState(state: State | Record<string, any> | null): State {
if (state !== null) {
if (state instanceof State) return cloneDeep(state);
return new State(state);
}
return new State();
}
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
componentProps: {
clearable: true,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImKafkaDeadLetterStatusOptions')),
placeholder: '请选择状态',
},
},
{
field: 'eventType',
component: 'NInput',
label: '事件类型',
componentProps: { placeholder: '请输入事件类型' },
},
{
field: 'bizType',
component: 'NInput',
label: '业务类型',
componentProps: { placeholder: '请输入业务类型' },
},
{
field: 'bizId',
component: 'NInput',
label: '业务ID',
componentProps: { placeholder: '请输入业务ID' },
},
]);
export const columns = [
{ title: 'ID', key: 'id', align: 'left', width: 90 },
{ title: '死信ID', key: 'deadId', align: 'left', width: 220 },
{ title: '事件ID', key: 'eventId', align: 'left', width: 220 },
{ title: '来源Topic', key: 'sourceTopic', align: 'left', width: 150 },
{ title: '消费组', key: 'consumerGroup', align: 'left', width: 160 },
{ title: '事件类型', key: 'eventType', align: 'left', width: 190 },
{ title: '业务类型', key: 'bizType', align: 'left', width: 130 },
{ title: '业务ID', key: 'bizId', align: 'left', width: 180 },
{
title: '状态',
key: 'status',
align: 'left',
width: 120,
render(row: State) {
return dict.getLabel('ImKafkaDeadLetterStatusOptions', row.status);
},
},
{ title: '重试', key: 'retryCount', align: 'left', width: 80 },
{ title: '重放', key: 'replayCount', align: 'left', width: 80 },
{ title: '原因', key: 'lastReason', align: 'left', width: 180 },
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImKafkaDeadLetterStatusOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 200 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+123
View File
@@ -0,0 +1,123 @@
<template>
<div>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'死信详情 #' + formValue.id"
:style="{ width: dialogWidth }"
>
<n-scrollbar style="max-height: 87vh" class="pr-5">
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="死信ID">{{ formValue.deadId }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
<n-descriptions-item label="事件ID">{{ formValue.eventId }}</n-descriptions-item>
<n-descriptions-item label="父事件ID">{{ formValue.parentEventId || '-' }}</n-descriptions-item>
<n-descriptions-item label="来源Topic">{{ formValue.sourceTopic || '-' }}</n-descriptions-item>
<n-descriptions-item label="消费组">{{ formValue.consumerGroup || '-' }}</n-descriptions-item>
<n-descriptions-item label="事件类型">{{ formValue.eventType || '-' }}</n-descriptions-item>
<n-descriptions-item label="业务对象">{{ formValue.bizType }} / {{ formValue.bizId }}</n-descriptions-item>
<n-descriptions-item label="重试次数">{{ formValue.retryCount }}</n-descriptions-item>
<n-descriptions-item label="重放次数">{{ formValue.replayCount }}</n-descriptions-item>
<n-descriptions-item label="最后原因">{{ formValue.lastReason || '-' }}</n-descriptions-item>
<n-descriptions-item label="最后错误" :span="2">{{ formValue.lastError || '-' }}</n-descriptions-item>
<n-descriptions-item label="Envelope" :span="2">
<n-code :code="formValue.envelope || formValue.envelopeRaw || '-'" language="json" word-wrap />
</n-descriptions-item>
</n-descriptions>
<n-form class="mt-4" label-placement="left" :label-width="110">
<n-form-item label="操作IM用户ID">
<n-input-number v-model:value="actionForm.actorUserId" clearable class="w-full" />
</n-form-item>
<n-form-item label="处置备注">
<n-input v-model:value="actionForm.note" type="textarea" clearable placeholder="请输入备注" />
</n-form-item>
</n-form>
</n-spin>
</n-scrollbar>
<template #action>
<n-space>
<n-button @click="closeForm">关闭</n-button>
<n-button type="default" :loading="submitting" @click="submitAction('ignore')">忽略</n-button>
<n-button type="default" :loading="submitting" @click="submitAction('resolve')">标记已处理</n-button>
<n-button type="primary" :loading="submitting" @click="submitAction('replay')">重放</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import { Ignore, Replay, Resolve, View } from '@/api/imDeadLetter';
import { newState, State } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
import { useMessage } from 'naive-ui';
const emit = defineEmits(['reload']);
const settingStore = useProjectSettingStore();
const dict = useDictStore();
const message = useMessage();
const loading = ref(false);
const submitting = ref(false);
const showModal = ref(false);
const formValue = ref<State>(newState(null));
const tenantId = ref(0);
const actionForm = reactive({
actorUserId: null as number | null,
note: '',
});
const dialogWidth = computed(() => adaModalWidth(980));
const statusLabel = computed(() => dict.getLabel('ImKafkaDeadLetterStatusOptions', formValue.value.status) || '-');
function closeForm() {
showModal.value = false;
loading.value = false;
submitting.value = false;
}
function openModal(state: State, currentTenantId: number) {
tenantId.value = currentTenantId;
showModal.value = true;
loading.value = true;
actionForm.actorUserId = null;
actionForm.note = '';
View({ tenantId: currentTenantId, deadId: state.deadId })
.then((res) => {
formValue.value = newState(res);
})
.finally(() => {
loading.value = false;
});
}
async function submitAction(type: 'replay' | 'ignore' | 'resolve') {
const params = {
tenantId: tenantId.value,
deadId: formValue.value.deadId,
actorUserId: actionForm.actorUserId,
note: actionForm.note,
};
submitting.value = true;
try {
const api = type === 'replay' ? Replay : type === 'ignore' ? Ignore : Resolve;
const res = await api(params);
message.success(`命令已提交:${res?.commandId || '-'}`);
emit('reload');
closeForm();
} finally {
submitting.value = false;
}
}
defineExpose({
openModal,
});
</script>
<style lang="less"></style>
+137 -1
View File
@@ -12,6 +12,44 @@
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="settings" tab="治理配置">
<n-spin :show="loading" description="请稍候...">
<n-form
ref="commandFormRef"
:model="commandForm"
label-placement="left"
:label-width="110"
class="governance-command-form"
>
<n-grid :cols="settingStore.isMobile ? 1 : 2" :x-gap="16">
<n-form-item-gi label="操作IM用户ID" path="actorUserId">
<n-input-number v-model:value="commandForm.actorUserId" clearable />
</n-form-item-gi>
<n-form-item-gi label="版本" path="version">
<n-input-number v-model:value="commandForm.version" disabled />
</n-form-item-gi>
<n-form-item-gi label="群名称" path="name">
<n-input v-model:value="commandForm.name" clearable />
</n-form-item-gi>
<n-form-item-gi label="头像文件ID" path="avatarFileId">
<n-input v-model:value="commandForm.avatarFileId" clearable />
</n-form-item-gi>
<n-form-item-gi label="简介" path="description">
<n-input v-model:value="commandForm.description" type="textarea" clearable />
</n-form-item-gi>
<n-form-item-gi label="治理开关">
<n-space>
<n-checkbox v-model:checked="commandForm.joinApproval">入群审批</n-checkbox>
<n-checkbox v-model:checked="commandForm.muteAll">全员禁言</n-checkbox>
<n-checkbox v-model:checked="commandForm.memberUploadFiles">成员上传文件</n-checkbox>
<n-checkbox v-model:checked="commandForm.memberPrivateChat">成员私聊</n-checkbox>
<n-checkbox v-model:checked="commandForm.memberInvite">成员邀请</n-checkbox>
</n-space>
</n-form-item-gi>
</n-grid>
<n-space>
<n-button type="primary" :loading="submitting" @click="submitProfile">提交资料命令</n-button>
<n-button type="primary" :loading="submitting" @click="submitGovernance">提交治理命令</n-button>
</n-space>
</n-form>
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="群名称">{{ governance.name || '-' }}</n-descriptions-item>
<n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item>
@@ -95,15 +133,18 @@
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicTable } from '@/components/Table';
import {
AdminLogList,
AnnouncementList,
Governance,
GovernanceUpdate,
InvitationList,
JoinRequestList,
MuteList,
ProfileUpdate,
} from '@/api/imGroup';
import {
adminLogColumns,
@@ -120,11 +161,27 @@
const settingStore = useProjectSettingStore();
const dict = useDictStore();
const message = useMessage();
const dialog = useDialog();
const showModal = ref(false);
const loading = ref(false);
const submitting = ref(false);
const activeTab = ref('settings');
const current = ref<State>(newState(null));
const governance = ref<GovernanceState>(new GovernanceState());
const commandFormRef = ref();
const commandForm = ref({
actorUserId: 0,
version: 0,
name: '',
avatarFileId: '',
description: '',
joinApproval: false,
muteAll: false,
memberUploadFiles: false,
memberPrivateChat: false,
memberInvite: false,
});
const dialogWidth = computed(() => adaModalWidth(1120));
const groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', governance.value.groupStatus) || '-');
@@ -178,11 +235,86 @@
try {
const res = await Governance({ tenantId: current.value.tenantId, topic: current.value.topic });
governance.value = Object.assign(new GovernanceState(), res || {});
fillCommandForm();
} finally {
loading.value = false;
}
}
function fillCommandForm() {
commandForm.value = {
actorUserId: governance.value.ownerUserId || current.value.ownerId || 0,
version: governance.value.version || current.value.settingsVersion || 0,
name: governance.value.name || current.value.groupName || '',
avatarFileId: governance.value.avatarFileId || current.value.avatarFileId || '',
description: governance.value.description || current.value.description || '',
joinApproval: !!governance.value.joinApproval,
muteAll: !!governance.value.muteAll,
memberUploadFiles: !!governance.value.memberUploadFiles,
memberPrivateChat: !!governance.value.memberPrivateChat,
memberInvite: !!governance.value.memberInvite,
};
}
function commandBase() {
if (!commandForm.value.actorUserId) {
message.warning('请填写操作IM用户ID');
return null;
}
return {
tenantId: current.value.tenantId,
topic: current.value.topic,
actorUserId: String(commandForm.value.actorUserId),
version: commandForm.value.version,
};
}
async function submitProfile() {
const base = commandBase();
if (!base) return;
submitting.value = true;
try {
const res = await ProfileUpdate({
...base,
name: commandForm.value.name,
avatarFileId: commandForm.value.avatarFileId,
description: commandForm.value.description,
});
showCommandResult(res);
} finally {
submitting.value = false;
}
}
async function submitGovernance() {
const base = commandBase();
if (!base) return;
submitting.value = true;
try {
const res = await GovernanceUpdate({
...base,
joinApproval: commandForm.value.joinApproval,
muteAll: commandForm.value.muteAll,
memberUploadFiles: commandForm.value.memberUploadFiles,
memberPrivateChat: commandForm.value.memberPrivateChat,
memberInvite: commandForm.value.memberInvite,
});
showCommandResult(res);
} finally {
submitting.value = false;
}
}
function showCommandResult(res) {
dialog.success({
title: '命令已发送',
content: `CommandID:${res?.commandId || '-'},状态:${res?.status || '-'}`,
positiveText: '知道了',
});
loadGovernance();
reloadActiveTable();
}
function reloadActiveTable() {
const refs = {
joinRequests: joinRequestRef,
@@ -213,4 +345,8 @@
});
</script>
<style lang="less"></style>
<style lang="less" scoped>
.governance-command-form {
margin-bottom: 16px;
}
</style>
+111 -7
View File
@@ -9,11 +9,17 @@
:title="'群成员 ' + current.topic"
:style="{ width: dialogWidth }"
>
<n-form label-placement="left" :label-width="110" class="member-command-form">
<n-form-item label="操作IM用户ID">
<n-input-number v-model:value="actorUserId" clearable />
</n-form-item>
</n-form>
<BasicTable
ref="actionRef"
:columns="memberColumns"
:request="loadDataTable"
:row-key="(row) => row.userId"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
@@ -27,17 +33,63 @@
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { BasicTable } from '@/components/Table';
import { MemberList } from '@/api/imGroup';
import { memberColumns, newState, State } from './model';
import { computed, h, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { AdminRemove, AdminSet, MemberList, MemberMute, MemberRemove, MemberUnmute } from '@/api/imGroup';
import { MemberState, memberColumns, newState, State } from './model';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
const dialog = useDialog();
const message = useMessage();
const showModal = ref(false);
const current = ref<State>(newState(null));
const actorUserId = ref<number | null>(null);
const actionRef = ref();
const dialogWidth = computed(() => adaModalWidth(980));
const scrollX = computed(() => adaTableScrollX(memberColumns, 0));
const dialogWidth = computed(() => adaModalWidth(1120));
const actionColumn = reactive({
width: 300,
title: '操作',
key: 'action',
fixed: 'right',
render(record: MemberState) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '设管理员',
onClick: () => submitMemberCommand('setAdmin', record),
auth: ['/imGroup/adminSet'],
ifShow: record.role !== 'admin' && record.role !== 'owner',
},
{
label: '删管理员',
onClick: () => submitMemberCommand('removeAdmin', record),
auth: ['/imGroup/adminRemove'],
ifShow: record.role === 'admin',
},
{
label: '禁言',
onClick: () => submitMemberCommand('mute', record),
auth: ['/imGroup/memberMute'],
},
{
label: '解禁',
onClick: () => submitMemberCommand('unmute', record),
auth: ['/imGroup/memberUnmute'],
},
{
label: '移出',
onClick: () => submitMemberCommand('remove', record),
auth: ['/imGroup/memberRemove'],
},
],
});
},
});
const scrollX = computed(() => adaTableScrollX(memberColumns, actionColumn.width));
// 加载表格数据
const loadDataTable = async (res) => {
@@ -56,9 +108,57 @@
showModal.value = false;
}
function baseCommand(record: MemberState) {
if (!actorUserId.value) {
message.warning('请先填写操作IM用户ID');
return null;
}
return {
tenantId: current.value.tenantId,
topic: current.value.topic,
actorUserId: String(actorUserId.value),
userId: String(record.userId),
};
}
function submitMemberCommand(type: string, record: MemberState) {
const labels = {
setAdmin: '设置管理员',
removeAdmin: '删除管理员',
mute: '禁言成员',
unmute: '解除禁言',
remove: '移出群成员',
};
dialog.warning({
title: labels[type],
content: `确认对用户 ${record.userId} 执行该操作?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
const params = baseCommand(record);
if (!params) return false;
let res: any;
if (type === 'setAdmin') {
res = await AdminSet({ ...params, userIds: [String(record.userId)] });
} else if (type === 'removeAdmin') {
res = await AdminRemove(params);
} else if (type === 'mute') {
res = await MemberMute(params);
} else if (type === 'unmute') {
res = await MemberUnmute(params);
} else {
res = await MemberRemove(params);
}
message.success(`命令已发送:${res?.commandId || '-'}`);
actionRef.value?.reload();
},
});
}
// 打开弹窗
function openModal(state: State) {
current.value = newState(state);
actorUserId.value = current.value.ownerId || null;
showModal.value = true;
setTimeout(() => {
actionRef.value?.reload();
@@ -70,4 +170,8 @@
});
</script>
<style lang="less"></style>
<style lang="less" scoped>
.member-command-form {
margin-bottom: 12px;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM Kafka审计事件"></n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<ViewModal ref="viewRef" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, reactive, ref } from 'vue';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { List } from '@/api/imKafkaAudit';
import { columns, schemas, State } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const actionColumn = reactive({
width: 100,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imKafkaAudit/view'] }],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const [register] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
</script>
<style lang="less" scoped></style>
+106
View File
@@ -0,0 +1,106 @@
import { ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
export class State {
public id = 0;
public eventId = '';
public tenantCode = '';
public eventType = '';
public bizType = '';
public bizId = '';
public actorType = '';
public actorId = '';
public action = '';
public resource = '';
public resourceId = '';
public result = '';
public reason = '';
public payload = '';
public envelope = '';
public occurredAt = '';
public receivedAt = '';
constructor(state?: Partial<State>) {
if (state) Object.assign(this, state);
}
}
export function newState(state: State | Record<string, any> | null): State {
if (state !== null) {
if (state instanceof State) return cloneDeep(state);
return new State(state);
}
return new State();
}
export const schemas = ref<FormSchema[]>([
{
field: 'tenantCode',
component: 'NInput',
label: '租户编码',
componentProps: { placeholder: '请输入租户编码' },
},
{
field: 'eventId',
component: 'NInput',
label: '事件ID',
componentProps: { placeholder: '请输入事件ID' },
},
{
field: 'eventType',
component: 'NInput',
label: '事件类型',
componentProps: { placeholder: '请输入事件类型' },
},
{
field: 'action',
component: 'NInput',
label: '动作',
componentProps: { placeholder: '请输入动作' },
},
{
field: 'resource',
component: 'NInput',
label: '资源',
componentProps: { placeholder: '请输入资源' },
},
{
field: 'resourceId',
component: 'NInput',
label: '资源ID',
componentProps: { placeholder: '请输入资源ID' },
},
{
field: 'result',
component: 'NInput',
label: '结果',
componentProps: { placeholder: '请输入结果' },
},
{
field: 'createdAt',
component: 'NDatePicker',
label: '接收时间',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
},
},
]);
export const columns = [
{ title: 'ID', key: 'id', align: 'left', width: 90 },
{ title: '租户', key: 'tenantCode', align: 'left', width: 120 },
{ title: '事件ID', key: 'eventId', align: 'left', width: 220 },
{ title: '事件类型', key: 'eventType', align: 'left', width: 190 },
{ title: '动作', key: 'action', align: 'left', width: 170 },
{ title: '资源', key: 'resource', align: 'left', width: 120 },
{ title: '资源ID', key: 'resourceId', align: 'left', width: 170 },
{ title: '操作者', key: 'actorId', align: 'left', width: 140 },
{ title: '结果', key: 'result', align: 'left', width: 100 },
{ title: '原因', key: 'reason', align: 'left', width: 160 },
{ title: '发生时间', key: 'occurredAt', align: 'left', width: 180 },
{ title: '接收时间', key: 'receivedAt', align: 'left', width: 180 },
];
+79
View File
@@ -0,0 +1,79 @@
<template>
<div>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'Kafka审计事件 #' + formValue.id"
:style="{ width: dialogWidth }"
>
<n-scrollbar style="max-height: 87vh" class="pr-5">
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ formValue.tenantCode || '-' }}</n-descriptions-item>
<n-descriptions-item label="事件类型">{{ formValue.eventType || '-' }}</n-descriptions-item>
<n-descriptions-item label="事件ID">{{ formValue.eventId || '-' }}</n-descriptions-item>
<n-descriptions-item label="业务ID">{{ formValue.bizId || '-' }}</n-descriptions-item>
<n-descriptions-item label="操作者">{{ formValue.actorType || '-' }} / {{ formValue.actorId || '-' }}</n-descriptions-item>
<n-descriptions-item label="动作">{{ formValue.action || '-' }}</n-descriptions-item>
<n-descriptions-item label="资源">{{ formValue.resource || '-' }} / {{ formValue.resourceId || '-' }}</n-descriptions-item>
<n-descriptions-item label="结果">{{ formValue.result || '-' }}</n-descriptions-item>
<n-descriptions-item label="原因" :span="2">{{ formValue.reason || '-' }}</n-descriptions-item>
<n-descriptions-item label="发生时间">{{ formValue.occurredAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="接收时间">{{ formValue.receivedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="Payload" :span="2">
<n-code :code="formValue.payload || '-'" language="json" word-wrap />
</n-descriptions-item>
<n-descriptions-item label="Envelope" :span="2">
<n-code :code="formValue.envelope || '-'" language="json" word-wrap />
</n-descriptions-item>
</n-descriptions>
</n-spin>
</n-scrollbar>
<template #action>
<n-space>
<n-button @click="closeForm">关闭</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { View } from '@/api/imKafkaAudit';
import { newState, State } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
const settingStore = useProjectSettingStore();
const loading = ref(false);
const showModal = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(980));
function closeForm() {
showModal.value = false;
loading.value = false;
}
function openModal(state: State) {
showModal.value = true;
loading.value = true;
View({ id: state.id })
.then((res) => {
formValue.value = newState(res);
})
.finally(() => {
loading.value = false;
});
}
defineExpose({
openModal,
});
</script>
<style lang="less"></style>
+127
View File
@@ -0,0 +1,127 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM后台命令"></n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<n-space class="table-toolbar">
<n-button type="primary" @click="sendCacheInvalidate">发送缓存失效测试</n-button>
<n-button @click="sendWorkbenchEvent">发送工作台测试事件</n-button>
</n-space>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<ViewModal ref="viewRef" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { CacheInvalidate, List, WorkbenchEvent } from '@/api/imKafkaCommand';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const message = useMessage();
const actionColumn = reactive({
width: 100,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imKafkaCommand/view'] }],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const [register] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function sendCacheInvalidate() {
const tenant = getFirstTenant();
if (!tenant) {
message.warning('暂无可用租户');
return;
}
const res = await CacheInvalidate({
tenantCode: tenant.code,
namespace: 'kafka_connectivity',
keys: ['healthcheck'],
reason: 'admin_connectivity_test',
});
message.success(`已发送缓存失效测试事件:${res?.eventId || ''}`);
reloadTable();
}
async function sendWorkbenchEvent() {
const tenant = getFirstTenant();
if (!tenant) {
message.warning('暂无可用租户');
return;
}
const res = await WorkbenchEvent({
tenantCode: tenant.code,
eventType: 'workbench.connectivity.test',
bizId: 'admin_connectivity_test',
payload: { message: 'admin workbench event connectivity test' },
});
message.success(`已发送工作台测试事件:${res?.eventId || ''}`);
}
function getFirstTenant() {
const tenant = tenantOptions.value[0];
if (!tenant) return null;
return {
...tenant,
code: tenant.code || String(tenant.label || '').match(/((.+))/)?.[1] || 'default',
};
}
onMounted(async () => {
loadOptions();
await loadTenantOptions();
});
</script>
<style lang="less" scoped>
.table-toolbar {
margin-bottom: 16px;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
import { computed, ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { useDictStore } from '@/store/modules/dict';
import { List as TenantList } from '@/api/imTenant';
const dict = useDictStore();
export const tenantOptions = ref<any[]>([]);
export class State {
public id = 0;
public commandId = '';
public tenantId = 0;
public tenantCode = '';
public eventId = '';
public eventType = '';
public bizType = '';
public bizId = '';
public idempotencyKey = '';
public status = '';
public reason = '';
public message = '';
public payload = '';
public envelope = '';
public result = '';
public sentAt = '';
public completedAt = '';
public createdBy = 0;
public createdAt = '';
public updatedAt = '';
constructor(state?: Partial<State>) {
if (state) Object.assign(this, state);
}
}
export function newState(state: State | Record<string, any> | null): State {
if (state !== null) {
if (state instanceof State) return cloneDeep(state);
return new State(state);
}
return new State();
}
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
componentProps: {
clearable: true,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
},
},
{
field: 'commandId',
component: 'NInput',
label: '命令ID',
componentProps: { placeholder: '请输入命令ID' },
},
{
field: 'eventId',
component: 'NInput',
label: '事件ID',
componentProps: { placeholder: '请输入事件ID' },
},
{
field: 'eventType',
component: 'NInput',
label: '事件类型',
componentProps: { placeholder: '请输入事件类型' },
},
{
field: 'bizId',
component: 'NInput',
label: '业务ID',
componentProps: { placeholder: '请输入业务ID' },
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImAdminCommandStatusOptions')),
placeholder: '请选择状态',
},
},
{
field: 'createdAt',
component: 'NDatePicker',
label: '创建时间',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
},
},
]);
export const columns = [
{ title: 'ID', key: 'id', align: 'left', width: 90 },
{ title: '租户', key: 'tenantCode', align: 'left', width: 120 },
{ title: '命令ID', key: 'commandId', align: 'left', width: 210 },
{ title: '事件类型', key: 'eventType', align: 'left', width: 190 },
{ title: '业务类型', key: 'bizType', align: 'left', width: 110 },
{ title: '业务ID', key: 'bizId', align: 'left', width: 180 },
{
title: '状态',
key: 'status',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImAdminCommandStatusOptions', row.status);
},
},
{ title: '错误码', key: 'reason', align: 'left', width: 170 },
{ title: '消息', key: 'message', align: 'left', width: 220 },
{ title: '投递时间', key: 'sentAt', align: 'left', width: 180 },
{ title: '完成时间', key: 'completedAt', align: 'left', width: 180 },
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImAdminCommandStatusOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 20 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
code: item.code,
}));
return tenantOptions.value;
}
+86
View File
@@ -0,0 +1,86 @@
<template>
<div>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'命令详情 #' + formValue.id"
:style="{ width: dialogWidth }"
>
<n-scrollbar style="max-height: 87vh" class="pr-5">
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ formValue.tenantCode || '-' }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
<n-descriptions-item label="命令ID">{{ formValue.commandId }}</n-descriptions-item>
<n-descriptions-item label="事件ID">{{ formValue.eventId }}</n-descriptions-item>
<n-descriptions-item label="事件类型">{{ formValue.eventType }}</n-descriptions-item>
<n-descriptions-item label="业务对象">{{ formValue.bizType }} / {{ formValue.bizId }}</n-descriptions-item>
<n-descriptions-item label="幂等键">{{ formValue.idempotencyKey || '-' }}</n-descriptions-item>
<n-descriptions-item label="创建人">{{ formValue.createdBy || '-' }}</n-descriptions-item>
<n-descriptions-item label="投递时间">{{ formValue.sentAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="完成时间">{{ formValue.completedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="错误码">{{ formValue.reason || '-' }}</n-descriptions-item>
<n-descriptions-item label="消息">{{ formValue.message || '-' }}</n-descriptions-item>
<n-descriptions-item label="Payload" :span="2">
<n-code :code="formValue.payload || '-'" language="json" word-wrap />
</n-descriptions-item>
<n-descriptions-item label="Envelope" :span="2">
<n-code :code="formValue.envelope || '-'" language="json" word-wrap />
</n-descriptions-item>
<n-descriptions-item label="Result" :span="2">
<n-code :code="formValue.result || '-'" language="json" word-wrap />
</n-descriptions-item>
</n-descriptions>
</n-spin>
</n-scrollbar>
<template #action>
<n-space>
<n-button @click="closeForm">关闭</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import { View } from '@/api/imKafkaCommand';
import { newState, State } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
const settingStore = useProjectSettingStore();
const dict = useDictStore();
const loading = ref(false);
const showModal = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(980));
const statusLabel = computed(() => dict.getLabel('ImAdminCommandStatusOptions', formValue.value.status) || '-');
function closeForm() {
showModal.value = false;
loading.value = false;
}
function openModal(state: State) {
showModal.value = true;
loading.value = true;
View({ id: state.id })
.then((res) => {
formValue.value = newState(res);
})
.finally(() => {
loading.value = false;
});
}
defineExpose({
openModal,
});
</script>
<style lang="less"></style>
+142
View File
@@ -0,0 +1,142 @@
<template>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'用户治理 #' + formValue.userId"
:style="{ width: dialogWidth }"
>
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ formValue.tenantName || '-' }}</n-descriptions-item>
<n-descriptions-item label="用户ID">{{ formValue.userId }}</n-descriptions-item>
<n-descriptions-item label="昵称">{{ formValue.nickname || '-' }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ stateLabel }}</n-descriptions-item>
</n-descriptions>
<n-form class="mt-4" label-placement="left" :label-width="110">
<n-form-item label="治理动作">
<n-select v-model:value="actionForm.action" :options="actionOptions" />
</n-form-item>
<n-form-item label="操作IM用户ID">
<n-input-number v-model:value="actionForm.actorUserId" clearable class="w-full" />
</n-form-item>
<n-form-item v-if="actionForm.action === 'deviceKick'" label="设备ID">
<n-input v-model:value="actionForm.deviceId" clearable placeholder="请输入 device_id" />
</n-form-item>
<n-form-item v-if="actionForm.action === 'accountCancel'" label="硬删除">
<n-switch v-model:value="actionForm.hard" />
</n-form-item>
<n-form-item label="原因">
<n-input v-model:value="actionForm.reason" type="textarea" clearable placeholder="请输入操作原因" />
</n-form-item>
</n-form>
</n-spin>
<template #action>
<n-space>
<n-button @click="closeForm">关闭</n-button>
<n-button type="primary" :loading="loading" @click="submitAction">提交命令</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import {
AccountCancel,
AccountDelete,
Ban,
DeviceKick,
Freeze,
LogoutAll,
Unban,
Unfreeze,
} from '@/api/imUser';
import { newState, State } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
import { useMessage } from 'naive-ui';
const emit = defineEmits(['reload']);
const settingStore = useProjectSettingStore();
const dict = useDictStore();
const message = useMessage();
const loading = ref(false);
const showModal = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(720));
const stateLabel = computed(() => dict.getLabel('ImUserStateOptions', formValue.value.state) || '-');
const actionOptions = [
{ label: '封禁用户', value: 'ban' },
{ label: '解封用户', value: 'unban' },
{ label: '冻结用户', value: 'freeze' },
{ label: '解冻用户', value: 'unfreeze' },
{ label: '踢出指定设备', value: 'deviceKick' },
{ label: '踢出全部会话', value: 'logoutAll' },
{ label: '注销账号', value: 'accountCancel' },
{ label: '删除账号', value: 'accountDelete' },
];
const actionForm = reactive({
action: 'ban',
actorUserId: null as number | null,
deviceId: '',
reason: '',
hard: false,
});
function closeForm() {
showModal.value = false;
loading.value = false;
}
function openModal(state: State) {
formValue.value = newState(state);
actionForm.action = 'ban';
actionForm.actorUserId = state.userId || null;
actionForm.deviceId = '';
actionForm.reason = '';
actionForm.hard = false;
showModal.value = true;
}
async function submitAction() {
const params = {
tenantId: formValue.value.tenantId,
userId: formValue.value.userId,
actorUserId: actionForm.actorUserId,
deviceId: actionForm.deviceId,
reason: actionForm.reason,
hard: actionForm.hard,
};
const apiMap = {
ban: Ban,
unban: Unban,
freeze: Freeze,
unfreeze: Unfreeze,
deviceKick: DeviceKick,
logoutAll: LogoutAll,
accountCancel: AccountCancel,
accountDelete: AccountDelete,
};
loading.value = true;
try {
const res = await apiMap[actionForm.action](params);
message.success(`命令已提交:${res?.commandId || '-'}`);
emit('reload');
closeForm();
} finally {
loading.value = false;
}
}
defineExpose({
openModal,
});
</script>
<style lang="less"></style>
+13 -1
View File
@@ -22,6 +22,7 @@
/>
</n-card>
<ViewModal ref="viewRef" />
<GovernanceModal ref="governanceRef" @reload="reloadTable" />
</div>
</template>
@@ -35,15 +36,17 @@
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
import GovernanceModal from './governance.vue';
const message = useMessage();
const { hasPermission } = usePermission();
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const governanceRef = ref();
const actionColumn = reactive({
width: 120,
width: 170,
title: '操作',
key: 'action',
fixed: 'right',
@@ -56,6 +59,11 @@
onClick: handleView.bind(null, record),
auth: ['/imUser/view'],
},
{
label: '治理',
onClick: handleGovernance.bind(null, record),
auth: ['/imUser/ban'],
},
],
});
},
@@ -90,6 +98,10 @@
viewRef.value.openModal(record);
}
function handleGovernance(record: Recordable) {
governanceRef.value.openModal(record);
}
onMounted(async () => {
loadOptions();
await loadTenantOptions();