Files
im_manager/web/src/views/imUser/index.vue
T

591 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="用户管理" />
</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.tenantId + '-' + row.userId"
: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" />
<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?.displayNo || 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?.displayNo || 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, 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 {
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 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 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) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const actionColumn = reactive({
width: 600,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '详情',
type: 'default',
onClick: handleView.bind(null, record),
auth: ['/imUser/view'],
},
{
label: '重置密码',
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 === 0 || record.state === 10,
},
{
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,
},
],
});
},
});
const scrollX = computed(() => {
return 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 };
}
return await List(params);
};
// 重新加载表格数据
function reloadTable() {
actionRef.value?.reload();
}
// 查看详情
function handleView(record: Recordable) {
viewRef.value.openModal(record);
}
function handleForceOffline(record: State) {
dialog.warning({
title: '强制下线',
content: `确定强制用户“${record.username}”的全部设备下线吗?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await LogoutAll({
tenantId: record.tenantId,
userId: Number(record.userId),
reason: '后台强制下线',
});
message.success('强制下线已完成');
reloadTable();
},
});
}
function handleResetPassword(record: State) {
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;
}
}
async function openAdjustGroup(record: State) {
operationUser.value = record;
await loadPolicyGroupOptions(record.tenantId);
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();
let tenantId = currentTenantId();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
tenantId = Number(tenantOptions.value[0].value);
await setFieldsValue({ tenantId });
} else {
message.warning('暂无可用商户');
}
}
await loadPolicyGroupOptions(tenantId);
reloadTable();
});
</script>
<style lang="less" scoped>
:deep(.user-dialog) {
width: min(560px, calc(100vw - 32px));
}
</style>