feat: add im kafka admin integration

This commit is contained in:
richjohnrambo
2026-08-28 00:05:54 +08:00
parent d53b0c1b62
commit 4779214591
75 changed files with 9053 additions and 185 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,
});
}
+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
@@ -53,3 +53,35 @@ export function Devices(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>
+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>