feat: expand IM admin controls

This commit is contained in:
richjohnrambo
2026-08-21 15:09:39 +08:00
parent c3676f9744
commit 543c7b624e
29 changed files with 1308 additions and 39 deletions
+6 -1
View File
@@ -1,4 +1,4 @@
import { http } from '@/utils/http/axios';
import { http, jumpExport } from '@/utils/http/axios';
// 获取IM审计日志列表
export function List(params) {
@@ -17,3 +17,8 @@ export function View(params) {
params,
});
}
// 导出IM审计日志
export function Export(params) {
jumpExport('/imAuditLog/export', params);
}
+33
View File
@@ -35,3 +35,36 @@ export function View(params) {
params,
});
}
// 获取IM租户用量和健康状态
export function Usage(params) {
return http.request({
url: '/imTenant/usage',
method: 'GET',
params,
});
}
export function DeletionCheck(params) {
return http.request({
url: '/imTenant/deletionCheck',
method: 'GET',
params,
});
}
export function EnterDeleting(params) {
return http.request({
url: '/imTenant/enterDeleting',
method: 'POST',
params,
});
}
export function Restore(params) {
return http.request({
url: '/imTenant/restore',
method: 'POST',
params,
});
}
+27
View File
@@ -26,3 +26,30 @@ export function ResetPassword(params) {
params,
});
}
// 撤销IM用户会话
export function RevokeSessions(params) {
return http.request({
url: '/imUser/revokeSessions',
method: 'POST',
params,
});
}
// 更新IM用户状态
export function Status(params) {
return http.request({
url: '/imUser/status',
method: 'POST',
params,
});
}
// 获取IM用户设备列表
export function Devices(params) {
return http.request({
url: '/imUser/devices',
method: 'GET',
params,
});
}
+40 -6
View File
@@ -1,7 +1,7 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM注册邀请码"></n-card>
<n-card :bordered="false" title="IM注册邀请码" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@@ -22,13 +22,23 @@
>
<template #tableTitle>
<n-space>
<n-button type="primary" @click="handleCreate(false)" v-if="hasPermission(['/imInvite/create'])">
<n-button
type="primary"
@click="handleCreate(false)"
v-if="hasPermission(['/imInvite/create'])"
>
添加
</n-button>
<n-button type="primary" @click="handleCreate(true)" v-if="hasPermission(['/imInvite/batchCreate'])">
<n-button
type="primary"
@click="handleCreate(true)"
v-if="hasPermission(['/imInvite/batchCreate'])"
>
批量生成
</n-button>
<n-button @click="handleExport" v-if="hasPermission(['/imInvite/export'])">导出</n-button>
<n-button @click="handleExport" v-if="hasPermission(['/imInvite/export'])">
导出
</n-button>
</n-space>
</template>
</BasicTable>
@@ -45,7 +55,7 @@
import { BasicForm, useForm } from '@/components/Form/index';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { Export, List, Revoke } from '@/api/im/registrationInvite';
import { BatchRevoke, Export, List, Revoke } from '@/api/im/registrationInvite';
import {
columns,
loadOptions,
@@ -88,7 +98,7 @@
);
const actionColumn = reactive({
width: 220,
width: 300,
title: '操作',
key: 'action',
fixed: 'right',
@@ -109,6 +119,13 @@
auth: ['/imInvite/revoke'],
ifShow: () => record.status === 'active',
},
{
label: '撤销批次',
type: 'error',
onClick: handleBatchRevoke.bind(null, record),
auth: ['/imInvite/batchRevoke'],
ifShow: () => !!record.batchId && record.status === 'active',
},
],
});
},
@@ -165,6 +182,23 @@
});
}
function handleBatchRevoke(record: State) {
dialog.warning({
title: '确认撤销批次',
content: `将撤销批次 ${record.batchId} 下仍有效的邀请码,已过期或已撤销的邀请码不受影响,是否确认?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await BatchRevoke({
tenantId: Number(record.tenantId || selectedTenantId()),
batchId: record.batchId,
});
message.success('批次撤销成功');
reloadTable();
},
});
}
async function handleExport() {
const params = { ...searchFormRef.value?.formModel };
const response: any = await Export(params);
+59 -13
View File
@@ -1,7 +1,7 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM审计日志"></n-card>
<n-card :bordered="false" title="IM审计日志" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@@ -19,7 +19,23 @@
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
>
<template #tableTitle>
<n-button
type="primary"
class="min-left-space"
@click="handleExport"
v-if="hasPermission(['/imAuditLog/export'])"
>
<template #icon>
<n-icon>
<ExportOutlined />
</n-icon>
</template>
导出
</n-button>
</template>
</BasicTable>
</n-card>
<ViewModal ref="viewRef" />
</div>
@@ -30,16 +46,27 @@
import { useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { List } from '@/api/imAuditLog';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { List, Export } from '@/api/imAuditLog';
import { columns, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import { ExportOutlined } from '@vicons/antd';
import ViewModal from './view.vue';
const message = useMessage();
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: 100,
title: '操作',
@@ -48,7 +75,13 @@
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imAuditLog/view'] }],
actions: [
{
label: '详情',
onClick: () => viewRef.value.openModal(record),
auth: ['/imAuditLog/view'],
},
],
});
},
});
@@ -63,7 +96,8 @@
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
if (!params.tenantId) return { list: [], page: 1, pageCount: 0, totalCount: 0 };
if (isPlatformAdmin.value && !params.tenantId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
return await List(params);
};
@@ -73,19 +107,31 @@
async function handleReset() {
const defaults: Recordable = {};
if (tenantOptions.value.length > 0) defaults.tenantId = tenantOptions.value[0].value;
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
await setFieldsValue(defaults);
reloadTable();
}
onMounted(async () => {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
reloadTable();
} else {
message.warning('暂无可用租户');
function handleExport() {
const params = { ...searchFormRef.value?.formModel };
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请选择租户');
return;
}
Export(params);
}
onMounted(async () => {
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用租户');
}
}
reloadTable();
});
</script>
+103
View File
@@ -0,0 +1,103 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'删除前检查 #' + current.id"
:mask-closable="false"
:style="{ width: dialogWidth }"
>
<n-spin :show="loading" description="请稍候...">
<n-alert :type="detail.canEnterDeleting ? 'success' : 'warning'" class="mb-4">
{{
detail.canEnterDeleting ? '检查通过,可进入删除流程。' : '检查未通过,不能进入删除流程。'
}}
</n-alert>
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ detail.name || current.name }}</n-descriptions-item>
<n-descriptions-item label="编码">{{ detail.code || current.code }}</n-descriptions-item>
<n-descriptions-item label="用户数">{{ detail.userCount }}</n-descriptions-item>
<n-descriptions-item label="群数">{{ detail.groupCount }}</n-descriptions-item>
<n-descriptions-item label="待处理Outbox">{{ detail.pendingOutbox }}</n-descriptions-item>
<n-descriptions-item v-if="detail.blockers?.length" label="阻断项" :span="2">
<n-code :code="detail.blockers.join('\n')" language="text" word-wrap />
</n-descriptions-item>
<n-descriptions-item v-if="detail.warnings?.length" label="警告" :span="2">
<n-code :code="detail.warnings.join('\n')" language="text" word-wrap />
</n-descriptions-item>
</n-descriptions>
</n-spin>
<template #action>
<n-space>
<n-button @click="load">重新检查</n-button>
<n-button
type="error"
:disabled="!detail.canEnterDeleting"
:loading="submitLoading"
@click="enterDeleting"
>
进入删除流程
</n-button>
<n-button @click="showModal = false">关闭</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { DeletionCheck, EnterDeleting } from '@/api/imTenant';
import { newState, State } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
const emit = defineEmits(['reload']);
const message = useMessage();
const settingStore = useProjectSettingStore();
const showModal = ref(false);
const loading = ref(false);
const submitLoading = ref(false);
const current = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(840));
const detail = reactive({
id: 0,
code: '',
name: '',
canEnterDeleting: false,
blockers: [] as string[],
warnings: [] as string[],
userCount: 0,
groupCount: 0,
pendingOutbox: 0,
});
async function load() {
loading.value = true;
try {
Object.assign(detail, await DeletionCheck({ id: current.value.id }));
} finally {
loading.value = false;
}
}
async function enterDeleting() {
submitLoading.value = true;
try {
await EnterDeleting({ id: current.value.id });
message.success('已进入删除流程');
showModal.value = false;
emit('reload');
} finally {
submitLoading.value = false;
}
}
function openModal(state: State) {
current.value = newState(state);
showModal.value = true;
load();
}
defineExpose({ openModal });
</script>
+44 -5
View File
@@ -1,7 +1,7 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM租户管理"></n-card>
<n-card :bordered="false" title="IM租户管理" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@@ -37,7 +37,9 @@
</template>
</BasicTable>
</n-card>
<Edit ref="editRef" @reloadTable="reloadTable" />
<Edit ref="editRef" @reload-table="reloadTable" />
<UsageModal ref="usageRef" />
<DeletionCheckModal ref="deletionCheckRef" @reload="reloadTable" />
</div>
</template>
@@ -48,11 +50,13 @@
import { BasicForm, useForm } from '@/components/Form/index';
import { usePermission } from '@/hooks/web/usePermission';
import { useDictStore } from '@/store/modules/dict';
import { List, Status } from '@/api/imTenant';
import { List, Restore, Status } from '@/api/imTenant';
import { PlusOutlined } from '@vicons/antd';
import { columns, loadOptions, schemas, State } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import Edit from './edit.vue';
import UsageModal from './usage.vue';
import DeletionCheckModal from './deletionCheck.vue';
const dict = useDictStore();
const message = useMessage();
@@ -60,9 +64,11 @@
const actionRef = ref();
const searchFormRef = ref<any>({});
const editRef = ref();
const usageRef = ref();
const deletionCheckRef = ref();
const actionColumn = reactive({
width: 180,
width: 240,
title: '操作',
key: 'action',
fixed: 'right',
@@ -75,6 +81,11 @@
onClick: handleEdit.bind(null, record),
auth: ['/imTenant/edit'],
},
{
label: '用量',
onClick: handleUsage.bind(null, record),
auth: ['/imTenant/usage'],
},
{
label: '停用',
onClick: handleStatus.bind(null, record, 2),
@@ -84,9 +95,22 @@
{
label: '启用',
onClick: handleStatus.bind(null, record, 1),
ifShow: () => record.state !== 1,
ifShow: () => ![1, 3, 4].includes(record.state),
auth: ['/imTenant/status'],
},
{
label: '恢复',
onClick: handleRestore.bind(null, record),
ifShow: () => [2, 3].includes(record.state),
auth: ['/imTenant/restore'],
},
{
label: '删除检查',
type: 'error',
onClick: handleDeletionCheck.bind(null, record),
ifShow: () => record.state !== 4,
auth: ['/imTenant/deletionCheck'],
},
],
});
},
@@ -122,6 +146,21 @@
editRef.value.openModal(record);
}
function handleUsage(record: State) {
usageRef.value.openModal(record);
}
function handleDeletionCheck(record: State) {
deletionCheckRef.value.openModal(record);
}
function handleRestore(record: State) {
Restore({ id: record.id }).then((_res) => {
message.success('恢复成功');
reloadTable();
});
}
// 修改状态
function handleStatus(record: Recordable, state: number) {
Status({ id: record.id, state }).then((_res) => {
+19
View File
@@ -34,6 +34,25 @@ export class State {
}
}
export class UsageState {
public id = 0;
public code = '';
public name = '';
public dbName = '';
public dbDsnConfigured = false;
public dbReachable = false;
public dbCheckError = '';
public schemaVersion = '';
public userCount = 0;
public activeUserCount = 0;
public groupCount = 0;
public deviceCount = 0;
public pushTokenCount = 0;
public inviteUseCount = 0;
public outboxPendingCount = 0;
public warnings: string[] = [];
}
export function newState(state: State | Record<string, any> | null): State {
if (state !== null) {
if (state instanceof State) {
+83
View File
@@ -0,0 +1,83 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'租户健康与用量 #' + current.id"
:mask-closable="false"
:style="{ width: dialogWidth }"
>
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ usage.name || current.name }}</n-descriptions-item>
<n-descriptions-item label="租户编码">{{ usage.code || current.code }}</n-descriptions-item>
<n-descriptions-item label="业务库">{{ usage.dbName || '-' }}</n-descriptions-item>
<n-descriptions-item label="独立连接串">
{{ usage.dbDsnConfigured ? '已配置' : '未配置' }}
</n-descriptions-item>
<n-descriptions-item label="业务库状态">
<n-tag :type="usage.dbReachable ? 'success' : 'error'" :bordered="false">
{{ usage.dbReachable ? '可达' : '不可达' }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="Schema版本">
{{ usage.schemaVersion || '-' }}
</n-descriptions-item>
<n-descriptions-item label="用户数">{{ usage.userCount }}</n-descriptions-item>
<n-descriptions-item label="正常用户数">{{ usage.activeUserCount }}</n-descriptions-item>
<n-descriptions-item label="群数">{{ usage.groupCount }}</n-descriptions-item>
<n-descriptions-item label="设备数">{{ usage.deviceCount }}</n-descriptions-item>
<n-descriptions-item label="Push Token">{{ usage.pushTokenCount }}</n-descriptions-item>
<n-descriptions-item label="邀请码使用">{{ usage.inviteUseCount }}</n-descriptions-item>
<n-descriptions-item label="待处理Outbox">
{{ usage.outboxPendingCount }}
</n-descriptions-item>
<n-descriptions-item v-if="usage.dbCheckError" label="连接错误" :span="2">
{{ usage.dbCheckError }}
</n-descriptions-item>
<n-descriptions-item v-if="usage.warnings?.length" label="检查警告" :span="2">
<n-code :code="usage.warnings.join('\n')" language="text" word-wrap />
</n-descriptions-item>
</n-descriptions>
</n-spin>
<template #action>
<n-space>
<n-button @click="load">刷新</n-button>
<n-button @click="showModal = false">关闭</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { Usage } from '@/api/imTenant';
import { newState, State, UsageState } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
const settingStore = useProjectSettingStore();
const showModal = ref(false);
const loading = ref(false);
const current = ref<State>(newState(null));
const usage = ref<UsageState>(new UsageState());
const dialogWidth = computed(() => adaModalWidth(900));
async function load() {
loading.value = true;
try {
usage.value = Object.assign(new UsageState(), await Usage({ id: current.value.id }));
} finally {
loading.value = false;
}
}
function openModal(state: State) {
current.value = newState(state);
usage.value = new UsageState();
showModal.value = true;
load();
}
defineExpose({ openModal });
</script>
+57
View File
@@ -0,0 +1,57 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'登录设备 ' + userId"
:mask-closable="false"
:style="{ width: dialogWidth }"
>
<BasicTable
ref="actionRef"
:columns="deviceColumns"
:request="loadDataTable"
:row-key="(row) => row.deviceId"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
<template #action>
<n-space>
<n-button @click="showModal = false">关闭</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { BasicTable } from '@/components/Table';
import { Devices } from '@/api/imUser';
import { deviceColumns, State } from './model';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
const showModal = ref(false);
const actionRef = ref();
const tenantId = ref(0);
const userId = ref('');
const dialogWidth = computed(() => adaModalWidth(1080));
const scrollX = computed(() => adaTableScrollX(deviceColumns, 0));
const loadDataTable = async (res) => {
if (!tenantId.value || !userId.value) {
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
}
return await Devices({ tenantId: tenantId.value, userId: userId.value, ...res });
};
function openModal(record: State) {
tenantId.value = record.tenantId;
userId.value = record.userId;
showModal.value = true;
setTimeout(() => {
actionRef.value?.reload();
});
}
defineExpose({ openModal });
</script>
+37 -4
View File
@@ -1,7 +1,7 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM用户管理"></n-card>
<n-card :bordered="false" title="IM用户管理" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@@ -22,30 +22,34 @@
/>
</n-card>
<ViewModal ref="viewRef" />
<DevicesModal ref="devicesRef" />
<ResetPasswordModal ref="resetPasswordRef" @reload="reloadTable" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
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 { List } from '@/api/imUser';
import { List, RevokeSessions } from '@/api/imUser';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
import DevicesModal from './DevicesModal.vue';
import ResetPasswordModal from './ResetPasswordModal.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 devicesRef = ref();
const resetPasswordRef = ref();
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
@@ -54,7 +58,7 @@
}
const actionColumn = reactive({
width: 220,
width: 330,
title: '操作',
key: 'action',
fixed: 'right',
@@ -67,6 +71,17 @@
onClick: handleView.bind(null, record),
auth: ['/imUser/view'],
},
{
label: '设备',
onClick: handleDevices.bind(null, record),
auth: ['/imUser/devices'],
},
{
label: '撤销会话',
type: 'error',
onClick: handleRevokeSessions.bind(null, record),
auth: ['/imUser/revokeSessions'],
},
{
label: '重置密码',
type: 'warning',
@@ -107,6 +122,24 @@
viewRef.value.openModal(record);
}
function handleDevices(record: State) {
devicesRef.value.openModal(record);
}
function handleRevokeSessions(record: State) {
dialog.warning({
title: '撤销用户会话',
content: '将递增该用户认证版本,并通知 IM 服务清退在线会话,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await RevokeSessions({ tenantId: record.tenantId, userId: record.userId });
message.success('会话撤销事件已提交');
reloadTable();
},
});
}
function handleResetPassword(record: State) {
resetPasswordRef.value.openModal(record);
}
+60 -2
View File
@@ -1,11 +1,16 @@
import { computed, ref } from 'vue';
import { computed, h, ref } from 'vue';
import { NSwitch } from 'naive-ui';
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';
import { Status } from '@/api/imUser';
import { usePermission } from '@/hooks/web/usePermission';
const dict = useDictStore();
const { hasPermission } = usePermission();
const $message = window['$message'];
export const tenantOptions = ref<any[]>([]);
@@ -43,6 +48,21 @@ export class State {
}
}
export class DeviceState {
public tenantId = 0;
public tenantName = '';
public userId = '';
public deviceId = '';
public platform = '';
public lang = '';
public lastSeen = '';
public pushProvider = '';
public pushPlatform = '';
public pushAppVersion = '';
public pushTokenConfigured = false;
public pushUpdatedAt = '';
}
export function newState(state: State | Record<string, any> | null): State {
if (state !== null) {
if (state instanceof State) {
@@ -159,7 +179,25 @@ export const columns = [
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImUserStateOptions', row.state);
if (row.state === 20) {
return dict.getLabel('ImUserStateOptions', row.state);
}
return h(NSwitch, {
value: row.state === 0,
checked: '正常',
unchecked: '停用',
disabled: !hasPermission(['/imUser/status']),
onUpdateValue: async (value) => {
const oldState = row.state;
row.state = value ? 0 : 10;
try {
await Status({ tenantId: row.tenantId, userId: row.userId, state: row.state });
$message.success('操作成功');
} catch {
row.state = oldState;
}
},
});
},
},
{
@@ -200,6 +238,26 @@ export const columns = [
},
];
export const deviceColumns = [
{ title: '设备ID', key: 'deviceId', align: 'left', width: 240 },
{ title: '平台', key: 'platform', align: 'left', width: 120 },
{ title: '语言', key: 'lang', align: 'left', width: 90 },
{ title: '最后活跃', key: 'lastSeen', align: 'left', width: 180 },
{ title: 'Push服务商', key: 'pushProvider', align: 'left', width: 120 },
{ title: 'Push平台', key: 'pushPlatform', align: 'left', width: 120 },
{ title: 'App版本', key: 'pushAppVersion', align: 'left', width: 120 },
{
title: 'Push Token',
key: 'pushTokenConfigured',
align: 'left',
width: 120,
render(row: DeviceState) {
return row.pushTokenConfigured ? '已配置' : '未配置';
},
},
{ title: 'Push更新时间', key: 'pushUpdatedAt', align: 'left', width: 180 },
];
// 加载字典数据选项
export function loadOptions() {
dict.loadOptions(['ImUserStateOptions']);