feat: 完善IM用户管理与治理操作

This commit is contained in:
richjohnrambo
2026-09-08 10:54:01 +08:00
parent 5f3f6913b8
commit 7d54bf785a
13 changed files with 1319 additions and 131 deletions
+30 -1
View File
@@ -1,4 +1,21 @@
import { http } from '@/utils/http/axios';
import { http, jumpExport } from '@/utils/http/axios';
import type { UploadFileParams } from '@/utils/http/axios/types';
export function Create(params) {
return http.request({ url: '/imUser/create', method: 'POST', params });
}
export function BatchCreate(params) {
return http.request({ url: '/imUser/batchCreate', method: 'POST', params });
}
export function ImportUsers(params: UploadFileParams) {
return http.uploadFile({ url: '/imUser/import', method: 'POST' }, params);
}
export function DownloadTemplate() {
jumpExport('/imUser/downloadTemplate', {});
}
// 获取IM用户列表
export function List(params) {
@@ -45,6 +62,18 @@ export function Status(params) {
});
}
export function AdjustGroup(params) {
return http.request({ url: '/imUser/adjustGroup', method: 'POST', params });
}
export function IPWhitelist(params) {
return http.request({ url: '/imUser/ipWhitelist', method: 'GET', params });
}
export function IPWhitelistSave(params) {
return http.request({ url: '/imUser/ipWhitelistSave', method: 'POST', params });
}
// 获取IM用户设备列表
export function Devices(params) {
return http.request({
+457 -31
View File
@@ -19,26 +19,226 @@
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
>
<template #tableTitle>
<n-space>
<n-button type="primary" v-if="hasPermission(['/imUser/create'])" @click="openCreate">
<template #icon
><n-icon><PlusOutlined /></n-icon
></template>
创建用户
</n-button>
<n-button v-if="hasPermission(['/imUser/batchCreate'])" @click="openBatchCreate">
批量创建
</n-button>
<n-button v-if="hasPermission(['/imUser/import'])" @click="openImport">
<template #icon
><n-icon><UploadOutlined /></n-icon
></template>
导入Excel
</n-button>
<n-button v-if="hasPermission(['/imUser/downloadTemplate'])" @click="DownloadTemplate">
<template #icon
><n-icon><DownloadOutlined /></n-icon
></template>
下载模板
</n-button>
</n-space>
</template>
</BasicTable>
</n-card>
<ViewModal ref="viewRef" />
<DevicesModal ref="devicesRef" />
<ResetPasswordModal ref="resetPasswordRef" @reload="reloadTable" />
<n-modal
v-model:show="ipVisible"
preset="dialog"
title="IP白名单"
:show-icon="false"
class="user-dialog"
>
<n-form label-placement="left" label-width="90">
<n-form-item label="用户"
>{{ operationUser?.username }}({{ operationUser?.userId }})</n-form-item
>
<n-form-item label="IP/CIDR">
<n-input
v-model:value="ipWhitelistText"
type="textarea"
:rows="7"
placeholder="每行一个 IP 或 CIDR,例如 192.168.1.10 或 10.0.0.0/8"
/>
</n-form-item>
</n-form>
<template #action>
<n-button @click="ipVisible = false">取消</n-button>
<n-button
v-if="hasPermission(['/imUser/ipWhitelistSave'])"
type="primary"
:loading="submitting"
@click="submitIPWhitelist"
>保存</n-button
>
</template>
</n-modal>
<n-modal
v-model:show="groupVisible"
preset="dialog"
title="调整分组"
:show-icon="false"
class="user-dialog"
>
<n-form label-placement="left" label-width="90">
<n-form-item label="用户"
>{{ operationUser?.username }}({{ operationUser?.userId }})</n-form-item
>
<n-form-item label="用户分组" required>
<n-select
v-model:value="adjustGroupId"
:options="policyGroupOptions"
placeholder="请选择用户分组"
/>
</n-form-item>
</n-form>
<template #action>
<n-button @click="groupVisible = false">取消</n-button>
<n-button type="primary" :loading="submitting" @click="submitAdjustGroup">保存</n-button>
</template>
</n-modal>
<n-modal
v-model:show="createVisible"
preset="dialog"
title="创建用户"
:show-icon="false"
class="user-dialog"
>
<n-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
label-placement="left"
label-width="90"
>
<n-form-item label="用户名" path="username"
><n-input v-model:value="createForm.username" placeholder="可空,系统自动生成"
/></n-form-item>
<n-form-item label="昵称" path="nickname"
><n-input v-model:value="createForm.nickname" placeholder="请输入昵称"
/></n-form-item>
<n-form-item label="手机号" path="phone"
><n-input v-model:value="createForm.phone" placeholder="可选"
/></n-form-item>
<n-form-item label="密码" path="password"
><n-input v-model:value="createForm.password" type="password" show-password-on="click"
/></n-form-item>
<n-form-item label="用户分组"
><n-select
v-model:value="createForm.groupId"
:options="policyGroupOptions"
clearable
placeholder="请选择用户分组"
/></n-form-item>
</n-form>
<template #action
><n-button @click="createVisible = false">取消</n-button
><n-button type="primary" :loading="submitting" @click="submitCreate"
>确定</n-button
></template
>
</n-modal>
<n-modal
v-model:show="batchVisible"
preset="dialog"
title="批量创建用户"
:show-icon="false"
class="user-dialog"
>
<n-form
ref="batchFormRef"
:model="batchForm"
:rules="batchRules"
label-placement="left"
label-width="100"
>
<n-form-item label="创建数量" path="count"
><n-input-number v-model:value="batchForm.count" :min="1" :max="100"
/></n-form-item>
<n-form-item label="昵称前缀"
><n-input v-model:value="batchForm.nicknamePrefix" placeholder="默认:用户"
/></n-form-item>
<n-form-item label="默认密码" path="password"
><n-input v-model:value="batchForm.password" type="password" show-password-on="click"
/></n-form-item>
<n-form-item label="默认分组"
><n-select v-model:value="batchForm.groupId" :options="policyGroupOptions" clearable
/></n-form-item>
</n-form>
<template #action
><n-button @click="batchVisible = false">取消</n-button
><n-button type="primary" :loading="submitting" @click="submitBatchCreate"
>确定</n-button
></template
>
</n-modal>
<n-modal
v-model:show="importVisible"
preset="dialog"
title="导入Excel"
:show-icon="false"
class="user-dialog"
>
<n-form label-placement="left" label-width="90">
<n-form-item label="默认分组"
><n-select v-model:value="importGroupId" :options="policyGroupOptions" clearable
/></n-form-item>
<n-form-item label="Excel文件">
<n-upload :max="1" accept=".xlsx" :custom-request="handleImport" :show-file-list="true">
<n-upload-dragger><n-text>点击或拖动 .xlsx 文件到此处</n-text></n-upload-dragger>
</n-upload>
</n-form-item>
</n-form>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { computed, h, onMounted, reactive, ref, watch } from 'vue';
import { UploadCustomRequestOptions, 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, RevokeSessions } from '@/api/imUser';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import {
AccountCancel,
AdjustGroup,
Ban,
BatchCreate,
Create,
DownloadTemplate,
ImportUsers,
IPWhitelist,
IPWhitelistSave,
List,
LogoutAll,
Unban,
} from '@/api/imUser';
import {
columns,
loadOptions,
loadPolicyGroupOptions,
loadTenantOptions,
policyGroupOptions,
schemas,
State,
tenantOptions,
} from './model';
import { DownloadOutlined, PlusOutlined, UploadOutlined } from '@vicons/antd';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
import DevicesModal from './DevicesModal.vue';
import ResetPasswordModal from './ResetPasswordModal.vue';
const message = useMessage();
@@ -49,8 +249,40 @@
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const devicesRef = ref();
const resetPasswordRef = ref();
const createFormRef = ref();
const batchFormRef = ref();
const createVisible = ref(false);
const batchVisible = ref(false);
const importVisible = ref(false);
const submitting = ref(false);
const ipVisible = ref(false);
const groupVisible = ref(false);
const operationUser = ref<State | null>(null);
const ipWhitelistText = ref('');
const adjustGroupId = ref<number | null>(null);
const importGroupId = ref<number | null>(null);
const createForm = reactive({
username: '',
nickname: '',
phone: '',
password: '',
groupId: null as number | null,
});
const batchForm = reactive({
count: 10,
nicknamePrefix: '用户',
password: '',
groupId: null as number | null,
});
const createRules = {
nickname: { required: true, message: '请输入昵称', trigger: 'blur' },
password: { required: true, min: 3, message: '密码至少3位', trigger: 'blur' },
};
const batchRules = {
count: { required: true, type: 'number', message: '请输入创建数量', trigger: 'change' },
password: { required: true, min: 3, message: '密码至少3位', trigger: 'blur' },
};
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
@@ -58,7 +290,7 @@
}
const actionColumn = reactive({
width: 330,
width: 600,
title: '操作',
key: 'action',
fixed: 'right',
@@ -68,25 +300,51 @@
actions: [
{
label: '详情',
type: 'default',
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',
type: 'default',
onClick: handleResetPassword.bind(null, record),
auth: ['/imUser/resetPassword'],
ifShow: record.state !== 20,
},
{
label: 'IP白名单',
type: 'default',
onClick: openIPWhitelist.bind(null, record),
auth: ['/imUser/ipWhitelist'],
ifShow: record.state !== 20,
},
{
label: '调整分组',
type: 'default',
onClick: openAdjustGroup.bind(null, record),
auth: ['/imUser/adjustGroup'],
ifShow: record.state !== 20,
},
{
label: record.state === 0 ? '封禁' : '解封',
type: record.state === 0 ? 'error' : 'default',
onClick: handleBanToggle.bind(null, record),
auth: [record.state === 0 ? '/imUser/ban' : '/imUser/unban'],
ifShow: record.state !== 20,
},
{
label: '强制下线',
type: 'error',
onClick: handleForceOffline.bind(null, record),
auth: ['/imUser/logoutAll'],
ifShow: record.state !== 20,
},
{
label: '强制注销',
type: 'error',
onClick: handleForceCancel.bind(null, record),
auth: ['/imUser/accountCancel'],
ifShow: record.state !== 20,
},
],
});
@@ -122,19 +380,19 @@
viewRef.value.openModal(record);
}
function handleDevices(record: State) {
devicesRef.value.openModal(record);
}
function handleRevokeSessions(record: State) {
function handleForceOffline(record: State) {
dialog.warning({
title: '撤销用户会话',
content: '将递增该用户认证版本,并通知 IM 服务清退在线会话,是否确认?',
title: '强制下线',
content: `确定强制用户“${record.username}”的全部设备下线吗?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await RevokeSessions({ tenantId: record.tenantId, userId: record.userId });
message.success('会话撤销事件已提交');
await LogoutAll({
tenantId: record.tenantId,
userId: Number(record.userId),
reason: '后台强制下线',
});
message.success('强制下线命令已提交');
reloadTable();
},
});
@@ -144,6 +402,169 @@
resetPasswordRef.value.openModal(record);
}
async function openIPWhitelist(record: State) {
operationUser.value = record;
const result = await IPWhitelist({ tenantId: record.tenantId, userId: Number(record.userId) });
ipWhitelistText.value = (result?.ipList || []).join('\n');
ipVisible.value = true;
}
async function submitIPWhitelist() {
if (!operationUser.value) return;
const ipList = ipWhitelistText.value
.split(/[\n,,]+/)
.map((item) => item.trim())
.filter(Boolean);
submitting.value = true;
try {
await IPWhitelistSave({
tenantId: operationUser.value.tenantId,
userId: Number(operationUser.value.userId),
ipList,
});
message.success('IP白名单保存成功');
ipVisible.value = false;
} finally {
submitting.value = false;
}
}
function openAdjustGroup(record: State) {
operationUser.value = record;
adjustGroupId.value = record.policyGroupId || null;
groupVisible.value = true;
}
async function submitAdjustGroup() {
if (!operationUser.value || !adjustGroupId.value) {
message.warning('请选择用户分组');
return;
}
submitting.value = true;
try {
await AdjustGroup({
tenantId: operationUser.value.tenantId,
userId: Number(operationUser.value.userId),
groupId: adjustGroupId.value,
});
message.success('用户分组调整成功');
groupVisible.value = false;
reloadTable();
} finally {
submitting.value = false;
}
}
function handleBanToggle(record: State) {
const banning = record.state === 0;
dialog.warning({
title: banning ? '封禁用户' : '解封用户',
content: `确定${banning ? '封禁' : '解封'}用户“${record.username}”吗?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
const action = banning ? Ban : Unban;
await action({
tenantId: record.tenantId,
userId: Number(record.userId),
reason: banning ? '后台封禁' : '后台解封',
});
message.success(`${banning ? '封禁' : '解封'}命令已提交`);
reloadTable();
},
});
}
function handleForceCancel(record: State) {
dialog.error({
title: '强制注销',
content: `确定强制注销用户“${record.username}”吗?注销后账号不可继续登录。`,
positiveText: '确认注销',
negativeText: '取消',
onPositiveClick: async () => {
await AccountCancel({
tenantId: record.tenantId,
userId: Number(record.userId),
reason: '后台强制注销',
});
message.success('强制注销命令已提交');
reloadTable();
},
});
}
function currentTenantId() {
return Number(searchFormRef.value?.formModel?.tenantId || 0);
}
function openCreate() {
Object.assign(createForm, {
username: '',
nickname: '',
phone: '',
password: '',
groupId: null,
});
createVisible.value = true;
}
async function submitCreate() {
await createFormRef.value?.validate();
submitting.value = true;
try {
const result = await Create({ ...createForm, tenantId: currentTenantId() });
message.success(`用户 ${result.username} 创建成功`);
createVisible.value = false;
reloadTable();
} finally {
submitting.value = false;
}
}
function openBatchCreate() {
Object.assign(batchForm, { count: 10, nicknamePrefix: '用户', password: '', groupId: null });
batchVisible.value = true;
}
async function submitBatchCreate() {
await batchFormRef.value?.validate();
submitting.value = true;
try {
const result = await BatchCreate({ ...batchForm, tenantId: currentTenantId() });
message.success(`创建完成:成功 ${result.successCount},失败 ${result.failedCount}`);
batchVisible.value = false;
reloadTable();
} finally {
submitting.value = false;
}
}
function openImport() {
importGroupId.value = null;
importVisible.value = true;
}
async function handleImport(options: UploadCustomRequestOptions) {
try {
const result = await ImportUsers({
file: options.file.file as File,
tenantId: currentTenantId(),
groupId: importGroupId.value || 0,
});
options.onFinish();
message.success(`导入完成:成功 ${result.successCount},失败 ${result.failedCount}`);
importVisible.value = false;
reloadTable();
} catch {
options.onError();
}
}
watch(
() => searchFormRef.value?.formModel?.tenantId,
(tenantId) => loadPolicyGroupOptions(Number(tenantId || 0))
);
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
@@ -154,8 +575,13 @@
message.warning('暂无可用租户');
}
}
await loadPolicyGroupOptions(currentTenantId());
reloadTable();
});
</script>
<style lang="less" scoped></style>
<style lang="less" scoped>
:deep(.user-dialog) {
width: min(560px, calc(100vw - 32px));
}
</style>
+84 -92
View File
@@ -1,18 +1,15 @@
import { computed, h, ref } from 'vue';
import { NSwitch } from 'naive-ui';
import { NAvatar, NImage, NTag } 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';
import { List as PolicyGroupList } from '@/api/imUserPolicyGroup';
const dict = useDictStore();
const { hasPermission } = usePermission();
const $message = window['$message'];
export const tenantOptions = ref<any[]>([]);
export const policyGroupOptions = ref<any[]>([]);
export class State {
public tenantId = 0; // 租户ID
@@ -25,6 +22,8 @@ export class State {
public gender = ''; // 性别
public region = ''; // 地区
public state = 0; // 账号状态
public policyGroupId = 0; // 用户策略分组ID
public policyGroupName = ''; // 用户策略分组名称
public authVersion = 0; // 认证版本
public passwordUpdatedAt = ''; // 密码更新时间
public authNames = ''; // 登录标识
@@ -35,6 +34,8 @@ export class State {
public updatedAt = ''; // 修改时间
public signature = ''; // 个性签名
public avatarFileId = ''; // 头像文件ID
public avatar = ''; // 头像地址
public registerType = ''; // 注册方式
public birthDate = ''; // 出生日期
public profileJson = ''; // Tinode Public资料
public trustedJson = ''; // Tinode Trusted资料
@@ -88,25 +89,19 @@ export const schemas = ref<FormSchema[]>([
{
field: 'userId',
component: 'NInput',
label: '用户ID',
label: '编号',
componentProps: {
placeholder: '请输入用户ID',
placeholder: '用户编号',
},
},
{
field: 'nickname',
component: 'NInput',
label: '昵称',
field: 'groupId',
component: 'NSelect',
label: '用户分组',
componentProps: {
placeholder: '请输入昵称',
},
},
{
field: 'authName',
component: 'NInput',
label: '登录标识',
componentProps: {
placeholder: '请输入登录名/手机号/邮箱',
clearable: true,
options: computed(() => policyGroupOptions.value),
placeholder: '全部',
},
},
{
@@ -116,27 +111,16 @@ export const schemas = ref<FormSchema[]>([
componentProps: {
clearable: true,
options: dict.getOption('ImUserStateOptions'),
placeholder: '请选择状态',
placeholder: '全部',
},
},
{
field: 'createdAt',
component: 'NDatePicker',
label: '创建时间',
field: 'keyword',
component: 'NInput',
label: '关键词',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
},
},
{
field: 'lastSeen',
component: 'NDatePicker',
label: '最后在线',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
placeholder: '用户名/昵称/手机号',
},
},
]);
@@ -144,85 +128,90 @@ export const schemas = ref<FormSchema[]>([
// 表格列
export const columns = [
{
title: '用户ID',
title: '编号',
key: 'userId',
align: 'left',
width: 120,
width: 90,
},
{
title: '头像',
key: 'avatar',
align: 'left',
width: 80,
render(row: State) {
if (row.avatar) {
return h(NImage, { width: 40, height: 40, src: row.avatar, objectFit: 'cover' });
}
return h(
NAvatar,
{ size: 40 },
{ default: () => (row.nickname || row.username || '?').slice(0, 1) }
);
},
},
{
title: '用户名',
key: 'username',
align: 'left',
width: 150,
},
{
title: '昵称',
key: 'nickname',
align: 'left',
width: 160,
},
{
title: '登录账号',
key: 'username',
align: 'left',
width: 180,
width: 150,
render(row: State) {
return row.nickname || '--';
},
},
{
title: '手机号',
key: 'phone',
align: 'left',
width: 180,
width: 140,
render(row: State) {
return row.phone || '--';
},
},
{
title: '公开账号',
key: 'publicAccount',
title: '注册方式',
key: 'registerType',
align: 'left',
width: 150,
width: 110,
render(row: State) {
return dict.getLabel('ImUserRegisterTypeOptions', row.registerType) || '--';
},
},
{
title: '状态',
key: 'state',
align: 'left',
width: 110,
width: 90,
render(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;
}
},
});
return h(
NTag,
{ type: row.state === 0 ? 'success' : row.state === 10 ? 'error' : 'default' },
{ default: () => dict.getLabel('ImUserStateOptions', row.state) || '--' }
);
},
},
{
title: '登录标识',
key: 'authNames',
title: '用户分组',
key: 'policyGroupName',
align: 'left',
width: 240,
width: 130,
render(row: State) {
return row.policyGroupName || '--';
},
},
{
title: '凭证',
key: 'credentials',
align: 'left',
width: 220,
},
{
title: '最后在线',
title: '最近登录',
key: 'lastSeen',
align: 'left',
width: 180,
},
{
title: '客户端',
key: 'userAgent',
align: 'left',
width: 240,
render(row: State) {
return row.lastSeen || '--';
},
},
{
title: '创建时间',
@@ -230,12 +219,6 @@ export const columns = [
align: 'left',
width: 180,
},
{
title: '修改时间',
key: 'updatedAt',
align: 'left',
width: 180,
},
];
export const deviceColumns = [
@@ -260,7 +243,7 @@ export const deviceColumns = [
// 加载字典数据选项
export function loadOptions() {
dict.loadOptions(['ImUserStateOptions']);
dict.loadOptions(['ImUserStateOptions', 'ImUserRegisterTypeOptions']);
}
export async function loadTenantOptions() {
@@ -271,3 +254,12 @@ export async function loadTenantOptions() {
}));
return tenantOptions.value;
}
export async function loadPolicyGroupOptions(tenantId: number) {
const res = await PolicyGroupList({ tenantId, page: 1, pageSize: 200 });
policyGroupOptions.value = (res?.list || []).map((item) => ({
label: item.groupName,
value: item.id,
}));
return policyGroupOptions.value;
}