feat: add IM governance admin enhancements

This commit is contained in:
richjohnrambo
2026-08-21 16:06:29 +08:00
parent 543c7b624e
commit 21a5b97ac8
34 changed files with 2621 additions and 135 deletions
+158
View File
@@ -0,0 +1,158 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM Outbox运维" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="handleReset"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.tenantId + '-' + row.source + '-' + 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 { useDialog, useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { Dead, List, Retry } from '@/api/imOutbox';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
const message = useMessage();
const dialog = useDialog();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const actionColumn = reactive({
width: 210,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '详情',
onClick: () => viewRef.value.openModal(record),
auth: ['/imOutbox/view'],
},
{
label: '重试',
type: 'warning',
ifShow: () => record.status !== 'delivered',
onClick: () => handleRetry(record),
auth: ['/imOutbox/retry'],
},
{
label: '死信',
type: 'error',
ifShow: () => !['delivered', 'dead'].includes(record.status),
onClick: () => handleDead(record),
auth: ['/imOutbox/dead'],
},
],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const [register, { setFieldsValue }] = 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 };
if (isPlatformAdmin.value && !params.tenantId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
if (!params.source) params.source = 'business';
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = { source: 'business' };
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
await setFieldsValue(defaults);
reloadTable();
}
function handleRetry(record: State) {
dialog.warning({
title: '重试Outbox事件',
content: '将事件状态改回待投递,并清空下次重试时间,等待 IM 服务端消费,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Retry({ tenantId: record.tenantId, source: record.source, id: record.id });
message.success('操作成功');
reloadTable();
},
});
}
function handleDead(record: State) {
dialog.error({
title: '标记死信',
content: '标记死信后 IM 服务端不会自动重试该事件,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Dead({ tenantId: record.tenantId, source: record.source, id: record.id });
message.success('操作成功');
reloadTable();
},
});
}
onMounted(async () => {
loadOptions();
await setFieldsValue({ source: 'business' });
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用租户');
}
}
reloadTable();
});
</script>
<style lang="less" scoped></style>
+148
View File
@@ -0,0 +1,148 @@
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 tenantId = 0;
public tenantName = '';
public source = 'business';
public id = 0;
public eventId = '';
public targetType = '';
public target = '';
public actorUserId = 0;
public eventType = '';
public payload = '';
public status = '';
public attempts = 0;
public nextRetryAt = '';
public deliveredAt = '';
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: false,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
},
},
{
field: 'source',
component: 'NSelect',
label: '来源',
defaultValue: 'business',
componentProps: {
clearable: false,
options: computed(() => dict.getOptions('ImOutboxSourceOptions')),
placeholder: '请选择来源',
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImOutboxStatusOptions')),
placeholder: '请选择状态',
},
},
{
field: 'eventType',
component: 'NInput',
label: '事件类型',
componentProps: { placeholder: '请输入事件类型' },
},
{
field: 'eventId',
component: 'NInput',
label: '事件ID',
componentProps: { placeholder: '请输入事件ID' },
},
{
field: 'target',
component: 'NInput',
label: '目标',
componentProps: { placeholder: '请输入目标/Topic' },
},
{
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: 'source',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImOutboxSourceOptions', row.source);
},
},
{
title: '状态',
key: 'status',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImOutboxStatusOptions', row.status);
},
},
{ title: '事件类型', key: 'eventType', align: 'left', width: 220 },
{ title: '事件ID', key: 'eventId', align: 'left', width: 240 },
{ title: '目标类型', key: 'targetType', align: 'left', width: 120 },
{ title: '目标', key: 'target', align: 'left', width: 180 },
{ title: '操作用户', key: 'actorUserId', align: 'left', width: 120 },
{ title: '尝试次数', key: 'attempts', align: 'left', width: 100 },
{ title: '下次重试', key: 'nextRetryAt', align: 'left', width: 180 },
{ title: '投递时间', key: 'deliveredAt', align: 'left', width: 180 },
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImOutboxSourceOptions', 'ImOutboxStatusOptions']);
}
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,
}));
return tenantOptions.value;
}
+70
View File
@@ -0,0 +1,70 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'Outbox详情 #' + formValue.id"
: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="来源">{{ sourceLabel }}</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="状态">{{ statusLabel }}</n-descriptions-item>
<n-descriptions-item label="尝试次数">{{ formValue.attempts }}</n-descriptions-item>
<n-descriptions-item label="目标类型">{{ formValue.targetType }}</n-descriptions-item>
<n-descriptions-item label="目标">{{ formValue.target }}</n-descriptions-item>
<n-descriptions-item label="操作用户">{{ formValue.actorUserId }}</n-descriptions-item>
<n-descriptions-item label="下次重试">
{{ formValue.nextRetryAt || '-' }}
</n-descriptions-item>
<n-descriptions-item label="投递时间">
{{ formValue.deliveredAt || '-' }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formValue.createdAt }}</n-descriptions-item>
<n-descriptions-item label="Payload" :span="2">
<n-code :code="formValue.payload || '{}'" language="json" word-wrap />
</n-descriptions-item>
</n-descriptions>
</n-spin>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
import { newState, State } from './model';
import { View } from '@/api/imOutbox';
const dict = useDictStore();
const settingStore = useProjectSettingStore();
const showModal = ref(false);
const loading = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(920));
const sourceLabel = computed(() =>
dict.getLabel('ImOutboxSourceOptions', formValue.value.source)
);
const statusLabel = computed(() =>
dict.getLabel('ImOutboxStatusOptions', formValue.value.status)
);
function openModal(state: State) {
showModal.value = true;
formValue.value = newState(state);
loading.value = true;
View({ tenantId: state.tenantId, source: state.source, id: state.id })
.then((res) => {
formValue.value = newState(res);
})
.finally(() => {
loading.value = false;
});
}
defineExpose({ openModal });
</script>