chore: seed migration objects batch 6

This commit is contained in:
dev
2026-10-18 13:37:38 +08:00
parent c8c40b1dd2
commit 8b1b0219c5
100 changed files with 9849 additions and 537 deletions
+9
View File
@@ -0,0 +1,9 @@
import { http } from '@/utils/http/axios';
export function Get(params) {
return http.request({ url: '/imFeedbackConfig/get', method: 'GET', params });
}
export function Update(params) {
return http.request({ url: '/imFeedbackConfig/update', method: 'POST', params });
}
+46
View File
@@ -0,0 +1,46 @@
import { http } from '@/utils/http/axios';
// 获取IM文件列表
export function List(params) {
return http.request({
url: '/imFile/list',
method: 'GET',
params,
});
}
// 获取IM文件详情
export function View(params) {
return http.request({
url: '/imFile/view',
method: 'GET',
params,
});
}
// 隔离IM文件
export function Quarantine(params) {
return http.request({
url: '/imFile/quarantine',
method: 'POST',
params,
});
}
// 删除IM文件
export function Delete(params) {
return http.request({
url: '/imFile/delete',
method: 'POST',
params,
});
}
// 清理IM孤儿文件
export function CleanupOrphan(params) {
return http.request({
url: '/imFile/cleanupOrphan',
method: 'POST',
params,
});
}
+9
View File
@@ -0,0 +1,9 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imFilePolicy/list', method: 'GET', params });
}
export function Update(params) {
return http.request({ url: '/imFilePolicy/update', method: 'POST', params });
}
+90
View File
@@ -36,6 +36,78 @@ export function Governance(params) {
});
}
// 更新IM群治理配置
export function GovernanceUpdate(params) {
return http.request({
url: '/imGroup/governanceUpdate',
method: 'POST',
params,
});
}
// 解散IM群
export function Dissolve(params) {
return http.request({
url: '/imGroup/dissolve',
method: 'POST',
params,
});
}
// 禁言IM群成员
export function Mute(params) {
return http.request({
url: '/imGroup/mute',
method: 'POST',
params,
});
}
// 解除IM群成员禁言
export function Unmute(params) {
return http.request({
url: '/imGroup/unmute',
method: 'POST',
params,
});
}
// 通过IM入群申请
export function JoinRequestApprove(params) {
return http.request({
url: '/imGroup/joinRequestApprove',
method: 'POST',
params,
});
}
// 拒绝IM入群申请
export function JoinRequestReject(params) {
return http.request({
url: '/imGroup/joinRequestReject',
method: 'POST',
params,
});
}
// 更新IM群成员角色
export function RoleUpdate(params) {
return http.request({
url: '/imGroup/roleUpdate',
method: 'POST',
params,
});
}
// 转让IM群主
export function OwnerTransfer(params) {
return http.request({
url: '/imGroup/ownerTransfer',
method: 'POST',
params,
});
}
// 获取IM入群申请列表
export function JoinRequestList(params) {
return http.request({
@@ -45,6 +117,24 @@ export function JoinRequestList(params) {
});
}
// 获取IM群文件列表
export function FileList(params) {
return http.request({
url: '/imGroup/fileList',
method: 'GET',
params,
});
}
// 删除IM群文件
export function FileDelete(params) {
return http.request({
url: '/imGroup/fileDelete',
method: 'POST',
params,
});
}
// 获取IM群邀请列表
export function InvitationList(params) {
return http.request({
+17
View File
@@ -0,0 +1,17 @@
import { http, jumpExport } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imGroupMessage/list', method: 'GET', params });
}
export function View(params) {
return http.request({ url: '/imGroupMessage/view', method: 'GET', params });
}
export function Hide(params) {
return http.request({ url: '/imGroupMessage/hide', method: 'POST', params });
}
export function Delete(params) {
return http.request({ url: '/imGroupMessage/delete', method: 'POST', params });
}
export function Export(params) {
jumpExport('/imGroupMessage/export', params);
}
+17
View File
@@ -0,0 +1,17 @@
import { http, jumpExport } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imLoginFailureLog/list', method: 'GET', params });
}
export function View(params) {
return http.request({ url: '/imLoginFailureLog/view', method: 'GET', params });
}
export function Export(params) {
jumpExport('/imLoginFailureLog/export', params);
}
export function AddBlacklist(params) {
return http.request({ url: '/imLoginFailureLog/addBlacklist', method: 'POST', params });
}
export function BanUser(params) {
return http.request({ url: '/imLoginFailureLog/banUser', method: 'POST', params });
}
+9
View File
@@ -0,0 +1,9 @@
import { http } from '@/utils/http/axios';
export function Get(params) {
return http.request({ url: '/imMailboxConfig/get', method: 'GET', params });
}
export function Update(params) {
return http.request({ url: '/imMailboxConfig/update', method: 'POST', params });
}
+9
View File
@@ -0,0 +1,9 @@
import { http } from '@/utils/http/axios';
export function TemplateList(params) {
return http.request({ url: '/imNotification/templateList', method: 'GET', params });
}
export function TemplateUpdate(params) {
return http.request({ url: '/imNotification/templateUpdate', method: 'POST', params });
}
+37
View File
@@ -0,0 +1,37 @@
import { http, jumpExport } from '@/utils/http/axios';
export function DepartmentList(params) {
return http.request({ url: '/imOrg/departmentList', method: 'GET', params });
}
export function DepartmentExport(params) {
jumpExport('/imOrg/departmentExport', params);
}
export function DepartmentEdit(params) {
return http.request({ url: '/imOrg/departmentEdit', method: 'POST', params });
}
export function DepartmentDelete(params) {
return http.request({ url: '/imOrg/departmentDelete', method: 'POST', params });
}
export function MemberList(params) {
return http.request({ url: '/imOrg/memberList', method: 'GET', params });
}
export function MemberExport(params) {
jumpExport('/imOrg/memberExport', params);
}
export function MemberEdit(params) {
return http.request({ url: '/imOrg/memberEdit', method: 'POST', params });
}
export function MemberDelete(params) {
return http.request({ url: '/imOrg/memberDelete', method: 'POST', params });
}
export function Search(params) {
return http.request({ url: '/imOrg/search', method: 'GET', params });
}
+33
View File
@@ -0,0 +1,33 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({
url: '/imOutbox/list',
method: 'get',
params,
});
}
export function View(params) {
return http.request({
url: '/imOutbox/view',
method: 'GET',
params,
});
}
export function Retry(params) {
return http.request({
url: '/imOutbox/retry',
method: 'POST',
params,
});
}
export function Dead(params) {
return http.request({
url: '/imOutbox/dead',
method: 'POST',
params,
});
}
+17
View File
@@ -0,0 +1,17 @@
import { http, jumpExport } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imPrivateMessage/list', method: 'GET', params });
}
export function View(params) {
return http.request({ url: '/imPrivateMessage/view', method: 'GET', params });
}
export function Hide(params) {
return http.request({ url: '/imPrivateMessage/hide', method: 'POST', params });
}
export function Delete(params) {
return http.request({ url: '/imPrivateMessage/delete', method: 'POST', params });
}
export function Export(params) {
jumpExport('/imPrivateMessage/export', params);
}
+18
View File
@@ -0,0 +1,18 @@
import { http } from '@/utils/http/axios';
export const List = (params) =>
http.request({ url: '/imSecurityPolicy/list', method: 'GET', params });
export const View = (params) =>
http.request({ url: '/imSecurityPolicy/view', method: 'GET', params });
export const Save = (params) =>
http.request({ url: '/imSecurityPolicy/save', method: 'POST', params });
export const Publish = (params) =>
http.request({ url: '/imSecurityPolicy/publish', method: 'POST', params });
export const RestoreDefault = (params) =>
http.request({ url: '/imSecurityPolicy/restoreDefault', method: 'POST', params });
export const IPRuleList = (params) =>
http.request({ url: '/imSecurityPolicy/ipRuleList', method: 'GET', params });
export const IPRuleEdit = (params) =>
http.request({ url: '/imSecurityPolicy/ipRuleEdit', method: 'POST', params });
export const IPRuleDelete = (params) =>
http.request({ url: '/imSecurityPolicy/ipRuleDelete', method: 'POST', params });
+16
View File
@@ -0,0 +1,16 @@
import { http } from '@/utils/http/axios';
export const List = (params) =>
http.request({ url: '/imSensitiveWord/list', method: 'GET', params });
export const Edit = (params) =>
http.request({ url: '/imSensitiveWord/edit', method: 'POST', params });
export const Delete = (params) =>
http.request({ url: '/imSensitiveWord/delete', method: 'POST', params });
export const Status = (params) =>
http.request({ url: '/imSensitiveWord/status', method: 'POST', params });
export const BatchImport = (params) =>
http.request({ url: '/imSensitiveWord/batchImport', method: 'POST', params });
export const GroupList = (params) =>
http.request({ url: '/imSensitiveWord/groupList', method: 'GET', params });
export const GroupEdit = (params) =>
http.request({ url: '/imSensitiveWord/groupEdit', method: 'POST', params });
+5
View File
@@ -0,0 +1,5 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imTaskDashboard/list', method: 'GET', params });
}
+45 -6
View File
@@ -1,15 +1,21 @@
import { http } from '@/utils/http/axios';
// 获取IM租户列表
export function List(params) {
return http.request({
// 获取IM商户列表
export async function List(params) {
const res: any = await http.request({
url: '/imTenant/list',
method: 'get',
params,
});
if (Array.isArray(res?.list)) {
res.list = [...res.list].sort((left, right) => {
return Number(right.code === 'default') - Number(left.code === 'default');
});
}
return res;
}
// 添加/编辑IM租户
// 添加/编辑IM商户
export function Edit(params) {
return http.request({
url: '/imTenant/edit',
@@ -18,7 +24,7 @@ export function Edit(params) {
});
}
// 修改IM租户状态
// 修改IM商户状态
export function Status(params) {
return http.request({
url: '/imTenant/status',
@@ -27,7 +33,7 @@ export function Status(params) {
});
}
// 获取IM租户指定详情
// 获取IM商户指定详情
export function View(params) {
return http.request({
url: '/imTenant/view',
@@ -35,3 +41,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,
});
}
+89 -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) {
@@ -49,3 +66,74 @@ export function AccountCancel(params) {
export function AccountDelete(params) {
return http.request({ url: '/imUser/accountDelete', method: 'POST', 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,
});
}
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({
url: '/imUser/devices',
method: 'GET',
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 });
}
+8
View File
@@ -0,0 +1,8 @@
import { http, jumpExport } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imUserLoginHistory/list', method: 'GET', params });
}
export function Export(params) {
jumpExport('/imUserLoginHistory/export', params);
}
+22
View File
@@ -0,0 +1,22 @@
import { http } from '@/utils/http/axios';
export const List = (params) =>
http.request({ url: '/imUserPolicyGroup/list', method: 'GET', params });
export const View = (params) =>
http.request({ url: '/imUserPolicyGroup/view', method: 'GET', params });
export const Edit = (params) =>
http.request({ url: '/imUserPolicyGroup/edit', method: 'POST', params });
export const Delete = (params) =>
http.request({ url: '/imUserPolicyGroup/delete', method: 'POST', params });
export const Status = (params) =>
http.request({ url: '/imUserPolicyGroup/status', method: 'POST', params });
export const MemberList = (params) =>
http.request({ url: '/imUserPolicyGroup/memberList', method: 'GET', params });
export const MemberAdd = (params) =>
http.request({ url: '/imUserPolicyGroup/memberAdd', method: 'POST', params });
export const MemberRemove = (params) =>
http.request({ url: '/imUserPolicyGroup/memberRemove', method: 'POST', params });
export const LoginIPWhitelist = (params) =>
http.request({ url: '/imUserPolicyGroup/loginIPWhitelist', method: 'GET', params });
export const LoginIPWhitelistSave = (params) =>
http.request({ url: '/imUserPolicyGroup/loginIPWhitelistSave', method: 'POST', params });
+17
View File
@@ -0,0 +1,17 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imWorkbench/list', method: 'GET', params });
}
export function Edit(params) {
return http.request({ url: '/imWorkbench/edit', method: 'POST', params });
}
export function Delete(params) {
return http.request({ url: '/imWorkbench/delete', method: 'POST', params });
}
export function Reorder(params) {
return http.request({ url: '/imWorkbench/reorder', method: 'POST', params });
}
+1
View File
@@ -9,6 +9,7 @@ const LayoutMap = new Map<string, () => Promise<typeof import('*.vue')>>();
LayoutMap.set('LAYOUT', Layout);
LayoutMap.set('IFRAME', Iframe);
LayoutMap.set('ParentLayout', ParentLayout);
/**
* 格式化 后端 结构信息并递归生成层级路由表
@@ -1,10 +1,9 @@
<template>
<n-modal v-model:show="showModal" preset="card" title="批次详情" class="batch-view-modal">
<n-descriptions :column="4" bordered size="small">
<n-descriptions-item label="批次ID">{{ detail.batchId || '-' }}</n-descriptions-item>
<n-descriptions-item label="总数">{{ detail.quantity || 0 }}</n-descriptions-item>
<n-descriptions-item label="有效">{{ detail.activeCount || 0 }}</n-descriptions-item>
<n-descriptions-item label="已使用">{{ detail.usedCount || 0 }}</n-descriptions-item>
<n-descriptions-item label="累计注册次数">{{ detail.usageCount || 0 }}</n-descriptions-item>
<n-descriptions-item label="已撤销">{{ detail.revokedCount || 0 }}</n-descriptions-item>
<n-descriptions-item label="已过期">{{ detail.expiredCount || 0 }}</n-descriptions-item>
<n-descriptions-item label="到期时间">{{ detail.expiresAt || '-' }}</n-descriptions-item>
@@ -26,8 +25,18 @@
{ title: 'ID', key: 'id' },
{ title: '邀请码', key: 'code' },
{ title: '状态', key: 'status' },
{ title: '使用用户', key: 'usedBy' },
{ title: '使用时间', key: 'usedAt' },
{ title: '累计使用次数', key: 'usageCount' },
{
title: '可用次数',
key: 'availableUses',
render(row: any) {
if (!row.maxUses || Number(row.maxUses) <= 0) {
return '不限制';
}
return `${Math.max(Number(row.maxUses) - Number(row.usageCount || 0), 0)}`;
},
},
{ title: '最后使用时间', key: 'lastUsedAt' },
];
async function openModal(tenantId: number, batchId: number) {
@@ -47,4 +56,3 @@
width: min(1000px, 92vw);
}
</style>
@@ -0,0 +1,167 @@
<template>
<n-modal v-model:show="showModal" preset="card" title="绑定用户" class="bind-user-modal">
<n-form :model="searchForm" label-placement="left" label-width="90">
<n-grid :cols="2" :x-gap="12">
<n-form-item-gi label="用户ID">
<n-input v-model:value="searchForm.userId" clearable placeholder="用户ID/编号" />
</n-form-item-gi>
<n-form-item-gi label="关键词">
<n-input v-model:value="searchForm.keyword" clearable placeholder="用户名/昵称/手机号" />
</n-form-item-gi>
</n-grid>
<n-space>
<n-button type="primary" :loading="loading" @click="handleSearch">查询</n-button>
<n-button @click="handleReset">重置</n-button>
</n-space>
</n-form>
<n-data-table
class="user-table"
:columns="columns"
:data="users"
:loading="loading"
:row-key="(row) => row.userId"
:checked-row-keys="checkedRowKeys"
:pagination="pagination"
@update:checked-row-keys="handleChecked"
@update:page="handlePageChange"
/>
<template #footer>
<n-space justify="end">
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :disabled="!selectedUserId" :loading="submitting" @click="handleSubmit">
确认绑定
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, h, reactive, ref } from 'vue';
import { NAvatar, useMessage } from 'naive-ui';
import { List as UserList } from '@/api/imUser';
import { BindUser } from '@/api/im/registrationInvite';
import defaultAvatar from '@/assets/images/schoolboy.png';
const emit = defineEmits(['reload']);
const message = useMessage();
const showModal = ref(false);
const loading = ref(false);
const submitting = ref(false);
const tenantId = ref(0);
const inviteId = ref('');
const users = ref<any[]>([]);
const checkedRowKeys = ref<string[]>([]);
const searchForm = reactive({ userId: '', keyword: '' });
const pagination = reactive({ page: 1, pageSize: 10, itemCount: 0, showSizePicker: false });
const selectedUserId = computed(() => checkedRowKeys.value[0] || '');
const columns = [
{ type: 'selection', multiple: false },
{ title: '编号', key: 'displayNo', width: 90 },
{
title: '头像',
key: 'avatar',
width: 70,
render(row: any) {
return h(NAvatar, { size: 36, src: row.avatar || defaultAvatar });
},
},
{ title: '用户ID', key: 'userId', width: 130 },
{ title: '用户名', key: 'username', width: 140 },
{
title: '昵称',
key: 'nickname',
width: 140,
render(row: any) {
return row.nickname || '--';
},
},
{
title: '手机号',
key: 'phone',
width: 150,
render(row: any) {
return row.phone || '--';
},
},
];
async function loadUsers() {
loading.value = true;
try {
const res = await UserList({
tenantId: tenantId.value,
userId: searchForm.userId,
keyword: searchForm.keyword,
page: pagination.page,
perPage: pagination.pageSize,
});
users.value = res?.list || [];
pagination.itemCount = Number(res?.total || 0);
} finally {
loading.value = false;
}
}
function handleChecked(keys: string[]) {
checkedRowKeys.value = keys.slice(-1);
}
function handlePageChange(page: number) {
pagination.page = page;
loadUsers();
}
function handleSearch() {
pagination.page = 1;
checkedRowKeys.value = [];
loadUsers();
}
function handleReset() {
searchForm.userId = '';
searchForm.keyword = '';
handleSearch();
}
async function handleSubmit() {
if (!selectedUserId.value) {
message.warning('请选择要绑定的用户');
return;
}
submitting.value = true;
try {
await BindUser({ tenantId: tenantId.value, id: inviteId.value, userId: Number(selectedUserId.value) });
message.success('绑定成功');
showModal.value = false;
emit('reload');
} finally {
submitting.value = false;
}
}
function openModal(rowTenantId: number, rowInviteId: string) {
tenantId.value = rowTenantId;
inviteId.value = rowInviteId;
searchForm.userId = '';
searchForm.keyword = '';
users.value = [];
checkedRowKeys.value = [];
pagination.page = 1;
pagination.itemCount = 0;
showModal.value = true;
}
defineExpose({ openModal });
</script>
<style lang="less" scoped>
.bind-user-modal {
width: min(980px, 94vw);
}
.user-table {
margin-top: 16px;
}
</style>
+57 -17
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="邀请码管理" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@@ -22,19 +22,30 @@
>
<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>
</n-card>
<CreateModal ref="createRef" @reload="reloadTable" />
<BatchViewModal ref="batchViewRef" />
<BindUserModal ref="bindUserRef" @reload="reloadTable" />
</div>
</template>
@@ -45,7 +56,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,
@@ -59,6 +70,7 @@
import { adaTableScrollX } from '@/utils/hotgo';
import CreateModal from './CreateModal.vue';
import BatchViewModal from './BatchViewModal.vue';
import BindUserModal from './BindUserModal.vue';
const message = useMessage();
const dialog = useDialog();
@@ -68,6 +80,7 @@
const searchFormRef = ref<any>({});
const createRef = ref();
const batchViewRef = ref();
const bindUserRef = ref();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const tableColumns = computed(() =>
@@ -88,7 +101,7 @@
);
const actionColumn = reactive({
width: 220,
width: 380,
title: '操作',
key: 'action',
fixed: 'right',
@@ -96,6 +109,12 @@
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '绑定用户',
onClick: handleBindUser.bind(null, record),
auth: ['/imInvite/bindUser'],
ifShow: () => record.status === 'active' && !record.boundUserId && Number(record.usageCount || 0) === 0,
},
{
label: '批次',
onClick: handleBatchView.bind(null, record),
@@ -109,6 +128,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',
},
],
});
},
@@ -124,9 +150,6 @@
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);
};
@@ -144,14 +167,18 @@
function handleCreate(batch: boolean) {
const tenantId = selectedTenantId();
if (isPlatformAdmin.value && !tenantId) {
message.warning('请先选择租户');
message.warning('请先选择商户');
return;
}
createRef.value.openModal(tenantId, batch);
}
function handleBatchView(record: State) {
batchViewRef.value.openModal(selectedTenantId(), record.batchId);
batchViewRef.value.openModal(Number(record.tenantId || selectedTenantId()), record.batchId);
}
function handleBindUser(record: State) {
bindUserRef.value.openModal(Number(record.tenantId || selectedTenantId()), record.id);
}
function handleRevoke(record: State) {
@@ -161,19 +188,32 @@
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Revoke({ tenantId: selectedTenantId(), id: record.id });
await Revoke({ tenantId: Number(record.tenantId || selectedTenantId()), id: record.id });
message.success('撤销成功');
reloadTable();
},
});
}
function handleBatchRevoke(record: State) {
dialog.warning({
title: '确认撤销批次',
content: '将撤销该批次下仍有效的邀请码,已过期或已撤销的邀请码不受影响,是否确认?',
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 };
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请先选择租户');
return;
}
const response: any = await Export(params);
const blob = response?.data || response;
const url = URL.createObjectURL(blob);
@@ -191,7 +231,7 @@
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用租户');
message.warning('暂无可用商户');
}
}
reloadTable();
+38 -19
View File
@@ -10,13 +10,17 @@ export const tenantOptions = ref<any[]>([]);
export class State {
public id = '';
public tenantId = '';
public tenantName = '';
public batchId = '';
public code = '';
public status = '';
public expiresAt = '';
public usedBy = '';
public usedAt = '';
public requestId = '';
public maxUses = 0;
public usageCount = 0;
public boundUserId = '';
public boundUserLabel = '';
public lastUsedAt = '';
public createdBy = '';
public remark = '';
public createdAt = '';
@@ -24,7 +28,6 @@ export class State {
export const statusOptions = [
{ label: '有效', value: 'active', type: 'success' },
{ label: '已使用', value: 'used', type: 'info' },
{ label: '已过期', value: 'expired', type: 'warning' },
{ label: '已撤销', value: 'revoked', type: 'error' },
];
@@ -33,11 +36,11 @@ export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
label: '商户',
componentProps: {
clearable: false,
clearable: true,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
placeholder: '请选择商户',
},
},
{
@@ -59,14 +62,6 @@ export const schemas = ref<FormSchema[]>([
placeholder: '请选择状态',
},
},
{
field: 'batchId',
component: 'NInput',
label: '批次ID',
componentProps: {
placeholder: '请输入批次ID',
},
},
{
field: 'createdBy',
component: 'NInput',
@@ -99,16 +94,33 @@ export const schemas = ref<FormSchema[]>([
export const columns = [
{ title: 'ID', key: 'id', align: 'left', width: 150 },
{ title: '商户', key: 'tenantName', align: 'left', width: 160 },
{ title: '邀请码', key: 'code', align: 'left', width: 120 },
{ title: '状态', key: 'status', align: 'left', width: 110 },
{ title: '批次ID', key: 'batchId', align: 'left', width: 150 },
{ title: '到期时间', key: 'expiresAt', align: 'left', width: 190 },
{ title: '使用用户', key: 'usedBy', align: 'left', width: 120 },
{ title: '使用时间', key: 'usedAt', align: 'left', width: 190 },
{ title: '累计使用次数', key: 'usageCount', align: 'left', width: 130 },
{
title: '可用次数',
key: 'availableUses',
align: 'left',
width: 110,
render(row: State) {
return inviteAvailableUsesLabel(row);
},
},
{
title: '绑定用户',
key: 'boundUserLabel',
align: 'left',
width: 180,
render(row: State) {
return row.boundUserLabel || row.boundUserId || '--';
},
},
{ title: '最后使用时间', key: 'lastUsedAt', align: 'left', width: 190 },
{ title: '创建人', key: 'createdBy', align: 'left', width: 110 },
{ title: '创建时间', key: 'createdAt', align: 'left', width: 190 },
{ title: '备注', key: 'remark', align: 'left', width: 220 },
{ title: '请求ID', key: 'requestId', align: 'left', width: 220 },
];
export function loadOptions() {
@@ -128,6 +140,13 @@ export function statusLabel(status: string) {
return statusOptions.find((item) => item.value === status)?.label || status;
}
export function inviteAvailableUsesLabel(row: State) {
if (!row.maxUses || Number(row.maxUses) <= 0) {
return '不限制';
}
return `${Math.max(Number(row.maxUses) - Number(row.usageCount || 0), 0)}`;
}
export function localRfc3339(value: number | null) {
if (!value) {
return '';
+261
View File
@@ -0,0 +1,261 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM举报原因" />
</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.targetType + '-' + row.reasonCode"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle>
<n-button
type="primary"
:disabled="!hasPermission(['/imAbuseReason/edit']) || !currentTenantReady"
@click="handleCreate"
>
新增原因
</n-button>
</template>
</BasicTable>
</n-card>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="formValue.id > 0 ? '编辑举报原因' : '新增举报原因'"
:style="{ width: dialogWidth }"
>
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="92"
>
<n-form-item label="对象" path="targetType">
<n-select
v-model:value="formValue.targetType"
:disabled="formValue.id > 0"
:options="targetTypeOptions"
placeholder="请选择对象类型"
/>
</n-form-item>
<n-form-item label="编码" path="reasonCode">
<n-input
v-model:value="formValue.reasonCode"
:disabled="formValue.id > 0"
placeholder="请输入原因编码"
/>
</n-form-item>
<n-form-item label="文案" path="label">
<n-input v-model:value="formValue.label" placeholder="请输入展示文案" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="formValue.sortOrder" :min="0" />
</n-form-item>
<n-form-item label="要求描述">
<n-switch v-model:value="requiresDescriptionValue" />
</n-form-item>
<n-form-item label="允许证据">
<n-switch v-model:value="allowsEvidenceValue" />
</n-form-item>
<n-form-item label="状态">
<n-switch v-model:value="enabledValue" />
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { Delete, Edit, List } from '@/api/imAbuseReason';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
columns,
loadOptions,
loadTenantOptions,
newState,
rules,
schemas,
State,
tenantOptions,
} from './model';
const dict = useDictStore();
const dialog = useDialog();
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const formRef = ref();
const showModal = ref(false);
const submitLoading = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(560));
const targetTypeOptions = dict.getOption('ImAbuseTargetTypeOptions');
const currentTenantReady = computed(
() => !isPlatformAdmin.value || !!searchFormRef.value?.formModel?.tenantId
);
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const requiresDescriptionValue = computed({
get: () => formValue.value.requiresDescription === 1,
set: (value: boolean) => {
formValue.value.requiresDescription = value ? 1 : 0;
},
});
const allowsEvidenceValue = computed({
get: () => formValue.value.allowsEvidence === 1,
set: (value: boolean) => {
formValue.value.allowsEvidence = value ? 1 : 0;
},
});
const enabledValue = computed({
get: () => formValue.value.enabled === 1,
set: (value: boolean) => {
formValue.value.enabled = value ? 1 : 0;
},
});
const actionColumn = reactive({
width: 150,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imAbuseReason/edit'],
onClick: () => handleEdit(record),
},
{
label: '禁用',
auth: ['/imAbuseReason/delete'],
disabled: record.enabled === 0,
onClick: () => handleDelete(record),
},
],
});
},
});
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 };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
await setFieldsValue(defaults);
reloadTable();
}
function handleCreate() {
formValue.value = newState({
tenantId: searchFormRef.value?.formModel?.tenantId || 0,
targetType: 'message',
requiresDescription: 0,
allowsEvidence: 1,
enabled: 1,
});
showModal.value = true;
}
function handleEdit(record: State) {
formValue.value = newState(record);
showModal.value = true;
}
function handleDelete(record: State) {
dialog.warning({
title: '确认禁用',
content: `确定禁用举报原因「${record.label}」吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
await Delete({ tenantId: record.tenantId, id: record.id });
message.success('操作成功');
reloadTable();
},
});
}
async function handleSubmit() {
submitLoading.value = true;
try {
await formRef.value?.validate();
await Edit({ ...formValue.value });
message.success('操作成功');
showModal.value = false;
reloadTable();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用商户');
}
}
reloadTable();
});
</script>
+149
View File
@@ -0,0 +1,149 @@
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 tenantId = 0;
public tenantName = '';
public id = 0;
public targetType = 'message';
public reasonCode = '';
public label = '';
public requiresDescription = 0;
public allowsEvidence = 1;
public sortOrder = 0;
public enabled = 1;
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: 'targetType',
component: 'NSelect',
label: '对象',
componentProps: {
clearable: true,
options: dict.getOption('ImAbuseTargetTypeOptions'),
placeholder: '请选择对象类型',
},
},
{
field: 'reasonCode',
component: 'NInput',
label: '编码',
componentProps: {
clearable: true,
placeholder: '请输入原因编码',
},
},
{
field: 'enabled',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: dict.getOption('ImAbuseReasonEnabledOptions'),
placeholder: '请选择状态',
},
},
]);
export const columns = [
{
title: '对象',
key: 'targetType',
align: 'left',
width: 100,
render(row: State) {
return dict.getLabel('ImAbuseTargetTypeOptions', row.targetType) || row.targetType;
},
},
{ title: '编码', key: 'reasonCode', align: 'left', width: 140 },
{ title: '文案', key: 'label', align: 'left', width: 160 },
{
title: '要求描述',
key: 'requiresDescription',
align: 'left',
width: 100,
render(row: State) {
return dict.getLabel('ImAbuseReasonEnabledOptions', row.requiresDescription);
},
},
{
title: '允许证据',
key: 'allowsEvidence',
align: 'left',
width: 100,
render(row: State) {
return dict.getLabel('ImAbuseReasonEnabledOptions', row.allowsEvidence);
},
},
{ title: '排序', key: 'sortOrder', align: 'left', width: 90 },
{
title: '状态',
key: 'enabled',
align: 'left',
width: 90,
render(row: State) {
return dict.getLabel('ImAbuseReasonEnabledOptions', row.enabled);
},
},
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const rules = {
targetType: {
required: true,
trigger: ['blur', 'change'],
type: 'string',
message: '请选择对象类型',
},
reasonCode: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入原因编码',
},
label: { required: true, trigger: ['blur', 'input'], type: 'string', message: '请输入展示文案' },
};
export function loadOptions() {
dict.loadOptions(['ImAbuseTargetTypeOptions', 'ImAbuseReasonEnabledOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+13
View File
@@ -0,0 +1,13 @@
import { ref } from 'vue';
import { List as TenantList } from '@/api/imTenant';
export const imQueryTenantOptions = ref<any[]>([]);
export async function loadImQueryTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
imQueryTenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return imQueryTenantOptions.value;
}
+215
View File
@@ -0,0 +1,215 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM全局版本更新" />
</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.platform + '-' + row.channel"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle>
<n-button
type="primary"
:disabled="!hasPermission(['/imAppRelease/edit'])"
@click="handleCreate"
>
新增策略
</n-button>
</template>
</BasicTable>
</n-card>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="formValue.id > 0 ? '编辑版本策略' : '新增版本策略'"
:style="{ width: dialogWidth }"
>
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="112"
>
<n-form-item label="平台" path="platform">
<n-select
v-model:value="formValue.platform"
:disabled="formValue.id > 0"
:options="platformOptions"
placeholder="请选择平台"
/>
</n-form-item>
<n-form-item label="渠道" path="channel">
<n-input
v-model:value="formValue.channel"
:disabled="formValue.id > 0"
placeholder="请输入渠道"
/>
</n-form-item>
<n-form-item label="最新版本" path="latestVersion">
<n-input v-model:value="formValue.latestVersion" placeholder="请输入最新版本" />
</n-form-item>
<n-form-item label="最低支持版本" path="minSupportedVersion">
<n-input v-model:value="formValue.minSupportedVersion" placeholder="请输入最低支持版本" />
</n-form-item>
<n-form-item label="更新类型" path="updateType">
<n-select
v-model:value="formValue.updateType"
:options="updateTypeOptions"
placeholder="请选择更新类型"
/>
</n-form-item>
<n-form-item label="再次提醒秒数">
<n-input-number v-model:value="formValue.remindAfterSeconds" :min="0" />
</n-form-item>
<n-form-item label="商店地址">
<n-input v-model:value="formValue.storeUrl" placeholder="请输入商店地址" />
</n-form-item>
<n-form-item label="安装包地址">
<n-input v-model:value="formValue.packageUrl" placeholder="请输入安装包地址" />
</n-form-item>
<n-form-item label="校验值">
<n-input v-model:value="formValue.checksum" placeholder="请输入安装包校验值" />
</n-form-item>
<n-form-item label="更新说明">
<n-input
v-model:value="formValue.releaseNotes"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="请输入更新说明"
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { Edit, List } from '@/api/imAppRelease';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
columns,
loadOptions,
newState,
rules,
schemas,
State,
} from './model';
const dict = useDictStore();
const message = useMessage();
const { hasPermission } = usePermission();
const actionRef = ref();
const searchFormRef = ref<any>({});
const formRef = ref();
const showModal = ref(false);
const submitLoading = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(720));
const platformOptions = dict.getOption('ImAppPlatformOptions');
const updateTypeOptions = dict.getOption('ImAppUpdateTypeOptions');
const actionColumn = reactive({
width: 100,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imAppRelease/edit'],
onClick: () => handleEdit(record),
},
],
});
},
});
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 };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
await setFieldsValue({});
reloadTable();
}
function handleCreate() {
formValue.value = newState({
platform: 'android',
channel: 'stable',
latestVersion: '1.0.0',
minSupportedVersion: '1.0.0',
updateType: 'none',
remindAfterSeconds: 0,
});
showModal.value = true;
}
function handleEdit(record: State) {
formValue.value = newState(record);
showModal.value = true;
}
async function handleSubmit() {
submitLoading.value = true;
try {
await formRef.value?.validate();
await Edit({ ...formValue.value });
message.success('操作成功');
showModal.value = false;
reloadTable();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
onMounted(async () => {
loadOptions();
reloadTable();
});
</script>
+112
View File
@@ -0,0 +1,112 @@
import { ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { FormSchema } from '@/components/Form';
import { useDictStore } from '@/store/modules/dict';
const dict = useDictStore();
export class State {
public id = 0;
public platform = 'android';
public channel = 'stable';
public latestVersion = '1.0.0';
public minSupportedVersion = '1.0.0';
public updateType = 'none';
public releaseNotes = '';
public storeUrl = '';
public packageUrl = '';
public checksum = '';
public remindAfterSeconds = 0;
public publishedAt = '';
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: 'platform',
component: 'NSelect',
label: '平台',
componentProps: {
clearable: true,
options: dict.getOption('ImAppPlatformOptions'),
placeholder: '请选择平台',
},
},
{
field: 'channel',
component: 'NInput',
label: '渠道',
componentProps: {
clearable: true,
placeholder: '请输入渠道',
},
},
]);
export const columns = [
{
title: '平台',
key: 'platform',
align: 'left',
width: 100,
render(row: State) {
return dict.getLabel('ImAppPlatformOptions', row.platform) || row.platform;
},
},
{ title: '渠道', key: 'channel', align: 'left', width: 100 },
{ title: '最新版本', key: 'latestVersion', align: 'left', width: 120 },
{ title: '最低支持', key: 'minSupportedVersion', align: 'left', width: 120 },
{
title: '更新类型',
key: 'updateType',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImAppUpdateTypeOptions', row.updateType) || row.updateType;
},
},
{ title: '再次提醒', key: 'remindAfterSeconds', align: 'left', width: 110 },
{ title: '商店地址', key: 'storeUrl', align: 'left', width: 220 },
{ title: '安装包地址', key: 'packageUrl', align: 'left', width: 220 },
{ title: '发布时间', key: 'publishedAt', align: 'left', width: 180 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const rules = {
platform: { required: true, trigger: ['blur', 'change'], type: 'string', message: '请选择平台' },
channel: { required: true, trigger: ['blur', 'input'], type: 'string', message: '请输入渠道' },
latestVersion: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入最新版本',
},
minSupportedVersion: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入最低支持版本',
},
updateType: {
required: true,
trigger: ['blur', 'change'],
type: 'string',
message: '请选择更新类型',
},
};
export function loadOptions() {
dict.loadOptions(['ImAppPlatformOptions', 'ImAppUpdateTypeOptions']);
}
+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>
+2 -2
View File
@@ -41,11 +41,11 @@ export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
label: '商户',
componentProps: {
clearable: false,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
placeholder: '请选择商户',
},
},
{
+1 -1
View File
@@ -12,7 +12,7 @@
<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.tenantName }}</n-descriptions-item>
<n-descriptions-item label="商户">{{ formValue.tenantName }}</n-descriptions-item>
<n-descriptions-item label="操作时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="操作人UID">{{ formValue.actorUid }}</n-descriptions-item>
<n-descriptions-item label="角色">{{ formValue.actorRole || '-' }}</n-descriptions-item>
+106
View File
@@ -0,0 +1,106 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="数据概览">
<template #header-extra>
<n-space align="center">
<n-select
v-if="isPlatformAdmin"
v-model:value="tenantId"
:options="imQueryTenantOptions"
class="tenant-select"
placeholder="请选择商户"
/>
<n-select v-model:value="days" :options="dayOptions" class="days-select" />
<n-button type="primary" :loading="loading" @click="loadDashboard">刷新</n-button>
</n-space>
</template>
</n-card>
</div>
<n-card :bordered="false" class="proCard">
<n-grid cols="1 s:2 m:3" responsive="screen" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in metrics" :key="item.key">
<n-statistic :label="item.label" :value="overview[item.key] || 0" />
</n-grid-item>
</n-grid>
<n-divider />
<n-h3>最近 {{ days }} 天增长趋势</n-h3>
<n-data-table
:columns="trendColumns"
:data="trends"
:loading="loading"
:pagination="false"
:bordered="false"
/>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { useUserStore } from '@/store/modules/user';
import { Overview, Trends } from '@/api/imDashboard';
import { imQueryTenantOptions, loadImQueryTenantOptions } from '@/views/imAdminQueryShared';
const message = useMessage();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const tenantId = ref(0);
const days = ref(7);
const loading = ref(false);
const overview = ref<Recordable>({});
const trends = ref<any[]>([]);
const dayOptions = [
{ label: '最近7天', value: 7 },
{ label: '最近14天', value: 14 },
{ label: '最近30天', value: 30 },
];
const metrics = [
{ key: 'totalUsers', label: '总用户数' },
{ key: 'activeUsers', label: '活跃用户' },
{ key: 'totalGroups', label: '总群组数' },
{ key: 'totalConversations', label: '总会话数' },
{ key: 'totalMessages', label: '总消息数' },
{ key: 'todayMessages', label: '今日消息' },
];
const trendColumns = [
{ title: '日期', key: 'date' },
{ title: '新增用户', key: 'userCount' },
{ title: '新增消息', key: 'messageCount' },
];
async function loadDashboard() {
if (isPlatformAdmin.value && !tenantId.value) {
message.warning('请选择商户');
return;
}
loading.value = true;
try {
const params = { tenantId: tenantId.value, days: days.value };
const [summary, trendRes] = await Promise.all([Overview(params), Trends(params)]);
overview.value = summary || {};
trends.value = trendRes?.list || [];
} finally {
loading.value = false;
}
}
onMounted(async () => {
if (isPlatformAdmin.value) {
await loadImQueryTenantOptions();
tenantId.value = imQueryTenantOptions.value[0]?.value || 0;
if (!tenantId.value) message.warning('暂无可用商户');
}
if (!isPlatformAdmin.value || tenantId.value) await loadDashboard();
});
</script>
<style lang="less" scoped>
.tenant-select {
width: 240px;
}
.days-select {
width: 120px;
}
</style>
+18 -5
View File
@@ -1,7 +1,7 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM Kafka死信"></n-card>
<n-card :bordered="false" title="IM Kafka死信" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@@ -30,7 +30,7 @@
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 { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
@@ -48,7 +48,13 @@
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record, currentTenantId.value), auth: ['/imDeadLetter/view'] }],
actions: [
{
label: '详情',
onClick: () => viewRef.value.openModal(record, currentTenantId.value),
auth: ['/imDeadLetter/view'],
},
],
});
},
});
@@ -56,7 +62,7 @@
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const currentTenantId = computed(() => searchFormRef.value?.formModel?.tenantId || 0);
const [register] = useForm({
const [register, { setFieldsValue }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
@@ -66,7 +72,11 @@
if (!currentTenantId.value) {
return { list: [], totalCount: 0 };
}
const params = { ...searchFormRef.value?.formModel, limit: res.pageSize || res.perPage || 50, cursor: cursor.value };
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 || '';
@@ -82,6 +92,9 @@
onMounted(async () => {
loadOptions();
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
}
});
</script>
+3 -3
View File
@@ -46,11 +46,11 @@ export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
label: '商户',
componentProps: {
clearable: true,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
placeholder: '请选择商户',
},
},
{
@@ -59,7 +59,7 @@ export const schemas = ref<FormSchema[]>([
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImKafkaDeadLetterStatusOptions')),
options: dict.getOption('ImKafkaDeadLetterStatusOptions'),
placeholder: '请选择状态',
},
},
+118
View File
@@ -0,0 +1,118 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'外部服务配置 - ' + serviceLabel"
:style="{ width: dialogWidth }"
>
<n-form ref="formRef" :model="formValue" label-placement="left" label-width="90">
<n-form-item label="服务商">
<n-input v-model:value="formValue.provider" placeholder="请输入服务商" />
</n-form-item>
<n-form-item label="端点">
<n-input v-model:value="formValue.endpoint" placeholder="请输入服务端点" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="enabledValue" />
</n-form-item>
<n-form-item label="普通配置">
<n-input
v-model:value="formValue.configJson"
type="textarea"
:autosize="{ minRows: 5, maxRows: 10 }"
placeholder="请输入JSON配置"
/>
</n-form-item>
<n-form-item label="敏感配置">
<n-input
v-model:value="formValue.secretJson"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="留空则保持原敏感配置"
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="closeForm">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { adaModalWidth } from '@/utils/hotgo';
import { newState, State } from './model';
import { Update } from '@/api/imExternalConfig';
const emit = defineEmits(['reloadTable']);
const message = useMessage();
const dict = useDictStore();
const showModal = ref(false);
const submitLoading = ref(false);
const formRef = ref();
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(760));
const serviceLabel = computed(
() =>
dict.getLabel('ImExternalServiceOptions', formValue.value.service) || formValue.value.service
);
const enabledValue = computed({
get: () => formValue.value.enabled === 1,
set: (value: boolean) => {
formValue.value.enabled = value ? 1 : 0;
},
});
function closeForm() {
showModal.value = false;
}
function parseJson(value: string, field: string) {
const text = (value || '').trim();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
throw new Error(`${field}不是合法JSON`);
}
}
async function handleSubmit() {
submitLoading.value = true;
try {
const configJson = parseJson(formValue.value.configJson || '{}', '普通配置') || {};
const secretJson = parseJson(formValue.value.secretJson, '敏感配置');
await Update({
tenantId: formValue.value.tenantId,
service: formValue.value.service,
provider: formValue.value.provider,
endpoint: formValue.value.endpoint,
enabled: formValue.value.enabled,
configJson,
secretJson,
});
message.success('操作成功');
closeForm();
emit('reloadTable');
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
function openModal(state: State) {
formValue.value = newState(state);
formValue.value.secretJson = '';
if (!formValue.value.configJson) formValue.value.configJson = '{}';
showModal.value = true;
}
defineExpose({ openModal });
</script>
+133
View File
@@ -0,0 +1,133 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM外部服务配置" />
</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.service"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<EditModal ref="editRef" @reload-table="reloadTable" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { Check, List } from '@/api/imExternalConfig';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import EditModal from './edit.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 editRef = ref();
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const actionColumn = reactive({
width: 170,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imExternalConfig/update'],
onClick: () => editRef.value.openModal(record),
},
{
label: '检查',
type: 'warning',
auth: ['/imExternalConfig/check'],
onClick: () => handleCheck(record),
},
],
});
},
});
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 };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
await setFieldsValue(defaults);
reloadTable();
}
function handleCheck(record: State) {
dialog.warning({
title: '检查配置',
content: '将执行基础配置完整性检查,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Check({ tenantId: record.tenantId, service: record.service });
message.success('操作成功');
reloadTable();
},
});
}
onMounted(async () => {
loadOptions();
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>
+138
View File
@@ -0,0 +1,138 @@
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 tenantId = 0;
public tenantName = '';
public id = 0;
public service = '';
public provider = '';
public endpoint = '';
public enabled = 0;
public status = '';
public configJson = '{}';
public secretJson = '';
public hasSecret = false;
public checkedAt = '';
public checkResult = '';
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: 'service',
component: 'NSelect',
label: '服务',
componentProps: {
clearable: true,
options: dict.getOption('ImExternalServiceOptions'),
placeholder: '请选择服务',
},
},
{
field: 'provider',
component: 'NInput',
label: '服务商',
componentProps: { placeholder: '请输入服务商' },
},
{
field: 'enabled',
component: 'NSelect',
label: '启用',
componentProps: {
clearable: true,
options: [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 },
],
placeholder: '请选择启用状态',
},
},
]);
export const columns = [
{
title: '服务',
key: 'service',
align: 'left',
width: 130,
render(row: State) {
return dict.getLabel('ImExternalServiceOptions', row.service);
},
},
{ title: '服务商', key: 'provider', align: 'left', width: 140 },
{ title: '端点', key: 'endpoint', align: 'left', width: 220 },
{
title: '启用',
key: 'enabled',
align: 'left',
width: 90,
render(row: State) {
return row.enabled ? '启用' : '停用';
},
},
{
title: '配置状态',
key: 'status',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImExternalConfigStatusOptions', row.status);
},
},
{
title: '敏感配置',
key: 'hasSecret',
align: 'left',
width: 110,
render(row: State) {
return row.hasSecret ? '已配置' : '未配置';
},
},
{ title: '检查结果', key: 'checkResult', align: 'left', width: 220 },
{ title: '检查时间', key: 'checkedAt', align: 'left', width: 180 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImExternalServiceOptions', 'ImExternalConfigStatusOptions']);
}
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;
}
+140
View File
@@ -0,0 +1,140 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM意见反馈配置" />
</div>
<n-card :bordered="false" class="proCard">
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="120"
>
<n-form-item v-if="isPlatformAdmin" label="商户">
<n-select
v-model:value="selectedTenantId"
:options="tenantOptions"
placeholder="请选择商户"
@update:value="loadConfig"
/>
</n-form-item>
<n-form-item label="允许联系方式">
<n-switch v-model:value="allowContactValue" />
</n-form-item>
<n-form-item label="最大字数" path="maxLength">
<n-input-number v-model:value="formValue.maxLength" :min="1" />
</n-form-item>
<n-form-item label="反馈分类">
<n-input
v-model:value="formValue.categories"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="请输入JSON数组"
/>
</n-form-item>
<n-form-item label="附件策略">
<n-input
v-model:value="formValue.attachmentPolicy"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="请输入JSON对象"
/>
</n-form-item>
<n-form-item label="诊断提示" path="diagnosticsNotice">
<n-input
v-model:value="formValue.diagnosticsNotice"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="请输入诊断提示"
/>
</n-form-item>
<n-form-item>
<n-space>
<n-button
type="primary"
:loading="submitLoading"
:disabled="!hasPermission(['/imFeedbackConfig/update']) || !currentTenantReady"
@click="handleSubmit"
>
保存
</n-button>
<n-button :loading="loadLoading" @click="loadConfig">刷新</n-button>
</n-space>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { Get, Update } from '@/api/imFeedbackConfig';
import { loadOptions, loadTenantOptions, newState, rules, State, tenantOptions } from './model';
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const formRef = ref();
const formValue = ref<State>(newState(null));
const selectedTenantId = ref(0);
const loadLoading = ref(false);
const submitLoading = ref(false);
const currentTenantReady = computed(() => !isPlatformAdmin.value || selectedTenantId.value > 0);
const allowContactValue = computed({
get: () => formValue.value.allowContact === 1,
set: (value: boolean) => {
formValue.value.allowContact = value ? 1 : 0;
},
});
async function loadConfig() {
if (!currentTenantReady.value) return;
loadLoading.value = true;
try {
const data = await Get({ tenantId: selectedTenantId.value });
formValue.value = newState(data || null);
formValue.value.tenantId = selectedTenantId.value;
} catch (e: any) {
message.error(e.message || '加载失败');
} finally {
loadLoading.value = false;
}
}
async function handleSubmit() {
submitLoading.value = true;
try {
await formRef.value?.validate();
const categories = JSON.parse(formValue.value.categories || '[]');
const attachmentPolicy = JSON.parse(formValue.value.attachmentPolicy || '{}');
await Update({
...formValue.value,
tenantId: selectedTenantId.value,
categories,
attachmentPolicy,
});
message.success('操作成功');
await loadConfig();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) selectedTenantId.value = tenantOptions.value[0].value;
else message.warning('暂无可用商户');
}
await loadConfig();
});
</script>
+58
View File
@@ -0,0 +1,58 @@
import { ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { List as TenantList } from '@/api/imTenant';
import { useDictStore } from '@/store/modules/dict';
export const tenantOptions = ref<any[]>([]);
export class State {
public tenantId = 0;
public tenantName = '';
public id = 1;
public categories = '["bug","suggestion","complaint","other"]';
public maxLength = 1000;
public attachmentPolicy = '{"max_files":5,"purpose":"feedback"}';
public allowContact = 1;
public diagnosticsNotice = '仅在你同意时上传脱敏诊断信息。';
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 rules = {
diagnosticsNotice: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入诊断提示',
},
maxLength: {
required: true,
trigger: ['blur', 'input'],
type: 'number',
message: '请输入最大字数',
},
};
export function loadOptions() {
useDictStore().loadOptions(['ImFilePolicySwitchOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+191
View File
@@ -0,0 +1,191 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM文件治理" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="handleReset"
@keyup.enter="reloadTable"
/>
<n-space class="table-toolbar">
<n-button
v-if="hasPermission(['/imFile/cleanupOrphan'])"
type="warning"
@click="handleCleanupOrphan"
>
清理孤儿文件
</n-button>
</n-space>
<BasicTable
ref="actionRef"
:columns="visibleColumns"
:request="loadDataTable"
:row-key="(row) => row.tenantId + '-' + 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 { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { CleanupOrphan, Delete, List, Quarantine } from '@/api/imFile';
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 visibleColumns = computed(() =>
isPlatformAdmin.value ? columns : columns.filter((column) => column.key !== 'location')
);
const actionColumn = reactive({
width: 220,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '详情',
auth: ['/imFile/view'],
onClick: () => viewRef.value.openModal(record),
},
{
label: '隔离',
type: 'warning',
ifShow: () => record.status === 1,
auth: ['/imFile/quarantine'],
onClick: () => handleQuarantine(record),
},
{
label: '删除',
type: 'error',
ifShow: () => record.status !== 3,
auth: ['/imFile/delete'],
onClick: () => handleDelete(record),
},
],
});
},
});
const scrollX = computed(() => adaTableScrollX(visibleColumns.value, 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();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
await setFieldsValue(defaults);
reloadTable();
}
function handleQuarantine(record: State) {
dialog.warning({
title: '隔离文件',
content: '隔离后客户端将无法按正常文件状态访问该资源,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Quarantine({ tenantId: record.tenantId, id: record.id, reason: '后台管理员隔离' });
message.success('操作成功');
reloadTable();
},
});
}
function handleDelete(record: State) {
dialog.error({
title: '删除文件',
content: '删除会把文件标记为已删除,后续由清理任务处理物理对象,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Delete({ tenantId: record.tenantId, id: record.id, reason: '后台管理员删除' });
message.success('操作成功');
reloadTable();
},
});
}
function handleCleanupOrphan() {
const params = searchFormRef.value?.formModel || {};
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请先选择商户');
return;
}
dialog.warning({
title: '清理孤儿文件',
content: '将标记7天前无引用的文件为已删除,单次最多100个,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
const res = await CleanupOrphan({ tenantId: params.tenantId, days: 7, limit: 100 });
message.success(`已标记 ${res?.count || 0} 个孤儿文件`);
reloadTable();
},
});
}
onMounted(async () => {
loadOptions();
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>
.table-toolbar {
margin-bottom: 12px;
}
</style>
+161
View File
@@ -0,0 +1,161 @@
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 id = 0;
public createdAt = '';
public updatedAt = '';
public userId = 0;
public status = 0;
public mimeType = '';
public size = 0;
public etag = '';
public location = '';
public purpose = '';
public linkCount = 0;
public firstTopic = '';
public firstMsgId = 0;
public firstLinkAt = '';
public links: any[] = [];
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: 'fileId',
component: 'NInput',
label: '文件ID',
componentProps: { placeholder: '请输入文件ID' },
},
{
field: 'userId',
component: 'NInput',
label: '上传用户',
componentProps: { placeholder: '请输入上传用户ID' },
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: dict.getOption('ImFileStatusOptions'),
placeholder: '请选择状态',
},
},
{
field: 'purpose',
component: 'NSelect',
label: '用途',
componentProps: {
clearable: true,
options: dict.getOption('ImFilePurposeOptions'),
placeholder: '请选择用途',
},
},
{
field: 'mimeType',
component: 'NInput',
label: 'MIME',
componentProps: { placeholder: '请输入MIME类型' },
},
{
field: 'topic',
component: 'NInput',
label: 'Topic',
componentProps: { placeholder: '请输入关联Topic' },
},
{
field: 'updatedAt',
component: 'NDatePicker',
label: '更新时间',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
},
},
]);
export const columns = [
{ title: '文件ID', key: 'id', align: 'left', width: 180 },
{
title: '状态',
key: 'status',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImFileStatusOptions', row.status);
},
},
{
title: '用途',
key: 'purpose',
align: 'left',
width: 120,
render(row: State) {
return dict.getLabel('ImFilePurposeOptions', row.purpose) || row.purpose;
},
},
{ title: '上传用户', key: 'userId', align: 'left', width: 120 },
{ title: 'MIME', key: 'mimeType', align: 'left', width: 180 },
{ title: '大小', key: 'size', align: 'left', width: 110 },
{ title: '引用数', key: 'linkCount', align: 'left', width: 90 },
{ title: '首个Topic', key: 'firstTopic', align: 'left', width: 180 },
{ title: '首个消息', key: 'firstMsgId', align: 'left', width: 110 },
{ title: '位置', key: 'location', align: 'left', width: 260 },
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const linkColumns = [
{ title: 'ID', key: 'id', align: 'left', width: 90 },
{ title: '消息ID', key: 'msgId', align: 'left', width: 100 },
{ title: 'Topic', key: 'topic', align: 'left', width: 180 },
{ title: '用户ID', key: 'userId', align: 'left', width: 120 },
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImFileStatusOptions', 'ImFilePurposeOptions']);
}
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;
}
+80
View File
@@ -0,0 +1,80 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'文件详情 #' + 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="状态">{{ statusLabel }}</n-descriptions-item>
<n-descriptions-item label="用途">{{ purposeLabel }}</n-descriptions-item>
<n-descriptions-item label="上传用户">{{ formValue.userId || '-' }}</n-descriptions-item>
<n-descriptions-item label="MIME">{{ formValue.mimeType }}</n-descriptions-item>
<n-descriptions-item label="大小">{{ formValue.size }}</n-descriptions-item>
<n-descriptions-item label="ETag">{{ formValue.etag || '-' }}</n-descriptions-item>
<n-descriptions-item label="引用数">{{ formValue.linkCount }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formValue.createdAt }}</n-descriptions-item>
<n-descriptions-item label="更新时间">{{ formValue.updatedAt }}</n-descriptions-item>
<n-descriptions-item v-if="isPlatformAdmin" label="文件位置" :span="2">
{{ formValue.location || '-' }}
</n-descriptions-item>
</n-descriptions>
<BasicTable
class="link-table"
:columns="linkColumns"
:dataSource="formValue.links"
:pagination="false"
:scroll-x="760"
:resizeHeightOffset="-10000"
/>
</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 { useUserStore } from '@/store/modules/user';
import { BasicTable } from '@/components/Table';
import { adaModalWidth } from '@/utils/hotgo';
import { linkColumns, newState, State } from './model';
import { View } from '@/api/imFile';
const dict = useDictStore();
const settingStore = useProjectSettingStore();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const showModal = ref(false);
const loading = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(980));
const statusLabel = computed(() => dict.getLabel('ImFileStatusOptions', formValue.value.status));
const purposeLabel = computed(
() => dict.getLabel('ImFilePurposeOptions', formValue.value.purpose) || formValue.value.purpose
);
function openModal(state: State) {
showModal.value = true;
formValue.value = newState(state);
loading.value = true;
View({ tenantId: state.tenantId, id: state.id })
.then((res) => {
formValue.value = newState(res);
})
.finally(() => {
loading.value = false;
});
}
defineExpose({ openModal });
</script>
<style lang="less" scoped>
.link-table {
margin-top: 12px;
}
</style>
+225
View File
@@ -0,0 +1,225 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM文件策略" />
</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.purpose"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'文件策略 - ' + purposeLabel"
:style="{ width: dialogWidth }"
>
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="100"
>
<n-form-item label="用途" path="purpose">
<n-select
v-model:value="formValue.purpose"
:disabled="true"
:options="purposeOptions"
placeholder="请选择用途"
/>
</n-form-item>
<n-form-item label="大小上限" path="maxSize">
<n-input-number v-model:value="formValue.maxSize" :min="1" />
</n-form-item>
<n-form-item label="保留天数">
<n-input-number v-model:value="formValue.retentionDays" :min="0" />
</n-form-item>
<n-form-item label="自动清理">
<n-switch v-model:value="autoCleanupEnabled" />
</n-form-item>
<n-form-item label="病毒扫描">
<n-switch v-model:value="scanEnabledValue" />
</n-form-item>
<n-form-item label="MIME白名单">
<n-input
v-model:value="formValue.allowedMimes"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="请输入JSON数组,空数组表示不限"
/>
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="formValue.remark" placeholder="请输入备注" />
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { List, Update } from '@/api/imFilePolicy';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
columns,
loadOptions,
loadTenantOptions,
newState,
rules,
schemas,
State,
tenantOptions,
} from './model';
const dict = useDictStore();
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const formRef = ref();
const showModal = ref(false);
const submitLoading = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(720));
const purposeOptions = computed(() =>
dict
.getOptionUnRef('ImFilePurposeOptions')
.filter((item) => item.value !== 'unknown' && item.value !== 'orphan')
);
const purposeLabel = computed(
() => dict.getLabel('ImFilePurposeOptions', formValue.value.purpose) || formValue.value.purpose
);
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const autoCleanupEnabled = computed({
get: () => formValue.value.autoCleanup === 1,
set: (value: boolean) => {
formValue.value.autoCleanup = value ? 1 : 0;
},
});
const scanEnabledValue = computed({
get: () => formValue.value.scanEnabled === 1,
set: (value: boolean) => {
formValue.value.scanEnabled = value ? 1 : 0;
},
});
const actionColumn = reactive({
width: 100,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imFilePolicy/update'],
onClick: () => handleEdit(record),
},
],
});
},
});
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 };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
await setFieldsValue(defaults);
reloadTable();
}
function handleEdit(record: State) {
formValue.value = newState(record);
if (!formValue.value.allowedMimes) formValue.value.allowedMimes = '[]';
showModal.value = true;
}
async function handleSubmit() {
submitLoading.value = true;
try {
await formRef.value?.validate();
let allowedMimes: any[] = [];
const text = (formValue.value.allowedMimes || '').trim();
if (text) {
allowedMimes = JSON.parse(text);
if (!Array.isArray(allowedMimes)) throw new Error('MIME白名单必须是JSON数组');
}
await Update({ ...formValue.value, allowedMimes });
message.success('操作成功');
showModal.value = false;
reloadTable();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用商户');
}
}
reloadTable();
});
</script>
+136
View File
@@ -0,0 +1,136 @@
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 tenantId = 0;
public tenantName = '';
public id = 0;
public purpose = '';
public maxSize = 0;
public retentionDays = 0;
public allowedMimes = '[]';
public autoCleanup = 0;
public scanEnabled = 0;
public remark = '';
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: 'purpose',
component: 'NSelect',
label: '用途',
componentProps: {
clearable: true,
options: dict.getOption('ImFilePurposeOptions'),
placeholder: '请选择用途',
},
},
{
field: 'autoCleanup',
component: 'NSelect',
label: '自动清理',
componentProps: {
clearable: true,
options: dict.getOption('ImFilePolicySwitchOptions'),
placeholder: '请选择开关',
},
},
{
field: 'scanEnabled',
component: 'NSelect',
label: '病毒扫描',
componentProps: {
clearable: true,
options: dict.getOption('ImFilePolicySwitchOptions'),
placeholder: '请选择开关',
},
},
]);
export const columns = [
{
title: '用途',
key: 'purpose',
align: 'left',
width: 130,
render(row: State) {
return dict.getLabel('ImFilePurposeOptions', row.purpose) || row.purpose;
},
},
{ title: '大小上限', key: 'maxSize', align: 'left', width: 120 },
{ title: '保留天数', key: 'retentionDays', align: 'left', width: 100 },
{
title: '自动清理',
key: 'autoCleanup',
align: 'left',
width: 100,
render(row: State) {
return dict.getLabel('ImFilePolicySwitchOptions', row.autoCleanup);
},
},
{
title: '病毒扫描',
key: 'scanEnabled',
align: 'left',
width: 100,
render(row: State) {
return dict.getLabel('ImFilePolicySwitchOptions', row.scanEnabled);
},
},
{ title: 'MIME白名单', key: 'allowedMimes', align: 'left', width: 260 },
{ title: '备注', key: 'remark', align: 'left', width: 180 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const rules = {
purpose: { required: true, trigger: ['blur', 'change'], type: 'string', message: '请选择用途' },
maxSize: {
required: true,
trigger: ['blur', 'input'],
type: 'number',
message: '请输入大小上限',
},
};
export function loadOptions() {
dict.loadOptions(['ImFilePurposeOptions', 'ImFilePolicySwitchOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+312 -142
View File
@@ -12,62 +12,87 @@
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="settings" tab="治理配置">
<n-spin :show="loading" description="请稍候...">
<n-form
ref="commandFormRef"
:model="commandForm"
label-placement="left"
:label-width="110"
class="governance-command-form"
>
<n-grid :cols="settingStore.isMobile ? 1 : 2" :x-gap="16">
<n-form-item-gi label="操作IM用户ID" path="actorUserId">
<n-input-number v-model:value="commandForm.actorUserId" clearable />
</n-form-item-gi>
<n-form-item-gi label="版本" path="version">
<n-input-number v-model:value="commandForm.version" disabled />
</n-form-item-gi>
<n-form-item-gi label="群名称" path="name">
<n-input v-model:value="commandForm.name" clearable />
</n-form-item-gi>
<n-form-item-gi label="头像文件ID" path="avatarFileId">
<n-input v-model:value="commandForm.avatarFileId" clearable />
</n-form-item-gi>
<n-form-item-gi label="简介" path="description">
<n-input v-model:value="commandForm.description" type="textarea" clearable />
</n-form-item-gi>
<n-form-item-gi label="治理开关">
<n-space>
<n-checkbox v-model:checked="commandForm.joinApproval">入群审批</n-checkbox>
<n-checkbox v-model:checked="commandForm.muteAll">全员禁言</n-checkbox>
<n-checkbox v-model:checked="commandForm.memberUploadFiles">成员上传文件</n-checkbox>
<n-checkbox v-model:checked="commandForm.memberPrivateChat">成员私聊</n-checkbox>
<n-checkbox v-model:checked="commandForm.memberInvite">成员邀请</n-checkbox>
</n-space>
</n-form-item-gi>
</n-grid>
<n-space>
<n-button type="primary" :loading="submitting" @click="submitProfile">提交资料命令</n-button>
<n-button type="primary" :loading="submitting" @click="submitGovernance">提交治理命令</n-button>
</n-space>
</n-form>
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="群名称">{{ governance.name || '-' }}</n-descriptions-item>
<n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item>
<n-descriptions-item label="群主ID">{{ governance.ownerUserId || '-' }}</n-descriptions-item>
<n-descriptions-item label="成员数">{{ governance.memberCount || '-' }}</n-descriptions-item>
<n-descriptions-item label="入群审批">{{ governance.joinApproval ? '开启' : '关闭' }}</n-descriptions-item>
<n-descriptions-item label="全员禁言">{{ governance.muteAll ? '开启' : '关闭' }}</n-descriptions-item>
<n-descriptions-item label="成员上传文件">{{ governance.memberUploadFiles ? '允许' : '禁止' }}</n-descriptions-item>
<n-descriptions-item label="成员私聊">{{ governance.memberPrivateChat ? '允许' : '禁止' }}</n-descriptions-item>
<n-descriptions-item label="成员邀请">{{ governance.memberInvite ? '允许' : '禁止' }}</n-descriptions-item>
<n-descriptions-item label="最大成员数">{{ governance.maxMemberCount || '-' }}</n-descriptions-item>
<n-descriptions-item label="最大管理员数">{{ governance.maxAdminCount || '-' }}</n-descriptions-item>
<n-descriptions-item label="版本">{{ governance.version || '-' }}</n-descriptions-item>
<n-descriptions-item label="创建人">{{ governance.createdBy || '-' }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ governance.createdAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="解散人">{{ governance.dissolvedBy || '-' }}</n-descriptions-item>
<n-descriptions-item label="解散时间">{{ governance.dissolvedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="简介" :span="2">{{ governance.description || '-' }}</n-descriptions-item>
<n-descriptions-item label="群主ID">{{
governance.ownerUserId || '-'
}}</n-descriptions-item>
<n-descriptions-item label="成员数">{{
governance.memberCount || '-'
}}</n-descriptions-item>
<n-descriptions-item label="入群审批">
<n-switch
v-model:value="governance.joinApproval"
:checked-value="1"
:unchecked-value="0"
:disabled="!canUpdateGovernance"
/>
</n-descriptions-item>
<n-descriptions-item label="全员禁言">
<n-switch
v-model:value="governance.muteAll"
:checked-value="1"
:unchecked-value="0"
:disabled="!canUpdateGovernance"
/>
</n-descriptions-item>
<n-descriptions-item label="成员上传文件">
<n-switch
v-model:value="governance.memberUploadFiles"
:checked-value="1"
:unchecked-value="0"
:disabled="!canUpdateGovernance"
/>
</n-descriptions-item>
<n-descriptions-item label="成员私聊">
<n-switch
v-model:value="governance.memberPrivateChat"
:checked-value="1"
:unchecked-value="0"
:disabled="!canUpdateGovernance"
/>
</n-descriptions-item>
<n-descriptions-item label="成员邀请">
<n-switch
v-model:value="governance.memberInvite"
:checked-value="1"
:unchecked-value="0"
:disabled="!canUpdateGovernance"
/>
</n-descriptions-item>
<n-descriptions-item label="最大成员数">
<n-input-number
v-model:value="governance.maxMemberCount"
:min="0"
:disabled="!canUpdateGovernance"
/>
</n-descriptions-item>
<n-descriptions-item label="最大管理员数">
<n-input-number
v-model:value="governance.maxAdminCount"
:min="0"
:disabled="!canUpdateGovernance"
/>
</n-descriptions-item>
<n-descriptions-item label="版本">{{
governance.version || '-'
}}</n-descriptions-item>
<n-descriptions-item label="创建人">{{
governance.createdBy || '-'
}}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{
governance.createdAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="解散人">{{
governance.dissolvedBy || '-'
}}</n-descriptions-item>
<n-descriptions-item label="解散时间">{{
governance.dissolvedAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="简介" :span="2">{{
governance.description || '-'
}}</n-descriptions-item>
</n-descriptions>
</n-spin>
</n-tab-pane>
@@ -77,10 +102,22 @@
:columns="joinRequestColumns"
:request="joinRequestData"
:row-key="(row) => row.requestId"
:actionColumn="joinRequestActionColumn"
:scroll-x="joinRequestScrollX"
:resizeHeightOffset="-10000"
/>
</n-tab-pane>
<n-tab-pane name="files" tab="群文件">
<BasicTable
ref="fileRef"
:columns="groupFileColumns"
:request="fileData"
:row-key="(row) => row.groupFileId"
:actionColumn="fileActionColumn"
:scroll-x="fileScrollX"
:resizeHeightOffset="-10000"
/>
</n-tab-pane>
<n-tab-pane name="invitations" tab="群邀请">
<BasicTable
ref="invitationRef"
@@ -107,6 +144,7 @@
:columns="muteColumns"
:request="muteData"
:row-key="(row) => row.topic + '-' + row.userId"
:actionColumn="muteActionColumn"
:scroll-x="muteScrollX"
:resizeHeightOffset="-10000"
/>
@@ -124,6 +162,22 @@
</n-tabs>
<template #action>
<n-space>
<n-button
v-if="activeTab === 'settings' && canUpdateGovernance"
type="primary"
:loading="submitLoading"
@click="handleSaveGovernance"
>
保存治理配置
</n-button>
<n-button
v-if="activeTab === 'settings' && canDissolve && governance.groupStatus !== 'dissolved'"
type="error"
:loading="submitLoading"
@click="handleDissolve"
>
解散群
</n-button>
<n-button @click="closeForm">关闭</n-button>
</n-space>
</template>
@@ -132,24 +186,31 @@
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import { computed, h, nextTick, reactive, ref, watch } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicTable } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { BasicTable, TableAction } from '@/components/Table';
import {
AdminLogList,
AnnouncementList,
Dissolve,
FileDelete,
FileList,
Governance,
GovernanceUpdate,
InvitationList,
JoinRequestApprove,
JoinRequestList,
JoinRequestReject,
MuteList,
ProfileUpdate,
Unmute,
} from '@/api/imGroup';
import {
adminLogColumns,
announcementColumns,
GovernanceState,
groupFileColumns,
invitationColumns,
joinRequestColumns,
muteColumns,
@@ -163,38 +224,101 @@
const dict = useDictStore();
const message = useMessage();
const dialog = useDialog();
const { hasPermission } = usePermission();
const showModal = ref(false);
const loading = ref(false);
const submitting = ref(false);
const submitLoading = ref(false);
const activeTab = ref('settings');
const current = ref<State>(newState(null));
const governance = ref<GovernanceState>(new GovernanceState());
const commandFormRef = ref();
const commandForm = ref({
actorUserId: 0,
version: 0,
name: '',
avatarFileId: '',
description: '',
joinApproval: false,
muteAll: false,
memberUploadFiles: false,
memberPrivateChat: false,
memberInvite: false,
});
const dialogWidth = computed(() => adaModalWidth(1120));
const groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', governance.value.groupStatus) || '-');
const canUpdateGovernance = computed(() => hasPermission(['/imGroup/governanceUpdate']));
const canDissolve = computed(() => hasPermission(['/imGroup/dissolve']));
const groupStatusLabel = computed(
() => dict.getLabel('ImGroupStatusOptions', governance.value.groupStatus) || '-'
);
const joinRequestRef = ref();
const fileRef = ref();
const invitationRef = ref();
const announcementRef = ref();
const muteRef = ref();
const adminLogRef = ref();
const joinRequestActionColumn = reactive({
width: 150,
title: '操作',
key: 'action',
fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '通过',
type: 'primary',
ifShow: () => record.status === 'pending',
auth: ['/imGroup/joinRequestApprove'],
onClick: () => handleJoinRequestApprove(record),
},
{
label: '拒绝',
type: 'error',
ifShow: () => record.status === 'pending',
auth: ['/imGroup/joinRequestReject'],
onClick: () => handleJoinRequestReject(record),
},
],
});
},
});
const fileActionColumn = reactive({
width: 100,
title: '操作',
key: 'action',
fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '删除',
type: 'error',
ifShow: () => record.status === 'active',
auth: ['/imGroup/fileDelete'],
onClick: () => handleFileDelete(record),
},
],
});
},
});
const muteActionColumn = reactive({
width: 110,
title: '操作',
key: 'action',
fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '解除',
type: 'warning',
ifShow: () => record.status === 'active',
auth: ['/imGroup/unmute'],
onClick: () => handleUnmute(record),
},
],
});
},
});
const joinRequestScrollX = computed(() => adaTableScrollX(joinRequestColumns, 0));
const joinRequestScrollX = computed(() =>
adaTableScrollX(joinRequestColumns, joinRequestActionColumn.width)
);
const fileScrollX = computed(() => adaTableScrollX(groupFileColumns, fileActionColumn.width));
const invitationScrollX = computed(() => adaTableScrollX(invitationColumns, 0));
const announcementScrollX = computed(() => adaTableScrollX(announcementColumns, 0));
const muteScrollX = computed(() => adaTableScrollX(muteColumns, 0));
const muteScrollX = computed(() => adaTableScrollX(muteColumns, muteActionColumn.width));
const adminLogScrollX = computed(() => adaTableScrollX(adminLogColumns, 0));
function baseParams(res) {
@@ -209,6 +333,10 @@
return await JoinRequestList(baseParams(res));
}
async function fileData(res) {
return await FileList(baseParams(res));
}
async function invitationData(res) {
return await InvitationList(baseParams(res));
}
@@ -233,91 +361,137 @@
async function loadGovernance() {
loading.value = true;
try {
const res = await Governance({ tenantId: current.value.tenantId, topic: current.value.topic });
const res = await Governance({
tenantId: current.value.tenantId,
topic: current.value.topic,
});
governance.value = Object.assign(new GovernanceState(), res || {});
fillCommandForm();
} finally {
loading.value = false;
}
}
function fillCommandForm() {
commandForm.value = {
actorUserId: governance.value.ownerUserId || current.value.ownerId || 0,
version: governance.value.version || current.value.settingsVersion || 0,
name: governance.value.name || current.value.groupName || '',
avatarFileId: governance.value.avatarFileId || current.value.avatarFileId || '',
description: governance.value.description || current.value.description || '',
joinApproval: !!governance.value.joinApproval,
muteAll: !!governance.value.muteAll,
memberUploadFiles: !!governance.value.memberUploadFiles,
memberPrivateChat: !!governance.value.memberPrivateChat,
memberInvite: !!governance.value.memberInvite,
};
}
function commandBase() {
if (!commandForm.value.actorUserId) {
message.warning('请填写操作IM用户ID');
return null;
}
return {
tenantId: current.value.tenantId,
topic: current.value.topic,
actorUserId: String(commandForm.value.actorUserId),
version: commandForm.value.version,
};
}
async function submitProfile() {
const base = commandBase();
if (!base) return;
submitting.value = true;
async function handleSaveGovernance() {
submitLoading.value = true;
try {
const res = await ProfileUpdate({
...base,
name: commandForm.value.name,
avatarFileId: commandForm.value.avatarFileId,
description: commandForm.value.description,
await GovernanceUpdate({
tenantId: current.value.tenantId,
topic: current.value.topic,
joinApproval: governance.value.joinApproval,
muteAll: governance.value.muteAll,
memberUploadFiles: governance.value.memberUploadFiles,
memberPrivateChat: governance.value.memberPrivateChat,
memberInvite: governance.value.memberInvite,
maxMemberCount: governance.value.maxMemberCount,
maxAdminCount: governance.value.maxAdminCount,
});
showCommandResult(res);
message.success('操作成功');
await loadGovernance();
} finally {
submitting.value = false;
submitLoading.value = false;
}
}
async function submitGovernance() {
const base = commandBase();
if (!base) return;
submitting.value = true;
try {
const res = await GovernanceUpdate({
...base,
joinApproval: commandForm.value.joinApproval,
muteAll: commandForm.value.muteAll,
memberUploadFiles: commandForm.value.memberUploadFiles,
memberPrivateChat: commandForm.value.memberPrivateChat,
memberInvite: commandForm.value.memberInvite,
});
showCommandResult(res);
} finally {
submitting.value = false;
}
function handleDissolve() {
dialog.error({
title: '解散群',
content: '解散后该群将进入不可用状态,并写入群事件等待 IM 服务端同步,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
submitLoading.value = true;
try {
await Dissolve({
tenantId: current.value.tenantId,
topic: current.value.topic,
reason: '后台管理员解散',
});
message.success('操作成功');
await loadGovernance();
} finally {
submitLoading.value = false;
}
},
});
}
function showCommandResult(res) {
function handleUnmute(record) {
dialog.warning({
title: '解除禁言',
content: '确定解除该成员禁言?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Unmute({
tenantId: current.value.tenantId,
topic: current.value.topic,
userId: record.userId,
});
message.success('操作成功');
muteRef.value?.reload();
},
});
}
function handleJoinRequestApprove(record) {
dialog.success({
title: '命令已发送',
content: `CommandID:${res?.commandId || '-'},状态:${res?.status || '-'}`,
positiveText: '知道了',
title: '通过入群申请',
content: '确定通过该入群申请?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await JoinRequestApprove({
tenantId: current.value.tenantId,
topic: current.value.topic,
requestId: record.requestId,
});
message.success('操作成功');
joinRequestRef.value?.reload();
},
});
}
function handleJoinRequestReject(record) {
dialog.error({
title: '拒绝入群申请',
content: '确定拒绝该入群申请?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await JoinRequestReject({
tenantId: current.value.tenantId,
topic: current.value.topic,
requestId: record.requestId,
rejectReason: '后台管理员拒绝',
});
message.success('操作成功');
joinRequestRef.value?.reload();
},
});
}
function handleFileDelete(record) {
dialog.error({
title: '删除群文件',
content: '删除后该群文件会被标记为已删除,并写入群事件等待 IM 服务端同步,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await FileDelete({
tenantId: current.value.tenantId,
topic: current.value.topic,
groupFileId: record.groupFileId,
});
message.success('操作成功');
fileRef.value?.reload();
},
});
loadGovernance();
reloadActiveTable();
}
function reloadActiveTable() {
const refs = {
joinRequests: joinRequestRef,
files: fileRef,
invitations: invitationRef,
announcements: announcementRef,
mutes: muteRef,
@@ -345,8 +519,4 @@
});
</script>
<style lang="less" scoped>
.governance-command-form {
margin-bottom: 16px;
}
</style>
<style lang="less"></style>
+18 -8
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="群组管理" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@@ -32,6 +32,7 @@
import { useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { useUserStore } from '@/store/modules/user';
import { List } from '@/api/imGroup';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
@@ -40,12 +41,19 @@
import GovernanceModal from './governance.vue';
const message = useMessage();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const membersRef = ref();
const governanceRef = ref();
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const actionColumn = reactive({
width: 220,
title: '操作',
@@ -88,7 +96,7 @@
// 加载表格数据
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
if (!params.tenantId) {
if (isPlatformAdmin.value && !params.tenantId) {
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
}
return await List(params);
@@ -116,13 +124,15 @@
onMounted(async () => {
loadOptions();
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
reloadTable();
} else {
message.warning('暂无可用租户');
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用商户');
}
}
reloadTable();
});
</script>
+78 -78
View File
@@ -9,11 +9,6 @@
:title="'群成员 ' + current.topic"
:style="{ width: dialogWidth }"
>
<n-form label-placement="left" :label-width="110" class="member-command-form">
<n-form-item label="操作IM用户ID">
<n-input-number v-model:value="actorUserId" clearable />
</n-form-item>
</n-form>
<BasicTable
ref="actionRef"
:columns="memberColumns"
@@ -36,59 +31,59 @@
import { computed, h, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { AdminRemove, AdminSet, MemberList, MemberMute, MemberRemove, MemberUnmute } from '@/api/imGroup';
import { MemberState, memberColumns, newState, State } from './model';
import { usePermission } from '@/hooks/web/usePermission';
import { MemberList, Mute, OwnerTransfer, RoleUpdate } from '@/api/imGroup';
import { memberColumns, newState, State } from './model';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
const dialog = useDialog();
const message = useMessage();
const dialog = useDialog();
const { hasPermission } = usePermission();
const showModal = ref(false);
const current = ref<State>(newState(null));
const actorUserId = ref<number | null>(null);
const actionRef = ref();
const dialogWidth = computed(() => adaModalWidth(1120));
const dialogWidth = computed(() => adaModalWidth(980));
const actionColumn = reactive({
width: 300,
width: 210,
title: '操作',
key: 'action',
fixed: 'right',
render(record: MemberState) {
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '设管理员',
onClick: () => submitMemberCommand('setAdmin', record),
auth: ['/imGroup/adminSet'],
ifShow: record.role !== 'admin' && record.role !== 'owner',
},
{
label: '删管理员',
onClick: () => submitMemberCommand('removeAdmin', record),
auth: ['/imGroup/adminRemove'],
ifShow: record.role === 'admin',
},
{
label: '禁言',
onClick: () => submitMemberCommand('mute', record),
auth: ['/imGroup/memberMute'],
type: 'warning',
ifShow: () => record.memberStatus === 'active',
disabled: !hasPermission(['/imGroup/mute']),
onClick: () => handleMute(record),
},
{
label: '解禁',
onClick: () => submitMemberCommand('unmute', record),
auth: ['/imGroup/memberUnmute'],
label: '设管理员',
type: 'primary',
ifShow: () => record.memberStatus === 'active' && record.role === 'member',
auth: ['/imGroup/roleUpdate'],
onClick: () => handleRoleUpdate(record, 'admin'),
},
{
label: '移出',
onClick: () => submitMemberCommand('remove', record),
auth: ['/imGroup/memberRemove'],
label: '取消管理员',
type: 'default',
ifShow: () => record.memberStatus === 'active' && record.role === 'admin',
auth: ['/imGroup/roleUpdate'],
onClick: () => handleRoleUpdate(record, 'member'),
},
{
label: '转群主',
type: 'error',
ifShow: () => record.memberStatus === 'active' && record.role !== 'owner',
auth: ['/imGroup/ownerTransfer'],
onClick: () => handleOwnerTransfer(record),
},
],
});
},
});
const scrollX = computed(() => adaTableScrollX(memberColumns, actionColumn.width));
// 加载表格数据
@@ -108,48 +103,58 @@
showModal.value = false;
}
function baseCommand(record: MemberState) {
if (!actorUserId.value) {
message.warning('请先填写操作IM用户ID');
return null;
}
return {
tenantId: current.value.tenantId,
topic: current.value.topic,
actorUserId: String(actorUserId.value),
userId: String(record.userId),
};
}
function submitMemberCommand(type: string, record: MemberState) {
const labels = {
setAdmin: '设置管理员',
removeAdmin: '删除管理员',
mute: '禁言成员',
unmute: '解除禁言',
remove: '移出群成员',
};
function handleMute(record) {
dialog.warning({
title: labels[type],
content: `确认对用户 ${record.userId} 执行该操作?`,
title: '禁言成员',
content: '将该成员禁言 24 小时,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
const params = baseCommand(record);
if (!params) return false;
let res: any;
if (type === 'setAdmin') {
res = await AdminSet({ ...params, userIds: [String(record.userId)] });
} else if (type === 'removeAdmin') {
res = await AdminRemove(params);
} else if (type === 'mute') {
res = await MemberMute(params);
} else if (type === 'unmute') {
res = await MemberUnmute(params);
} else {
res = await MemberRemove(params);
}
message.success(`命令已发送:${res?.commandId || '-'}`);
await Mute({
tenantId: current.value.tenantId,
topic: current.value.topic,
userId: record.userId,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
reason: '后台管理员禁言',
});
message.success('操作成功');
actionRef.value?.reload();
},
});
}
function handleRoleUpdate(record, role: string) {
dialog.warning({
title: role === 'admin' ? '设置管理员' : '取消管理员',
content: role === 'admin' ? '确定将该成员设置为管理员?' : '确定取消该成员管理员身份?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await RoleUpdate({
tenantId: current.value.tenantId,
topic: current.value.topic,
userId: record.userId,
role,
});
message.success('操作成功');
actionRef.value?.reload();
},
});
}
function handleOwnerTransfer(record) {
dialog.error({
title: '转让群主',
content: '转让后原群主会变为管理员,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await OwnerTransfer({
tenantId: current.value.tenantId,
topic: current.value.topic,
userId: record.userId,
});
message.success('操作成功');
actionRef.value?.reload();
},
});
@@ -158,7 +163,6 @@
// 打开弹窗
function openModal(state: State) {
current.value = newState(state);
actorUserId.value = current.value.ownerId || null;
showModal.value = true;
setTimeout(() => {
actionRef.value?.reload();
@@ -170,8 +174,4 @@
});
</script>
<style lang="less" scoped>
.member-command-form {
margin-bottom: 12px;
}
</style>
<style lang="less"></style>
+73 -19
View File
@@ -1,4 +1,5 @@
import { computed, ref } from 'vue';
import { computed, h, ref } from 'vue';
import { NTag } from 'naive-ui';
import { cloneDeep } from 'lodash-es';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
@@ -10,11 +11,13 @@ const dict = useDictStore();
export const tenantOptions = ref<any[]>([]);
export class State {
public tenantId = 0; // 租户ID
public tenantName = ''; // 租户名称
public tenantId = 0; // 商户ID
public tenantName = ''; // 商户名称
public topic = ''; // Topic
public state = 0; // 状态
public ownerId = 0; // 群主ID
public ownerNickname = ''; // 群主昵称
public ownerUsername = ''; // 群主用户名
public seqId = 0; // 最新消息序号
public delId = 0; // 删除序号
public subCnt = 0; // 成员数
@@ -115,11 +118,11 @@ export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
label: '商户',
componentProps: {
clearable: false,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
placeholder: '请选择商户',
},
},
{
@@ -144,7 +147,7 @@ export const schemas = ref<FormSchema[]>([
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImGroupStateOptions')),
options: dict.getOption('ImGroupStateOptions'),
placeholder: '请选择状态',
},
},
@@ -154,7 +157,7 @@ export const schemas = ref<FormSchema[]>([
label: '业务状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImGroupStatusOptions')),
options: dict.getOption('ImGroupStatusOptions'),
placeholder: '请选择业务状态',
},
},
@@ -183,10 +186,10 @@ export const schemas = ref<FormSchema[]>([
// 表格列
export const columns = [
{
title: 'Topic',
key: 'topic',
title: '群名称',
key: 'groupName',
align: 'left',
width: 220,
width: 180,
},
{
title: '状态',
@@ -194,7 +197,12 @@ export const columns = [
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImGroupStateOptions', row.state);
const label = dict.getLabel('ImGroupStateOptions', row.state) || '-';
return h(
NTag,
{ type: dict.getType('ImGroupStateOptions', row.state), bordered: true },
() => label
);
},
},
{
@@ -203,20 +211,29 @@ export const columns = [
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImGroupStatusOptions', row.groupStatus) || '-';
const dictName = row.groupStatus ? 'ImGroupStatusOptions' : 'ImGroupStateOptions';
const value = row.groupStatus || row.state;
const label = dict.getLabel(dictName, value) || '-';
return h(NTag, { type: dict.getType(dictName, value), bordered: true }, () => label);
},
},
{
title: '群名称',
key: 'groupName',
title: 'Topic',
key: 'topic',
align: 'left',
width: 160,
width: 220,
},
{
title: '群主ID',
key: 'ownerId',
title: '群主昵称',
key: 'ownerNickname',
align: 'left',
width: 120,
width: 150,
},
{
title: '群主用户名',
key: 'ownerUsername',
align: 'left',
width: 160,
},
{
title: '成员数',
@@ -287,7 +304,10 @@ export const memberColumns = [
align: 'left',
width: 110,
render(row: MemberState) {
return dict.getLabel('ImGroupMemberStatusOptions', row.memberStatus) || dict.getLabel('ImUserStateOptions', row.state);
return (
dict.getLabel('ImGroupMemberStatusOptions', row.memberStatus) ||
dict.getLabel('ImUserStateOptions', row.state)
);
},
},
{
@@ -365,6 +385,38 @@ export const joinRequestColumns = [
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
];
export const groupFileColumns = [
{ title: '群文件ID', key: 'groupFileId', align: 'left', width: 180 },
{ title: '文件ID', key: 'fileId', align: 'left', width: 160 },
{ title: '文件名', key: 'fileName', align: 'left', width: 220 },
{
title: '类型',
key: 'fileType',
align: 'left',
width: 100,
render(row) {
return dict.getLabel('ImGroupFileTypeOptions', row.fileType) || row.fileType;
},
},
{ title: 'MIME', key: 'mimeType', align: 'left', width: 160 },
{ title: '大小', key: 'fileSize', align: 'left', width: 110 },
{ title: '上传人', key: 'uploaderId', align: 'left', width: 120 },
{ title: '来源Topic', key: 'sourceTopic', align: 'left', width: 160 },
{ title: '来源序号', key: 'sourceSeq', align: 'left', width: 100 },
{
title: '状态',
key: 'status',
align: 'left',
width: 110,
render(row) {
return dict.getLabel('ImGroupFileStatusOptions', row.status);
},
},
{ title: '删除人', key: 'deletedBy', align: 'left', width: 120 },
{ title: '删除时间', key: 'deletedAt', align: 'left', width: 180 },
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
];
export const invitationColumns = [
{ title: '邀请ID', key: 'invitationId', align: 'left', width: 180 },
{ title: '邀请人ID', key: 'inviterUserId', align: 'left', width: 120 },
@@ -447,6 +499,8 @@ export function loadOptions() {
'ImGroupMuteStatusOptions',
'ImGroupAnnouncementStatusOptions',
'ImGroupOutboxStatusOptions',
'ImGroupFileStatusOptions',
'ImGroupFileTypeOptions',
]);
}
+58 -19
View File
@@ -12,29 +12,66 @@
<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.tenantName }}</n-descriptions-item>
<n-descriptions-item label="商户">{{ formValue.tenantName }}</n-descriptions-item>
<n-descriptions-item label="Topic">{{ formValue.topic }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ stateLabel }}</n-descriptions-item>
<n-descriptions-item label="群主ID">{{ formValue.ownerId }}</n-descriptions-item>
<n-descriptions-item label="群主昵称">{{
formValue.ownerNickname || '-'
}}</n-descriptions-item>
<n-descriptions-item label="群主用户名">{{
formValue.ownerUsername || '-'
}}</n-descriptions-item>
<n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item>
<n-descriptions-item label="群名称">{{ formValue.groupName || '-' }}</n-descriptions-item>
<n-descriptions-item label="成员数">{{ formValue.businessMemberCount || formValue.subCnt }}</n-descriptions-item>
<n-descriptions-item label="入群审批">{{ formValue.joinApproval ? '开启' : '关闭' }}</n-descriptions-item>
<n-descriptions-item label="全员禁言">{{ formValue.muteAll ? '开启' : '关闭' }}</n-descriptions-item>
<n-descriptions-item label="成员上传文件">{{ formValue.memberUploadFiles ? '允许' : '禁止' }}</n-descriptions-item>
<n-descriptions-item label="成员私聊">{{ formValue.memberPrivateChat ? '允许' : '禁止' }}</n-descriptions-item>
<n-descriptions-item label="成员邀请">{{ formValue.memberInvite ? '允许' : '禁止' }}</n-descriptions-item>
<n-descriptions-item label="最大成员数">{{ formValue.maxMemberCount || '-' }}</n-descriptions-item>
<n-descriptions-item label="最大管理员数">{{ formValue.maxAdminCount || '-' }}</n-descriptions-item>
<n-descriptions-item label="治理版本">{{ formValue.settingsVersion || '-' }}</n-descriptions-item>
<n-descriptions-item label="群名称">{{
formValue.groupName || '-'
}}</n-descriptions-item>
<n-descriptions-item label="成员数">{{
formValue.businessMemberCount || formValue.subCnt
}}</n-descriptions-item>
<n-descriptions-item label="入群审批">{{
formValue.joinApproval ? '开启' : '关闭'
}}</n-descriptions-item>
<n-descriptions-item label="全员禁言">{{
formValue.muteAll ? '开启' : '关闭'
}}</n-descriptions-item>
<n-descriptions-item label="成员上传文件">{{
formValue.memberUploadFiles ? '允许' : '禁止'
}}</n-descriptions-item>
<n-descriptions-item label="成员私聊">{{
formValue.memberPrivateChat ? '允许' : '禁止'
}}</n-descriptions-item>
<n-descriptions-item label="成员邀请">{{
formValue.memberInvite ? '允许' : '禁止'
}}</n-descriptions-item>
<n-descriptions-item label="最大成员数">{{
formValue.maxMemberCount || '-'
}}</n-descriptions-item>
<n-descriptions-item label="最大管理员数">{{
formValue.maxAdminCount || '-'
}}</n-descriptions-item>
<n-descriptions-item label="治理版本">{{
formValue.settingsVersion || '-'
}}</n-descriptions-item>
<n-descriptions-item label="最新序号">{{ formValue.seqId }}</n-descriptions-item>
<n-descriptions-item label="删除序号">{{ formValue.delId }}</n-descriptions-item>
<n-descriptions-item label="BT">{{ formValue.useBt ? '开启' : '关闭' }}</n-descriptions-item>
<n-descriptions-item label="活跃时间">{{ formValue.touchedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="修改时间">{{ formValue.updatedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="治理更新时间">{{ formValue.settingsUpdatedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="简介" :span="2">{{ formValue.description || '-' }}</n-descriptions-item>
<n-descriptions-item label="BT">{{
formValue.useBt ? '开启' : '关闭'
}}</n-descriptions-item>
<n-descriptions-item label="活跃时间">{{
formValue.touchedAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{
formValue.createdAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="修改时间">{{
formValue.updatedAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="治理更新时间">{{
formValue.settingsUpdatedAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="简介" :span="2">{{
formValue.description || '-'
}}</n-descriptions-item>
<n-descriptions-item label="访问权限" :span="2">
<n-code :code="formValue.accessJson || '-'" language="json" word-wrap />
</n-descriptions-item>
@@ -77,7 +114,9 @@
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(960));
const stateLabel = computed(() => dict.getLabel('ImGroupStateOptions', formValue.value.state));
const groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', formValue.value.groupStatus) || '-');
const groupStatusLabel = computed(
() => dict.getLabel('ImGroupStatusOptions', formValue.value.groupStatus) || '-'
);
// 关闭弹窗
function closeForm() {
+4
View File
@@ -0,0 +1,4 @@
<template><MessageTable :group="true" /></template>
<script lang="ts" setup>
import MessageTable from '@/views/imMessageShared/MessageTable.vue';
</script>
+3 -3
View File
@@ -39,8 +39,8 @@ export const schemas = ref<FormSchema[]>([
{
field: 'tenantCode',
component: 'NInput',
label: '租户编码',
componentProps: { placeholder: '请输入租户编码' },
label: '商户编码',
componentProps: { placeholder: '请输入商户编码' },
},
{
field: 'eventId',
@@ -92,7 +92,7 @@ export const schemas = ref<FormSchema[]>([
export const columns = [
{ title: 'ID', key: 'id', align: 'left', width: 90 },
{ title: '租户', key: 'tenantCode', align: 'left', width: 120 },
{ 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 },
+1 -1
View File
@@ -12,7 +12,7 @@
<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.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>
+14 -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
@@ -52,14 +52,20 @@
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imKafkaCommand/view'] }],
actions: [
{
label: '详情',
onClick: () => viewRef.value.openModal(record),
auth: ['/imKafkaCommand/view'],
},
],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const [register] = useForm({
const [register, { setFieldsValue }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
@@ -77,7 +83,7 @@
async function sendCacheInvalidate() {
const tenant = getFirstTenant();
if (!tenant) {
message.warning('暂无可用租户');
message.warning('暂无可用商户');
return;
}
const res = await CacheInvalidate({
@@ -93,7 +99,7 @@
async function sendWorkbenchEvent() {
const tenant = getFirstTenant();
if (!tenant) {
message.warning('暂无可用租户');
message.warning('暂无可用商户');
return;
}
const res = await WorkbenchEvent({
@@ -117,6 +123,9 @@
onMounted(async () => {
loadOptions();
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
}
});
</script>
+4 -4
View File
@@ -48,11 +48,11 @@ export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
label: '商户',
componentProps: {
clearable: true,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
placeholder: '请选择商户',
},
},
{
@@ -85,7 +85,7 @@ export const schemas = ref<FormSchema[]>([
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImAdminCommandStatusOptions')),
options: dict.getOption('ImAdminCommandStatusOptions'),
placeholder: '请选择状态',
},
},
@@ -103,7 +103,7 @@ export const schemas = ref<FormSchema[]>([
export const columns = [
{ title: 'ID', key: 'id', align: 'left', width: 90 },
{ title: '租户', key: 'tenantCode', align: 'left', width: 120 },
{ 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 },
+1 -1
View File
@@ -12,7 +12,7 @@
<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.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>
+220
View File
@@ -0,0 +1,220 @@
<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="handleReset"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.tenantId + '-' + row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-button
v-if="hasPermission(['/imLoginFailureLog/export'])"
type="primary"
@click="handleExport"
><template #icon
><n-icon><ExportOutlined /></n-icon></template
>导出</n-button
></template
>
</BasicTable>
</n-card>
<n-modal v-model:show="showDetail" preset="card" title="失败详情" class="detail-modal">
<n-descriptions bordered label-placement="left" :column="1">
<n-descriptions-item label="用户ID">{{ current.userId || '未识别' }}</n-descriptions-item
><n-descriptions-item label="脱敏账号">{{ current.accountMask }}</n-descriptions-item>
<n-descriptions-item label="失败类型">{{ current.failureType }}</n-descriptions-item
><n-descriptions-item label="原因码">{{ current.reason }}</n-descriptions-item>
<n-descriptions-item label="失败详情">{{ current.detail }}</n-descriptions-item
><n-descriptions-item label="IP / 地区"
>{{ current.ip }} / {{ current.region }}</n-descriptions-item
>
<n-descriptions-item label="设备"
>{{ current.platform }} {{ current.appVersion }} /
{{ current.deviceId }}</n-descriptions-item
><n-descriptions-item label="User-Agent">{{ current.userAgent }}</n-descriptions-item>
<n-descriptions-item label="风险"
>{{ current.riskLevel }} / {{ current.riskAction }}</n-descriptions-item
><n-descriptions-item label="请求ID">{{ current.requestId }}</n-descriptions-item>
<n-descriptions-item label="尝试时间">{{ current.attemptAt }}</n-descriptions-item>
</n-descriptions>
</n-modal>
<n-modal v-model:show="showBan" preset="card" title="封禁用户" class="action-modal">
<n-form-item label="封禁原因" required
><n-input
v-model:value="banReason"
type="textarea"
maxlength="500"
show-count
placeholder="请输入封禁原因"
/></n-form-item>
<template #footer
><n-space justify="end"
><n-button @click="showBan = false">取消</n-button
><n-button type="error" :loading="submitting" @click="submitBan"
>确认封禁</n-button
></n-space
></template
>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { ExportOutlined } from '@vicons/antd';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaTableScrollX } from '@/utils/hotgo';
import { AddBlacklist, BanUser, Export, List, View } from '@/api/imLoginFailureLog';
import { imQueryTenantOptions, loadImQueryTenantOptions } from '@/views/imAdminQueryShared';
import { columns, loadOptions, schemas } from './model';
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 showDetail = ref(false);
const showBan = ref(false);
const submitting = ref(false);
const banReason = ref('');
const current = ref<Recordable>({});
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) tenantSchema.ifShow = () => isPlatformAdmin.value;
const actionColumn = reactive({
width: 260,
title: '操作',
key: 'action',
fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{ label: '详情', auth: ['/imLoginFailureLog/view'], onClick: () => openDetail(record) },
{
label: 'IP黑名单',
type: 'warning',
auth: ['/imLoginFailureLog/addBlacklist'],
ifShow: () => !!record.ip,
onClick: () => addBlacklist(record),
},
{
label: '封禁用户',
type: 'error',
auth: ['/imLoginFailureLog/banUser'],
ifShow: () => Number(record.userId) > 0,
onClick: () => openBan(record),
},
],
});
},
});
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 };
return List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value) defaults.tenantId = imQueryTenantOptions.value[0]?.value;
await setFieldsValue(defaults);
reloadTable();
}
function handleExport() {
const params = { ...searchFormRef.value?.formModel };
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请选择商户');
return;
}
Export(params);
}
async function openDetail(record) {
current.value = await View({ tenantId: record.tenantId, id: record.id });
showDetail.value = true;
}
function addBlacklist(record) {
dialog.warning({
title: '加入IP黑名单',
content: `确认禁止 ${record.ip} 继续登录吗?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await AddBlacklist({
tenantId: record.tenantId,
ip: record.ip,
remark: `登录失败日志 #${record.id}`,
});
message.success('已加入IP黑名单');
},
});
}
function openBan(record) {
current.value = record;
banReason.value = '';
showBan.value = true;
}
async function submitBan() {
if (!banReason.value.trim()) {
message.warning('请输入封禁原因');
return;
}
submitting.value = true;
try {
const res = await BanUser({
tenantId: current.value.tenantId,
userId: Number(current.value.userId),
reason: banReason.value.trim(),
});
message.success(`封禁命令已提交:${res?.commandId || ''}`);
showBan.value = false;
} finally {
submitting.value = false;
}
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadImQueryTenantOptions();
if (imQueryTenantOptions.value.length)
await setFieldsValue({ tenantId: imQueryTenantOptions.value[0].value });
else message.warning('暂无可用商户');
}
reloadTable();
});
</script>
<style lang="less" scoped>
.detail-modal {
width: min(760px, 92vw);
}
.action-modal {
width: min(520px, 92vw);
}
</style>
+92
View File
@@ -0,0 +1,92 @@
import { computed, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { useDictStore } from '@/store/modules/dict';
import { imQueryTenantOptions } from '@/views/imAdminQueryShared';
const dict = useDictStore();
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '商户',
componentProps: {
clearable: false,
options: computed(() => imQueryTenantOptions.value),
placeholder: '请选择商户',
},
},
{
field: 'userId',
component: 'NInput',
label: '用户ID',
componentProps: { placeholder: '请输入用户ID' },
},
{
field: 'account',
component: 'NInput',
label: '账号',
componentProps: { placeholder: '请输入脱敏账号' },
},
{
field: 'failureType',
component: 'NSelect',
label: '失败类型',
componentProps: {
clearable: true,
options: dict.getOption('ImLoginFailureTypeOptions'),
placeholder: '全部类型',
},
},
{
field: 'platform',
component: 'NSelect',
label: '客户端',
componentProps: {
clearable: true,
options: dict.getOption('ImClientPlatformOptions'),
placeholder: '全部客户端',
},
},
{
field: 'ip',
component: 'NInput',
label: 'IP',
componentProps: { placeholder: '请输入IP地址' },
},
{
field: 'attemptAt',
component: 'NDatePicker',
label: '尝试时间',
componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() },
},
]);
export const columns = [
{ title: 'ID', key: 'id', width: 90 },
{ title: '用户ID', key: 'userId', width: 130 },
{ title: '账号', key: 'accountMask', width: 170 },
{
title: '失败类型',
key: 'failureType',
width: 140,
render: (row) => dict.getLabel('ImLoginFailureTypeOptions', row.failureType) || row.failureType,
},
{ title: '原因码', key: 'reason', width: 180 },
{ title: 'IP', key: 'ip', width: 140 },
{ title: '地区', key: 'region', width: 150 },
{
title: '客户端',
key: 'platform',
width: 110,
render: (row) => dict.getLabel('ImClientPlatformOptions', row.platform) || row.platform,
},
{ title: '风险', key: 'riskLevel', width: 90 },
{ title: '风控动作', key: 'riskAction', width: 120 },
{ title: '尝试时间', key: 'attemptAt', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImLoginFailureTypeOptions', 'ImClientPlatformOptions']);
}
+152
View File
@@ -0,0 +1,152 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM总经理信箱配置" />
</div>
<n-card :bordered="false" class="proCard">
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="120"
>
<n-form-item v-if="isPlatformAdmin" label="商户">
<n-select
v-model:value="selectedTenantId"
:options="tenantOptions"
placeholder="请选择商户"
@update:value="loadConfig"
/>
</n-form-item>
<n-form-item label="启用信箱">
<n-switch v-model:value="enabledValue" />
</n-form-item>
<n-form-item label="允许匿名">
<n-switch v-model:value="allowAnonymousValue" />
</n-form-item>
<n-form-item label="最大字数" path="maxLength">
<n-input-number v-model:value="formValue.maxLength" :min="1" />
</n-form-item>
<n-form-item label="必填字段">
<n-input
v-model:value="formValue.requiredFields"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="请输入JSON数组"
/>
</n-form-item>
<n-form-item label="附件策略">
<n-input
v-model:value="formValue.attachmentPolicy"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="请输入JSON对象"
/>
</n-form-item>
<n-form-item label="告知标题" path="noticeTitle">
<n-input v-model:value="formValue.noticeTitle" placeholder="请输入告知标题" />
</n-form-item>
<n-form-item label="告知内容" path="noticeContent">
<n-input
v-model:value="formValue.noticeContent"
type="textarea"
:autosize="{ minRows: 5, maxRows: 10 }"
placeholder="请输入告知内容"
/>
</n-form-item>
<n-form-item>
<n-space>
<n-button
type="primary"
:loading="submitLoading"
:disabled="!hasPermission(['/imMailboxConfig/update']) || !currentTenantReady"
@click="handleSubmit"
>
保存
</n-button>
<n-button :loading="loadLoading" @click="loadConfig">刷新</n-button>
</n-space>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { Get, Update } from '@/api/imMailboxConfig';
import { loadOptions, loadTenantOptions, newState, rules, State, tenantOptions } from './model';
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const formRef = ref();
const formValue = ref<State>(newState(null));
const selectedTenantId = ref(0);
const loadLoading = ref(false);
const submitLoading = ref(false);
const currentTenantReady = computed(() => !isPlatformAdmin.value || selectedTenantId.value > 0);
const enabledValue = computed({
get: () => formValue.value.enabled === 1,
set: (value: boolean) => {
formValue.value.enabled = value ? 1 : 0;
},
});
const allowAnonymousValue = computed({
get: () => formValue.value.allowAnonymous === 1,
set: (value: boolean) => {
formValue.value.allowAnonymous = value ? 1 : 0;
},
});
async function loadConfig() {
if (!currentTenantReady.value) return;
loadLoading.value = true;
try {
const data = await Get({ tenantId: selectedTenantId.value });
formValue.value = newState(data || null);
formValue.value.tenantId = selectedTenantId.value;
} catch (e: any) {
message.error(e.message || '加载失败');
} finally {
loadLoading.value = false;
}
}
async function handleSubmit() {
submitLoading.value = true;
try {
await formRef.value?.validate();
const requiredFields = JSON.parse(formValue.value.requiredFields || '[]');
const attachmentPolicy = JSON.parse(formValue.value.attachmentPolicy || '{}');
await Update({
...formValue.value,
tenantId: selectedTenantId.value,
requiredFields,
attachmentPolicy,
});
message.success('操作成功');
await loadConfig();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) selectedTenantId.value = tenantOptions.value[0].value;
else message.warning('暂无可用商户');
}
await loadConfig();
});
</script>
+67
View File
@@ -0,0 +1,67 @@
import { ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { List as TenantList } from '@/api/imTenant';
import { useDictStore } from '@/store/modules/dict';
export const tenantOptions = ref<any[]>([]);
export class State {
public tenantId = 0;
public tenantName = '';
public id = 1;
public enabled = 1;
public allowAnonymous = 1;
public requiredFields = '["subject","content"]';
public maxLength = 5000;
public attachmentPolicy = '{"max_files":5,"purpose":"executive_mailbox"}';
public noticeTitle = '总经理信箱';
public noticeContent = '请如实填写诉求,匿名提交将按企业政策保护展示身份。';
public noticeUpdatedAt = '';
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 rules = {
noticeTitle: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入告知标题',
},
noticeContent: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入告知内容',
},
maxLength: {
required: true,
trigger: ['blur', 'input'],
type: 'number',
message: '请输入最大字数',
},
};
export function loadOptions() {
useDictStore().loadOptions(['ImFilePolicySwitchOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
@@ -0,0 +1,240 @@
<template>
<div>
<div class="n-layout-page-header"><n-card :bordered="false" :title="title" /></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.topic + '-' + row.seqId"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle>
<n-button
v-if="hasPermission([permissionPrefix + '/export'])"
type="primary"
@click="handleExport"
><template #icon
><n-icon><ExportOutlined /></n-icon></template
>导出</n-button
>
</template>
</BasicTable>
</n-card>
<n-modal v-model:show="showDetail" preset="card" title="消息详情" class="detail-modal">
<n-descriptions label-placement="left" bordered :column="1">
<n-descriptions-item label="Topic">{{ current.topic }}</n-descriptions-item>
<n-descriptions-item label="消息序号">{{ current.seqId }}</n-descriptions-item>
<n-descriptions-item label="发送用户">{{
current.senderNickname || current.senderUsername || current.fromUserId
}}</n-descriptions-item>
<n-descriptions-item :label="group ? '群名称' : '接收用户'">{{
group
? current.groupName || current.topic
: current.receiverNickname || current.receiverUsername || current.toUserId
}}</n-descriptions-item>
<n-descriptions-item label="消息类型">{{ current.contentType }}</n-descriptions-item>
<n-descriptions-item label="消息状态">{{ current.status }}</n-descriptions-item>
<n-descriptions-item label="完整内容">
<pre class="content-view">{{ current.content || current.contentExcerpt }}</pre>
</n-descriptions-item>
<n-descriptions-item label="发送时间">{{ current.createdAt }}</n-descriptions-item>
</n-descriptions>
</n-modal>
<n-modal
v-model:show="showAction"
preset="card"
:title="actionType === 'hide' ? '隐藏消息' : '删除消息'"
class="action-modal"
>
<n-form-item label="处置原因" required
><n-input
v-model:value="reason"
type="textarea"
maxlength="500"
show-count
placeholder="请输入处置原因"
/></n-form-item>
<template #footer
><n-space justify="end"
><n-button @click="showAction = false">取消</n-button
><n-button
:type="actionType === 'hide' ? 'warning' : 'error'"
:loading="submitting"
@click="submitAction"
>确认</n-button
></n-space
></template
>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { ExportOutlined } from '@vicons/antd';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaTableScrollX } from '@/utils/hotgo';
import { imQueryTenantOptions, loadImQueryTenantOptions } from '@/views/imAdminQueryShared';
import { createMessageColumns, createMessageSchemas, loadMessageOptions } from './model';
import * as PrivateApi from '@/api/imPrivateMessage';
import * as GroupApi from '@/api/imGroupMessage';
const props = defineProps<{ group: boolean }>();
const api: any = props.group ? GroupApi : PrivateApi;
const title = props.group ? '群聊消息' : '单聊消息';
const permissionPrefix = props.group ? '/imGroupMessage' : '/imPrivateMessage';
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const schemas = createMessageSchemas(props.group);
const columns = createMessageColumns(props.group);
const actionRef = ref();
const searchFormRef = ref<any>({});
const showDetail = ref(false);
const showAction = ref(false);
const submitting = ref(false);
const hasQueried = ref(!props.group);
const current = ref<Recordable>({});
const reason = ref('');
const actionType = ref<'hide' | 'delete'>('hide');
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) {
return h(TableAction as any, {
style: 'button',
actions: [
{ label: '详情', auth: [permissionPrefix + '/view'], onClick: () => openDetail(record) },
{
label: '隐藏',
type: 'warning',
auth: [permissionPrefix + '/hide'],
ifShow: () => record.status === 'normal',
onClick: () => openAction(record, 'hide'),
},
{
label: '删除',
type: 'error',
auth: [permissionPrefix + '/delete'],
ifShow: () => record.status !== 'deleted',
onClick: () => openAction(record, 'delete'),
},
],
});
},
});
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) => {
if (props.group && !hasQueried.value) return { list: [], page: 1, pageCount: 0, totalCount: 0 };
const params = { ...searchFormRef.value?.formModel, ...res };
if (isPlatformAdmin.value && !params.tenantId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
return api.List(params);
};
function reloadTable() {
hasQueried.value = true;
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value) defaults.tenantId = imQueryTenantOptions.value[0]?.value;
await setFieldsValue(defaults);
if (props.group) hasQueried.value = false;
actionRef.value?.reload();
}
function handleExport() {
if (props.group && !hasQueried.value) {
message.warning('请先查询群聊消息');
return;
}
const params = { ...searchFormRef.value?.formModel };
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请选择商户');
return;
}
api.Export(params);
}
async function openDetail(record) {
current.value = await api.View({
tenantId: record.tenantId,
topic: record.topic,
seqId: record.seqId,
});
showDetail.value = true;
}
function openAction(record, type: 'hide' | 'delete') {
current.value = record;
actionType.value = type;
reason.value = '';
showAction.value = true;
}
async function submitAction() {
if (!reason.value.trim()) {
message.warning('请输入处置原因');
return;
}
submitting.value = true;
try {
const fn = actionType.value === 'hide' ? api.Hide : api.Delete;
const res = await fn({
tenantId: current.value.tenantId,
topic: current.value.topic,
seqId: current.value.seqId,
reason: reason.value.trim(),
});
message.success(`命令已提交:${res?.commandId || ''}`);
showAction.value = false;
reloadTable();
} finally {
submitting.value = false;
}
}
onMounted(async () => {
loadMessageOptions();
if (isPlatformAdmin.value) {
await loadImQueryTenantOptions();
if (imQueryTenantOptions.value.length)
await setFieldsValue({ tenantId: imQueryTenantOptions.value[0].value });
else message.warning('暂无可用商户');
}
if (!props.group) reloadTable();
});
</script>
<style lang="less" scoped>
.detail-modal {
width: min(760px, 92vw);
}
.action-modal {
width: min(520px, 92vw);
}
.content-view {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
</style>
+158
View File
@@ -0,0 +1,158 @@
import { computed, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { useDictStore } from '@/store/modules/dict';
import { imQueryTenantOptions } from '@/views/imAdminQueryShared';
const dict = useDictStore();
export function createMessageSchemas(group: boolean) {
const items: FormSchema[] = [
{
field: 'tenantId',
component: 'NSelect',
label: '商户',
componentProps: {
clearable: false,
options: computed(() => imQueryTenantOptions.value),
placeholder: '请选择商户',
},
},
{
field: 'topic',
component: 'NInput',
label: group ? '群号/Topic' : '会话Topic',
componentProps: { placeholder: group ? '请输入群号或Topic' : '请输入Topic' },
},
{
field: group ? 'senderUserId' : 'fromUserId',
component: 'NInput',
label: '发送用户',
componentProps: { placeholder: '请输入用户ID' },
},
];
if (group)
items.push(
{
field: 'seqId',
component: 'NInputNumber',
label: '消息序号',
componentProps: { min: 1, showButton: false, placeholder: '请输入消息序号' },
},
{
field: 'senderUsername',
component: 'NInput',
label: '发送人用户名',
componentProps: { placeholder: '请输入发送人用户名' },
},
{
field: 'senderNickname',
component: 'NInput',
label: '发送人昵称',
componentProps: { placeholder: '请输入发送人昵称' },
},
{
field: 'groupName',
component: 'NInput',
label: '群名称',
componentProps: { placeholder: '请输入群名称' },
}
);
if (!group)
items.push({
field: 'toUserId',
component: 'NInput',
label: '接收用户',
componentProps: { placeholder: '请输入用户ID' },
});
items.push(
{
field: 'contentType',
component: 'NSelect',
label: '消息类型',
componentProps: {
clearable: true,
options: dict.getOption('ImMessageContentTypeOptions'),
placeholder: '全部类型',
},
},
{
field: 'status',
component: 'NSelect',
label: '消息状态',
componentProps: {
clearable: true,
options: dict.getOption('ImMessageStatusOptions'),
placeholder: '全部状态',
},
},
{
field: 'keyword',
component: 'NInput',
label: '关键词',
componentProps: { placeholder: '请输入内容关键词' },
},
{
field: 'createdAt',
component: 'NDatePicker',
label: '发送时间',
componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() },
}
);
return ref(items);
}
export function createMessageColumns(group: boolean) {
const columns: any[] = [
{ title: '消息序号', key: 'seqId', width: 110 },
{
title: '发送人',
key: 'fromUserId',
width: 190,
render: (row) =>
row.senderNickname
? `${row.senderNickname} / ${row.senderUsername || row.fromUserId}`
: row.senderUsername || row.fromUserId,
},
];
if (group)
columns.push({
title: '群名称',
key: 'groupName',
width: 190,
render: (row) =>
row.groupName ? `${row.groupName} / ${row.groupNo || row.topic}` : row.topic,
});
else
columns.push({
title: '接收人',
key: 'toUserId',
width: 190,
render: (row) =>
row.receiverNickname
? `${row.receiverNickname} / ${row.receiverUsername || row.toUserId}`
: row.receiverUsername || row.toUserId,
});
columns.push(
{
title: '类型',
key: 'contentType',
width: 100,
render: (row) =>
dict.getLabel('ImMessageContentTypeOptions', row.contentType) || row.contentType,
},
{ title: '内容', key: 'contentExcerpt', width: 300, ellipsis: { tooltip: true } },
{
title: '状态',
key: 'status',
width: 110,
render: (row) => dict.getLabel('ImMessageStatusOptions', row.status) || row.status,
},
{ title: '发送时间', key: 'createdAt', width: 180 }
);
return columns;
}
export function loadMessageOptions() {
dict.loadOptions(['ImMessageContentTypeOptions', 'ImMessageStatusOptions']);
}
+228
View File
@@ -0,0 +1,228 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM通知模板" />
</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.scene"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'通知模板 - ' + sceneLabel"
:style="{ width: dialogWidth }"
>
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="90"
>
<n-form-item label="站内通知">
<n-switch v-model:value="inAppEnabled" />
</n-form-item>
<n-form-item label="Push通知">
<n-switch v-model:value="pushEnabled" />
</n-form-item>
<n-form-item label="短信通知">
<n-switch v-model:value="smsEnabled" />
</n-form-item>
<n-form-item label="状态">
<n-select
v-model:value="formValue.status"
:options="statusOptions"
placeholder="请选择状态"
/>
</n-form-item>
<n-form-item label="标题模板" path="titleTemplate">
<n-input v-model:value="formValue.titleTemplate" placeholder="请输入标题模板" />
</n-form-item>
<n-form-item label="内容模板" path="contentTemplate">
<n-input
v-model:value="formValue.contentTemplate"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="请输入内容模板"
/>
</n-form-item>
<n-form-item label="扩展JSON">
<n-input
v-model:value="formValue.payloadJson"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="请输入JSON配置"
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { TemplateList, TemplateUpdate } from '@/api/imNotification';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
columns,
loadOptions,
loadTenantOptions,
newState,
rules,
schemas,
State,
tenantOptions,
} from './model';
const dict = useDictStore();
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const formRef = ref();
const showModal = ref(false);
const submitLoading = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(720));
const statusOptions = dict.getOption('ImNotificationTemplateStatusOptions');
const sceneLabel = computed(
() => dict.getLabel('ImNotificationSceneOptions', formValue.value.scene) || formValue.value.name
);
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const inAppEnabled = computed({
get: () => formValue.value.inAppEnabled === 1,
set: (value: boolean) => {
formValue.value.inAppEnabled = value ? 1 : 0;
},
});
const pushEnabled = computed({
get: () => formValue.value.pushEnabled === 1,
set: (value: boolean) => {
formValue.value.pushEnabled = value ? 1 : 0;
},
});
const smsEnabled = computed({
get: () => formValue.value.smsEnabled === 1,
set: (value: boolean) => {
formValue.value.smsEnabled = value ? 1 : 0;
},
});
const actionColumn = reactive({
width: 100,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imNotification/templateUpdate'],
onClick: () => handleEdit(record),
},
],
});
},
});
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 };
return await TemplateList(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
await setFieldsValue(defaults);
reloadTable();
}
function handleEdit(record: State) {
formValue.value = newState(record);
if (!formValue.value.payloadJson) formValue.value.payloadJson = '{}';
showModal.value = true;
}
async function handleSubmit() {
submitLoading.value = true;
try {
await formRef.value?.validate();
let payloadJson = {};
const text = (formValue.value.payloadJson || '').trim();
if (text) payloadJson = JSON.parse(text);
await TemplateUpdate({ ...formValue.value, payloadJson });
message.success('操作成功');
showModal.value = false;
reloadTable();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用商户');
}
}
reloadTable();
});
</script>
+150
View File
@@ -0,0 +1,150 @@
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 tenantId = 0;
public tenantName = '';
public id = 0;
public scene = '';
public name = '';
public inAppEnabled = 1;
public pushEnabled = 1;
public smsEnabled = 0;
public status = 1;
public titleTemplate = '';
public contentTemplate = '';
public payloadJson = '{}';
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: 'scene',
component: 'NSelect',
label: '场景',
componentProps: {
clearable: true,
options: dict.getOption('ImNotificationSceneOptions'),
placeholder: '请选择场景',
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: dict.getOption('ImNotificationTemplateStatusOptions'),
placeholder: '请选择状态',
},
},
]);
export const columns = [
{ title: '商户', key: 'tenantName', align: 'left', width: 150 },
{
title: '场景',
key: 'scene',
align: 'left',
width: 140,
render(row: State) {
return dict.getLabel('ImNotificationSceneOptions', row.scene) || row.name;
},
},
{
title: '状态',
key: 'status',
align: 'left',
width: 90,
render(row: State) {
return dict.getLabel('ImNotificationTemplateStatusOptions', row.status);
},
},
{
title: '站内',
key: 'inAppEnabled',
align: 'left',
width: 80,
render(row: State) {
return row.inAppEnabled === 1 ? '开启' : '关闭';
},
},
{
title: 'Push',
key: 'pushEnabled',
align: 'left',
width: 80,
render(row: State) {
return row.pushEnabled === 1 ? '开启' : '关闭';
},
},
{
title: '短信',
key: 'smsEnabled',
align: 'left',
width: 80,
render(row: State) {
return row.smsEnabled === 1 ? '开启' : '关闭';
},
},
{ title: '标题模板', key: 'titleTemplate', align: 'left', width: 180 },
{ title: '内容模板', key: 'contentTemplate', align: 'left', width: 260 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const rules = {
titleTemplate: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入标题模板',
},
contentTemplate: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入内容模板',
},
};
export function loadOptions() {
dict.loadOptions(['ImNotificationSceneOptions', 'ImNotificationTemplateStatusOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+543
View File
@@ -0,0 +1,543 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="组织架构" />
</div>
<n-card :bordered="false" class="proCard">
<n-grid :x-gap="16" :y-gap="16" responsive="screen" cols="1 l:2">
<n-grid-item>
<BasicForm
ref="departmentSearchRef"
@register="registerDepartment"
@submit="reloadDepartments"
@reset="handleDepartmentReset"
@keyup.enter="reloadDepartments"
/>
<n-space class="table-toolbar">
<n-button
v-if="hasPermission(['/imOrg/departmentEdit'])"
type="primary"
@click="handleDepartmentAdd"
>
新增部门
</n-button>
<n-button
v-if="hasPermission(['/imOrg/departmentExport'])"
type="primary"
@click="handleDepartmentExport"
>
导出部门
</n-button>
</n-space>
<BasicTable
ref="departmentTableRef"
:columns="departmentColumns"
:request="loadDepartmentTable"
:row-key="(row) => row.departmentId"
:actionColumn="departmentActionColumn"
:scroll-x="departmentScrollX"
:resizeHeightOffset="-10000"
@update:checked-row-keys="handleDepartmentChecked"
/>
</n-grid-item>
<n-grid-item>
<BasicForm
ref="memberSearchRef"
@register="registerMember"
@submit="reloadMembers"
@reset="handleMemberReset"
@keyup.enter="reloadMembers"
/>
<n-space class="table-toolbar">
<n-button
v-if="hasPermission(['/imOrg/memberEdit'])"
type="primary"
:disabled="!selectedDepartment.departmentId"
@click="handleMemberAdd"
>
添加成员
</n-button>
<n-button
v-if="hasPermission(['/imOrg/memberExport'])"
type="primary"
:disabled="!selectedDepartment.departmentId"
@click="handleMemberExport"
>
导出成员
</n-button>
</n-space>
<BasicTable
ref="memberTableRef"
:columns="memberColumns"
:request="loadMemberTable"
:row-key="(row) => row.id"
:actionColumn="memberActionColumn"
:scroll-x="memberScrollX"
:resizeHeightOffset="-10000"
/>
</n-grid-item>
</n-grid>
</n-card>
<n-modal
v-model:show="departmentModalVisible"
preset="dialog"
:show-icon="false"
:title="departmentForm.id > 0 ? '编辑部门' : '新增部门'"
:style="{ width: dialogWidth }"
>
<n-form
ref="departmentFormRef"
:model="departmentForm"
:rules="departmentRules"
label-placement="left"
label-width="90"
>
<n-form-item label="部门名称" path="name">
<n-input v-model:value="departmentForm.name" placeholder="请输入部门名称" />
</n-form-item>
<n-form-item label="上级部门">
<n-select
v-model:value="departmentForm.parentId"
:options="parentDepartmentOptions"
filterable
placeholder="请选择上级部门"
/>
</n-form-item>
<n-form-item label="部门ID">
<n-input
v-model:value="departmentForm.departmentId"
:disabled="departmentForm.id > 0"
placeholder="留空自动生成"
/>
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="departmentForm.sortOrder" />
</n-form-item>
<n-form-item label="状态" path="state">
<n-select
v-model:value="departmentForm.state"
:options="departmentStateOptions"
placeholder="请选择状态"
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="departmentModalVisible = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="submitDepartment"
>保存</n-button
>
</n-space>
</template>
</n-modal>
<n-modal
v-model:show="memberModalVisible"
preset="dialog"
:show-icon="false"
:title="memberForm.id > 0 ? '编辑成员' : '添加成员'"
:style="{ width: dialogWidth }"
>
<n-form
ref="memberFormRef"
:model="memberForm"
:rules="memberRules"
label-placement="left"
label-width="90"
>
<n-form-item label="部门" path="departmentId">
<n-select
v-model:value="memberForm.departmentId"
:options="departmentOptions"
filterable
placeholder="请选择部门"
/>
</n-form-item>
<n-form-item label="用户ID" path="userId">
<n-input
v-model:value="memberForm.userId"
:disabled="memberForm.id > 0"
placeholder="请输入用户ID"
/>
</n-form-item>
<n-form-item label="姓名">
<n-input v-model:value="memberForm.displayName" placeholder="请输入展示姓名" />
</n-form-item>
<n-form-item label="岗位">
<n-input v-model:value="memberForm.position" placeholder="请输入岗位" />
</n-form-item>
<n-form-item label="手机号">
<n-input v-model:value="memberForm.mobile" placeholder="请输入手机号" />
</n-form-item>
<n-form-item label="邮箱">
<n-input v-model:value="memberForm.email" placeholder="请输入邮箱" />
</n-form-item>
<n-form-item label="在职状态" path="employmentState">
<n-select
v-model:value="memberForm.employmentState"
:options="employmentStateOptions"
placeholder="请选择在职状态"
/>
</n-form-item>
<n-form-item label="可联系">
<n-switch v-model:value="memberContactable" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="memberForm.sortOrder" />
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="memberModalVisible = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="submitMember">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
DepartmentDelete,
DepartmentEdit,
DepartmentExport,
DepartmentList,
MemberDelete,
MemberEdit,
MemberExport,
MemberList,
} from '@/api/imOrg';
import {
departmentColumns,
departmentOptions,
departmentRules,
departmentSchemas,
DepartmentState,
loadOptions,
loadTenantOptions,
memberColumns,
memberRules,
memberSchemas,
MemberState,
newDepartmentState,
newMemberState,
tenantOptions,
} from './model';
const dict = useDictStore();
const dialog = useDialog();
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const departmentSearchRef = ref<any>({});
const memberSearchRef = ref<any>({});
const departmentTableRef = ref();
const memberTableRef = ref();
const departmentFormRef = ref();
const memberFormRef = ref();
const selectedDepartment = ref<DepartmentState>(newDepartmentState(null));
const departmentRows = ref<DepartmentState[]>([]);
const departmentForm = ref<DepartmentState>(newDepartmentState(null));
const memberForm = ref<MemberState>(newMemberState(null));
const departmentModalVisible = ref(false);
const memberModalVisible = ref(false);
const submitLoading = ref(false);
const dialogWidth = computed(() => adaModalWidth(640));
const tenantSchema = departmentSchemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const departmentStateOptions = dict.getOption('ImOrgDepartmentStateOptions');
const employmentStateOptions = dict.getOption('ImOrgEmploymentStateOptions');
const parentDepartmentOptions = computed(() => [
{ label: '根部门', value: 'root' },
...departmentOptions.value.filter((item) => item.value !== departmentForm.value.departmentId),
]);
const memberContactable = computed({
get: () => memberForm.value.contactable === 1,
set: (value: boolean) => {
memberForm.value.contactable = value ? 1 : 0;
},
});
const departmentActionColumn = reactive({
width: 180,
title: '操作',
key: 'action',
fixed: 'right',
render(record: DepartmentState) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '成员',
auth: ['/imOrg/memberList'],
onClick: () => selectDepartment(record),
},
{
label: '编辑',
auth: ['/imOrg/departmentEdit'],
onClick: () => handleDepartmentEdit(record),
},
{
label: '删除',
type: 'error',
auth: ['/imOrg/departmentDelete'],
ifShow: () => record.childCount === 0 && record.memberCount === 0,
onClick: () => handleDepartmentDelete(record),
},
],
});
},
});
const memberActionColumn = reactive({
width: 150,
title: '操作',
key: 'action',
fixed: 'right',
render(record: MemberState) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imOrg/memberEdit'],
onClick: () => handleMemberEdit(record),
},
{
label: '移除',
type: 'error',
auth: ['/imOrg/memberDelete'],
onClick: () => handleMemberDelete(record),
},
],
});
},
});
const departmentScrollX = computed(() =>
adaTableScrollX(departmentColumns, departmentActionColumn.width)
);
const memberScrollX = computed(() => adaTableScrollX(memberColumns, memberActionColumn.width));
const [registerDepartment, { setFieldsValue: setDepartmentFields }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:3 2xl:3' },
labelWidth: 90,
schemas: departmentSchemas,
});
const [registerMember, { setFieldsValue: setMemberFields }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:2 xl:2 2xl:2' },
labelWidth: 90,
schemas: memberSchemas,
});
const currentTenantId = computed(() => departmentSearchRef.value?.formModel?.tenantId || 0);
const loadDepartmentTable = async (res) => {
const params = { ...departmentSearchRef.value?.formModel, ...res };
if (isPlatformAdmin.value && !params.tenantId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
const data = await DepartmentList(params);
departmentRows.value = data?.list || [];
departmentOptions.value = departmentRows.value.map((item) => ({
label: `${item.name}(${item.departmentId})`,
value: item.departmentId,
}));
if (!selectedDepartment.value.departmentId && departmentRows.value.length > 0) {
selectedDepartment.value = newDepartmentState(departmentRows.value[0]);
reloadMembers();
}
return data;
};
const loadMemberTable = async (res) => {
if (!selectedDepartment.value.departmentId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
const params = {
...memberSearchRef.value?.formModel,
...res,
tenantId: currentTenantId.value,
departmentId: selectedDepartment.value.departmentId,
};
return await MemberList(params);
};
function reloadDepartments() {
departmentTableRef.value?.reload();
}
function reloadMembers() {
memberTableRef.value?.reload();
}
function handleDepartmentExport() {
const params = { ...departmentSearchRef.value?.formModel };
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请先选择商户');
return;
}
DepartmentExport(params);
}
function handleMemberExport() {
if (!selectedDepartment.value.departmentId) {
message.warning('请先选择部门');
return;
}
MemberExport({
...memberSearchRef.value?.formModel,
tenantId: currentTenantId.value,
departmentId: selectedDepartment.value.departmentId,
});
}
async function handleDepartmentReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
selectedDepartment.value = newDepartmentState(null);
await setDepartmentFields(defaults);
reloadDepartments();
}
async function handleMemberReset() {
await setMemberFields({});
reloadMembers();
}
function handleDepartmentChecked(keys) {
const key = keys?.[0];
const row = departmentRows.value.find((item) => item.departmentId === key);
if (row) selectDepartment(row);
}
function selectDepartment(record: DepartmentState) {
selectedDepartment.value = newDepartmentState(record);
reloadMembers();
}
function handleDepartmentAdd() {
departmentForm.value = newDepartmentState({
tenantId: currentTenantId.value,
parentId: selectedDepartment.value.departmentId || 'root',
state: 'active',
});
departmentModalVisible.value = true;
}
function handleDepartmentEdit(record: DepartmentState) {
departmentForm.value = newDepartmentState(record);
departmentModalVisible.value = true;
}
async function submitDepartment() {
submitLoading.value = true;
try {
await departmentFormRef.value?.validate();
await DepartmentEdit({ ...departmentForm.value, tenantId: currentTenantId.value });
message.success('操作成功');
departmentModalVisible.value = false;
selectedDepartment.value = newDepartmentState(null);
reloadDepartments();
} finally {
submitLoading.value = false;
}
}
function handleDepartmentDelete(record: DepartmentState) {
dialog.error({
title: '删除部门',
content: `确认删除部门 ${record.name}?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await DepartmentDelete({
tenantId: currentTenantId.value,
departmentId: record.departmentId,
});
message.success('操作成功');
selectedDepartment.value = newDepartmentState(null);
reloadDepartments();
reloadMembers();
},
});
}
function handleMemberAdd() {
memberForm.value = newMemberState({
tenantId: currentTenantId.value,
departmentId: selectedDepartment.value.departmentId,
employmentState: 'active',
contactable: 1,
});
memberModalVisible.value = true;
}
function handleMemberEdit(record: MemberState) {
memberForm.value = newMemberState(record);
memberModalVisible.value = true;
}
async function submitMember() {
submitLoading.value = true;
try {
await memberFormRef.value?.validate();
await MemberEdit({ ...memberForm.value, tenantId: currentTenantId.value });
message.success('操作成功');
memberModalVisible.value = false;
reloadDepartments();
reloadMembers();
} finally {
submitLoading.value = false;
}
}
function handleMemberDelete(record: MemberState) {
dialog.error({
title: '移除成员',
content: `确认将 ${record.displayName || record.userId} 从部门移除?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await MemberDelete({ tenantId: currentTenantId.value, id: record.id });
message.success('操作成功');
reloadDepartments();
reloadMembers();
},
});
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setDepartmentFields({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用商户');
}
}
reloadDepartments();
});
</script>
<style lang="less" scoped>
.table-toolbar {
margin-bottom: 12px;
}
</style>
+204
View File
@@ -0,0 +1,204 @@
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 const departmentOptions = ref<any[]>([]);
export class DepartmentState {
public tenantId = 0;
public tenantName = '';
public id = 0;
public departmentId = '';
public name = '';
public parentId = 'root';
public sortOrder = 0;
public version = 1;
public state = 'active';
public childCount = 0;
public memberCount = 0;
public createdAt = '';
public updatedAt = '';
constructor(state?: Partial<DepartmentState>) {
if (state) Object.assign(this, state);
}
}
export class MemberState {
public tenantId = 0;
public tenantName = '';
public id = 0;
public departmentId = '';
public departmentName = '';
public userId = '';
public displayName = '';
public position = '';
public avatarFileId = '';
public mobile = '';
public email = '';
public employmentState = 'active';
public contactable = 1;
public sortOrder = 0;
public version = 1;
public createdAt = '';
public updatedAt = '';
constructor(state?: Partial<MemberState>) {
if (state) Object.assign(this, state);
}
}
export function newDepartmentState(state: DepartmentState | Record<string, any> | null) {
if (state !== null) {
if (state instanceof DepartmentState) return cloneDeep(state);
return new DepartmentState(state);
}
return new DepartmentState();
}
export function newMemberState(state: MemberState | Record<string, any> | null) {
if (state !== null) {
if (state instanceof MemberState) return cloneDeep(state);
return new MemberState(state);
}
return new MemberState();
}
export const departmentSchemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '商户',
componentProps: {
clearable: false,
options: computed(() => tenantOptions.value),
placeholder: '请选择商户',
},
},
{
field: 'name',
component: 'NInput',
label: '部门名称',
componentProps: { placeholder: '请输入部门名称' },
},
{
field: 'state',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: dict.getOption('ImOrgDepartmentStateOptions'),
placeholder: '请选择状态',
},
},
]);
export const memberSchemas = ref<FormSchema[]>([
{
field: 'query',
component: 'NInput',
label: '关键词',
componentProps: { placeholder: '姓名/手机号/邮箱/用户ID' },
},
{
field: 'employmentState',
component: 'NSelect',
label: '在职状态',
componentProps: {
clearable: true,
options: dict.getOption('ImOrgEmploymentStateOptions'),
placeholder: '请选择在职状态',
},
},
]);
export const departmentColumns = [
{ title: '部门ID', key: 'departmentId', align: 'left', width: 170 },
{ title: '部门名称', key: 'name', align: 'left', width: 160 },
{ title: '上级部门', key: 'parentId', align: 'left', width: 130 },
{
title: '状态',
key: 'state',
align: 'left',
width: 90,
render(row: DepartmentState) {
return dict.getLabel('ImOrgDepartmentStateOptions', row.state);
},
},
{ title: '子部门', key: 'childCount', align: 'left', width: 80 },
{ title: '成员', key: 'memberCount', align: 'left', width: 80 },
{ title: '排序', key: 'sortOrder', align: 'left', width: 80 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const memberColumns = [
{ title: '用户ID', key: 'userId', align: 'left', width: 110 },
{ title: '姓名', key: 'displayName', align: 'left', width: 130 },
{ title: '部门', key: 'departmentName', align: 'left', width: 130 },
{ title: '岗位', key: 'position', align: 'left', width: 130 },
{ title: '手机号', key: 'mobile', align: 'left', width: 140 },
{ title: '邮箱', key: 'email', align: 'left', width: 180 },
{
title: '在职状态',
key: 'employmentState',
align: 'left',
width: 100,
render(row: MemberState) {
return dict.getLabel('ImOrgEmploymentStateOptions', row.employmentState);
},
},
{
title: '可联系',
key: 'contactable',
align: 'left',
width: 90,
render(row: MemberState) {
return dict.getLabel('ImOrgContactableOptions', row.contactable);
},
},
{ title: '排序', key: 'sortOrder', align: 'left', width: 80 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const departmentRules = {
name: { required: true, trigger: ['blur', 'input'], type: 'string', message: '请输入部门名称' },
state: { required: true, trigger: ['blur', 'change'], type: 'string', message: '请选择状态' },
};
export const memberRules = {
departmentId: {
required: true,
trigger: ['blur', 'change'],
type: 'string',
message: '请选择部门',
},
userId: { required: true, trigger: ['blur', 'input'], type: 'string', message: '请输入用户ID' },
employmentState: {
required: true,
trigger: ['blur', 'change'],
type: 'string',
message: '请选择在职状态',
},
};
export function loadOptions() {
dict.loadOptions([
'ImOrgDepartmentStateOptions',
'ImOrgEmploymentStateOptions',
'ImOrgContactableOptions',
]);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+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: dict.getOption('ImOutboxSourceOptions'),
placeholder: '请选择来源',
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: dict.getOption('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>
+40
View File
@@ -0,0 +1,40 @@
import { ref } from 'vue';
import { List as TenantList } from '@/api/imTenant';
export const policyTenantOptions = ref<any[]>([{ label: '全局默认', value: 0 }]);
export function getDefaultTenantValue(options: any[]) {
const defaultTenant = options.find(
(item) => item.code === 'default' || String(item.label || '').includes('(default)')
);
return defaultTenant?.value || options.find((item) => Number(item.value) > 0)?.value || 0;
}
export async function loadPolicyTenantOptions(includeGlobal = true) {
const res = await TenantList({ page: 1, perPage: 100 });
const tenants = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
code: item.code,
}));
policyTenantOptions.value = includeGlobal
? [{ label: '全局默认', value: 0 }, ...tenants]
: tenants;
return policyTenantOptions.value;
}
export function parseJSON(value: string, fallback: any) {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
}
export function stringifyJSON(value: any, fallback = '{}') {
try {
return JSON.stringify(value ?? JSON.parse(fallback));
} catch {
return fallback;
}
}
+4
View File
@@ -0,0 +1,4 @@
<template><MessageTable :group="false" /></template>
<script lang="ts" setup>
import MessageTable from '@/views/imMessageShared/MessageTable.vue';
</script>
+28 -8
View File
@@ -1,10 +1,15 @@
<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 ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
@@ -24,6 +29,7 @@
import { useDialog, useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { useUserStore } from '@/store/modules/user';
import { List, Review } from '@/api/imRealname';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
@@ -31,10 +37,17 @@
const message = useMessage();
const dialog = useDialog();
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: 200,
title: '操作',
@@ -44,7 +57,11 @@
return h(TableAction as any, {
style: 'button',
actions: [
{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imRealname/view'] },
{
label: '详情',
onClick: () => viewRef.value.openModal(record),
auth: ['/imRealname/view'],
},
{
label: '通过',
onClick: () => handleReview(record, 'verified'),
@@ -71,7 +88,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);
};
@@ -98,10 +116,12 @@
onMounted(async () => {
loadOptions();
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
reloadTable();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
}
}
reloadTable();
});
</script>
+3 -3
View File
@@ -42,11 +42,11 @@ export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
label: '商户',
componentProps: {
clearable: false,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
placeholder: '请选择商户',
},
},
{
@@ -67,7 +67,7 @@ export const schemas = ref<FormSchema[]>([
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImRealnameStatusOptions')),
options: dict.getOption('ImRealnameStatusOptions'),
placeholder: '请选择状态',
},
},
+26 -8
View File
@@ -1,18 +1,34 @@
<template>
<n-modal v-model:show="showModal" preset="dialog" :show-icon="false" :title="'实名审核详情 #' + formValue.id" :style="{ width: dialogWidth }">
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'实名审核详情 #' + formValue.id"
:style="{ width: dialogWidth }"
>
<n-spin :show="loading">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ formValue.tenantName }}</n-descriptions-item>
<n-descriptions-item label="商户">{{ formValue.tenantName }}</n-descriptions-item>
<n-descriptions-item label="用户ID">{{ formValue.userId }}</n-descriptions-item>
<n-descriptions-item label="提交ID">{{ formValue.submissionId }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
<n-descriptions-item label="脱敏姓名">{{ formValue.maskedName || '-' }}</n-descriptions-item>
<n-descriptions-item label="脱敏证件">{{ formValue.maskedIdNumber || '-' }}</n-descriptions-item>
<n-descriptions-item label="脱敏姓名">{{
formValue.maskedName || '-'
}}</n-descriptions-item>
<n-descriptions-item label="脱敏证件">{{
formValue.maskedIdNumber || '-'
}}</n-descriptions-item>
<n-descriptions-item label="证件类型">{{ formValue.idType || '-' }}</n-descriptions-item>
<n-descriptions-item label="证件哈希">{{ formValue.idNumberHash || '-' }}</n-descriptions-item>
<n-descriptions-item label="通过时间">{{ formValue.verifiedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="证件哈希">{{
formValue.idNumberHash || '-'
}}</n-descriptions-item>
<n-descriptions-item label="通过时间">{{
formValue.verifiedAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="审核原因" :span="2">{{ formValue.reviewReason || '-' }}</n-descriptions-item>
<n-descriptions-item label="审核原因" :span="2">{{
formValue.reviewReason || '-'
}}</n-descriptions-item>
</n-descriptions>
</n-spin>
<template #action>
@@ -35,7 +51,9 @@
const loading = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(820));
const statusLabel = computed(() => dict.getLabel('ImRealnameStatusOptions', formValue.value.status));
const statusLabel = computed(() =>
dict.getLabel('ImRealnameStatusOptions', formValue.value.status)
);
function openModal(state: State) {
showModal.value = true;
+580
View File
@@ -0,0 +1,580 @@
<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="reloadAll" @reset="reloadAll" />
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="policy" tab="策略版本">
<BasicTable
ref="tableRef"
:columns="columns"
:request="loadTable"
:row-key="(r) => r.id"
:actionColumn="actionColumn"
:scroll-x="policyScrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-space
><n-button
type="primary"
:disabled="!hasPermission(['/imSecurityPolicy/save'])"
@click="openPolicy()"
>新建草稿</n-button
><n-button
:disabled="!hasPermission(['/imSecurityPolicy/restoreDefault'])"
@click="restoreDefault"
>恢复默认</n-button
></n-space
></template
>
</BasicTable>
</n-tab-pane>
<n-tab-pane name="ip" tab="IP黑白名单">
<BasicTable
ref="ipTableRef"
:columns="ipColumns"
:request="loadIPTable"
:row-key="(r) => r.id"
:actionColumn="ipActionColumn"
:scroll-x="ipScrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-button
type="primary"
:disabled="!hasPermission(['/imSecurityPolicy/ipRuleEdit'])"
@click="openIPRule()"
>新增IP规则</n-button
></template
>
</BasicTable>
</n-tab-pane>
</n-tabs>
</n-card>
<n-modal
v-model:show="policyVisible"
preset="dialog"
:show-icon="false"
:title="policyForm.id ? '编辑安全策略草稿' : '新建安全策略草稿'"
:style="{ width: dialogWidth }"
>
<n-form
ref="policyFormRef"
:model="policyForm"
:rules="policyRules"
:disabled="policyReadOnly"
label-placement="left"
label-width="132"
>
<n-tabs type="segment" animated>
<n-tab-pane name="friend" tab="好友控制">
<n-form-item label="策略名称" path="policyName"
><n-input v-model:value="policyForm.policyName"
/></n-form-item>
<n-form-item label="允许添加好友"
><n-switch
v-model:value="policyForm.globalFriendEnabled"
:checked-value="1"
:unchecked-value="0"
@update:value="onGlobalFriendChange"
/></n-form-item>
<template v-if="!policyForm.globalFriendEnabled">
<n-form-item label="隐藏个人资料"
><n-switch v-model:value="policyForm.friendPolicy.hide_profile" disabled
/></n-form-item>
<n-form-item label="禁止二维码添加"
><n-switch v-model:value="policyForm.friendPolicy.block_qrcode" disabled
/></n-form-item>
<n-form-item label="禁止群成员入口"
><n-switch
v-model:value="policyForm.friendPolicy.block_group_member_entry"
disabled
/></n-form-item>
<n-form-item label="禁用加好友入口">
<n-checkbox-group v-model:value="policyForm.friendPolicy.blocked_entries" disabled>
<n-space>
<n-checkbox
v-for="option in friendEntryOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</n-space>
</n-checkbox-group>
</n-form-item>
<n-form-item label="移动端提示"
><n-input v-model:value="policyForm.friendPolicy.message"
/></n-form-item>
</template>
</n-tab-pane>
<n-tab-pane name="curfew" tab="消息宵禁">
<n-form-item label="启用消息宵禁"
><n-switch
v-model:value="policyForm.messageCurfewEnabled"
:checked-value="1"
:unchecked-value="0"
/></n-form-item>
<n-space vertical size="large">
<n-card
v-for="(rule, index) in policyForm.curfewRules"
:key="rule.ruleCode"
size="small"
:title="`规则 ${index + 1}`"
>
<template v-if="!policyReadOnly" #header-extra
><n-button quaternary type="error" @click="removeCurfew(index)"
>删除</n-button
></template
>
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="规则编码"
><n-input v-model:value="rule.ruleCode" /></n-form-item-gi
><n-form-item-gi label="规则名称"
><n-input v-model:value="rule.ruleName"
/></n-form-item-gi>
<n-form-item-gi label="日期类型"
><n-select
v-model:value="rule.dateType"
:options="dateOptions" /></n-form-item-gi
><n-form-item-gi label="管控范围"
><n-select v-model:value="rule.scope" :options="scopeOptions"
/></n-form-item-gi>
<n-form-item-gi label="开始时间"
><n-time-picker
v-model:formatted-value="rule.startTime"
format="HH:mm"
value-format="HH:mm" /></n-form-item-gi
><n-form-item-gi label="结束时间"
><n-time-picker
v-model:formatted-value="rule.endTime"
format="HH:mm"
value-format="HH:mm"
/></n-form-item-gi>
<n-form-item-gi label="时区"
><n-input v-model:value="rule.timezone" /></n-form-item-gi
><n-form-item-gi label="状态"
><n-select v-model:value="rule.status" :options="statusOptions"
/></n-form-item-gi>
</n-grid>
<n-form-item label="提示文案"><n-input v-model:value="rule.message" /></n-form-item>
<n-form-item label="豁免用户ID"
><n-input v-model:value="rule.whitelistUserText" placeholder="多个ID用逗号分隔"
/></n-form-item>
</n-card>
<n-button v-if="!policyReadOnly" dashed block @click="addCurfew"
>添加宵禁规则</n-button
>
</n-space>
</n-tab-pane>
<n-tab-pane name="advanced" tab="其他策略">
<n-form-item label="密码策略JSON"
><n-input
v-model:value="policyForm.passwordPolicy"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
/></n-form-item>
<n-form-item label="登录限频JSON"
><n-input
v-model:value="policyForm.loginRateLimit"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
/></n-form-item>
<n-form-item label="实名策略JSON"
><n-input
v-model:value="policyForm.realnamePolicy"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
/></n-form-item>
<n-form-item label="备注"
><n-input v-model:value="policyForm.remark" type="textarea"
/></n-form-item>
</n-tab-pane>
</n-tabs>
</n-form>
<template #action
><n-space
><n-button @click="policyVisible = false">取消</n-button
><n-button v-if="!policyReadOnly" type="primary" :loading="submitting" @click="savePolicy"
>保存草稿</n-button
></n-space
></template
>
</n-modal>
<n-modal
v-model:show="ipVisible"
preset="dialog"
:show-icon="false"
:title="ipForm.id ? '编辑IP规则' : '新增IP规则'"
:style="{ width: '620px' }"
><n-form
ref="ipFormRef"
:model="ipForm"
:rules="ipRules"
label-placement="left"
label-width="100"
><n-grid :cols="2" :x-gap="16"
><n-form-item-gi label="规则类型"
><n-select v-model:value="ipForm.ruleType" :options="ipTypeOptions" /></n-form-item-gi
><n-form-item-gi label="IP/CIDR" path="ipCidr"
><n-input v-model:value="ipForm.ipCidr" /></n-form-item-gi
><n-form-item-gi label="动作"
><n-select v-model:value="ipForm.action" :options="actionOptions" /></n-form-item-gi
><n-form-item-gi label="风险等级"
><n-select v-model:value="ipForm.riskLevel" :options="riskOptions" /></n-form-item-gi
><n-form-item-gi label="状态"
><n-select
v-model:value="ipForm.status"
:options="statusOptions" /></n-form-item-gi></n-grid
><n-form-item label="生效场景"
><n-select
v-model:value="ipForm.scenes"
multiple
:options="ipSceneOptions" /></n-form-item
><n-form-item label="备注"
><n-input v-model:value="ipForm.remark" type="textarea" /></n-form-item></n-form
><template #action
><n-space
><n-button @click="ipVisible = false">取消</n-button
><n-button type="primary" :loading="submitting" @click="saveIPRule"
>保存</n-button
></n-space
></template
></n-modal
>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicForm, useForm } from '@/components/Form';
import { BasicTable, TableAction } from '@/components/Table';
import { useDictStore } from '@/store/modules/dict';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
IPRuleDelete,
IPRuleEdit,
IPRuleList,
List,
Publish,
RestoreDefault,
Save,
View,
} from '@/api/imSecurityPolicy';
import { columns, ipColumns, loadOptions, schemas } from './model';
import {
getDefaultTenantValue,
loadPolicyTenantOptions,
parseJSON,
policyTenantOptions,
} from '@/views/imPolicyShared';
const dict = useDictStore(),
message = useMessage(),
dialog = useDialog(),
userStore = useUserStore();
const { hasPermission } = usePermission();
const searchFormRef = ref<any>({}),
tableRef = ref(),
ipTableRef = ref(),
policyFormRef = ref(),
ipFormRef = ref();
const activeTab = ref('policy'),
policyVisible = ref(false),
ipVisible = ref(false),
submitting = ref(false);
const dialogWidth = computed(() => adaModalWidth(900));
const tenantId = () => Number(searchFormRef.value?.formModel?.tenantId || 0);
const dateOptions = dict.getOption('ImCurfewDateTypeOptions'),
scopeOptions = dict.getOption('ImCurfewScopeOptions'),
statusOptions = dict.getOption('sys_normal_disable'),
actionOptions = dict.getOption('ImSecurityActionOptions'),
friendEntryOptions = dict.getOption('ImFriendEntryOptions'),
riskOptions = dict.getOption('ImRiskLevelOptions');
const ipTypeOptions = [
{ label: '白名单', value: 'whitelist' },
{ label: '黑名单', value: 'blacklist' },
],
ipSceneOptions = [
{ label: '登录', value: 'login' },
{ label: '注册', value: 'register' },
{ label: '发消息', value: 'message' },
{ label: '上传文件', value: 'upload' },
];
const newCurfew = (i = 1) => ({
ruleCode: `work_off_${i}`,
ruleName: '下班时段',
dateType: 'weekday',
customDates: [],
weekdays: [1, 2, 3, 4, 5],
timezone: 'Asia/Shanghai',
startTime: '18:00',
endTime: '09:00',
scope: 'private_message',
whitelistUserIds: [],
whitelistGroupIds: [],
whitelistUserText: '',
message: '当前时段暂不能发送消息',
status: 1,
sort: i * 10,
});
const allFriendEntries = [
'search',
'profile_card',
'group_member',
'qrcode',
'invite_auto_add',
'recommendation',
];
const defaultFriendDisabledPolicy = () => ({
hide_profile: true,
block_qrcode: true,
block_group_member_entry: true,
blocked_entries: [...allFriendEntries],
message: '当前时段暂不允许添加好友',
});
const emptyPolicy = () => ({
id: 0,
tenantId: tenantId(),
policyName: '安全策略',
passwordPolicy: '{}',
loginRateLimit: '{}',
sensitiveWordPolicy: '{}',
fileReviewPolicy: '{}',
realnamePolicy: '{}',
riskChallengePolicy: '{}',
globalFriendEnabled: 1,
friendPolicy: defaultFriendDisabledPolicy(),
messageCurfewEnabled: 0,
remark: '',
curfewRules: [] as any[],
});
const policyForm = ref<any>(emptyPolicy());
const policyReadOnly = computed(
() => policyForm.value.id > 0 && policyForm.value.publishStatus !== 'draft'
);
const policyRules = { policyName: { required: true, message: '请输入策略名称' } };
const emptyIP = () => ({
id: 0,
tenantId: tenantId(),
ruleType: 'blacklist',
ipCidr: '',
action: 'block',
scenes: ['login'],
riskLevel: 'high',
status: 1,
remark: '',
});
const ipForm = ref<any>(emptyIP());
const ipRules = { ipCidr: { required: true, message: '请输入IP或CIDR' } };
const tenantSchema = schemas.value.find((v) => v.field === 'tenantId');
if (tenantSchema) tenantSchema.ifShow = () => userStore.isCompanyDept;
const [register, { setFieldsValue }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3' },
labelWidth: 90,
schemas,
});
const loadTable = (res) => List({ ...searchFormRef.value?.formModel, ...res }),
loadIPTable = (res) => IPRuleList({ tenantId: tenantId(), ...res });
const reloadAll = () => {
tableRef.value?.reload();
ipTableRef.value?.reload();
};
const actionColumn = reactive({
width: 220,
title: '操作',
key: 'action',
fixed: 'right',
render: (row) =>
h(TableAction as any, {
style: 'button',
actions: [
{
label: row.publishStatus === 'draft' ? '编辑' : '查看',
auth: ['/imSecurityPolicy/view'],
onClick: () => openPolicy(row),
},
{
label: '发布',
type: 'warning',
auth: ['/imSecurityPolicy/publish'],
ifShow: () => row.publishStatus === 'draft',
onClick: () => publishPolicy(row),
},
],
}),
});
const ipActionColumn = reactive({
width: 160,
title: '操作',
key: 'action',
fixed: 'right',
render: (row) =>
h(TableAction as any, {
style: 'button',
actions: [
{ label: '编辑', auth: ['/imSecurityPolicy/ipRuleEdit'], onClick: () => openIPRule(row) },
{
label: '删除',
type: 'error',
auth: ['/imSecurityPolicy/ipRuleDelete'],
onClick: () => removeIP(row),
},
],
}),
});
const policyScrollX = computed(() => adaTableScrollX(columns, actionColumn.width)),
ipScrollX = computed(() => adaTableScrollX(ipColumns, ipActionColumn.width));
async function openPolicy(row?: any) {
if (!row) {
policyForm.value = emptyPolicy();
policyVisible.value = true;
return;
}
const data = await View({ id: row.id });
policyForm.value = {
...data,
friendPolicy: {
...defaultFriendDisabledPolicy(),
...parseJSON(data.friendDisabledPolicy, {}),
},
passwordPolicy: data.passwordPolicy || '{}',
loginRateLimit: data.loginRateLimit || '{}',
sensitiveWordPolicy: data.sensitiveWordPolicy || '{}',
fileReviewPolicy: data.fileReviewPolicy || '{}',
realnamePolicy: data.realnamePolicy || '{}',
riskChallengePolicy: data.riskChallengePolicy || '{}',
curfewRules: (data.curfewRules || []).map((v) => ({
...v,
customDates: parseJSON(v.customDates, []),
weekdays: parseJSON(v.weekdays, []),
whitelistUserIds: parseJSON(v.whitelistUserIds, []),
whitelistGroupIds: parseJSON(v.whitelistGroupIds, []),
whitelistUserText: parseJSON(v.whitelistUserIds, []).join(','),
})),
};
policyVisible.value = true;
}
function addCurfew() {
policyForm.value.curfewRules.push(newCurfew(policyForm.value.curfewRules.length + 1));
}
function onGlobalFriendChange(enabled: number) {
if (enabled === 0) {
policyForm.value.friendPolicy = {
...policyForm.value.friendPolicy,
hide_profile: true,
block_qrcode: true,
block_group_member_entry: true,
blocked_entries: [...allFriendEntries],
};
}
}
function removeCurfew(i) {
policyForm.value.curfewRules.splice(i, 1);
}
function jsonObject(text, label) {
try {
return JSON.parse(text || '{}');
} catch {
throw new Error(`${label}不是合法JSON`);
}
}
async function savePolicy() {
submitting.value = true;
try {
await policyFormRef.value?.validate();
const f = policyForm.value;
await Save({
...f,
passwordPolicy: jsonObject(f.passwordPolicy, '密码策略'),
loginRateLimit: jsonObject(f.loginRateLimit, '登录限频'),
sensitiveWordPolicy: jsonObject(f.sensitiveWordPolicy, '敏感词策略'),
fileReviewPolicy: jsonObject(f.fileReviewPolicy, '文件审核策略'),
realnamePolicy: jsonObject(f.realnamePolicy, '实名策略'),
riskChallengePolicy: jsonObject(f.riskChallengePolicy, '风控策略'),
friendDisabledPolicy: f.friendPolicy,
curfewRules: f.curfewRules.map((v) => ({
...v,
whitelistUserIds: v.whitelistUserText
.split(',')
.map((x) => Number(x.trim()))
.filter(Boolean),
})),
});
message.success('草稿已保存');
policyVisible.value = false;
reloadAll();
} catch (e: any) {
message.error(e.message || '保存失败');
} finally {
submitting.value = false;
}
}
function publishPolicy(row) {
dialog.warning({
title: '发布安全策略',
content: `发布版本 ${row.policyVersion} 后将由IM后端执行,确认发布?`,
positiveText: '发布',
negativeText: '取消',
onPositiveClick: async () => {
await Publish({ id: row.id, reason: '管理员发布' });
message.success('发布成功');
reloadAll();
},
});
}
function restoreDefault() {
dialog.warning({
title: '恢复默认策略',
content: '将根据全局默认创建一个新草稿,不会直接发布。',
positiveText: '创建草稿',
negativeText: '取消',
onPositiveClick: async () => {
await RestoreDefault({ tenantId: tenantId(), reason: '管理员恢复默认' });
message.success('默认策略草稿已创建');
reloadAll();
},
});
}
function openIPRule(row?: any) {
ipForm.value = row ? { ...row, scenes: parseJSON(row.sceneJson, []) } : emptyIP();
ipVisible.value = true;
}
async function saveIPRule() {
submitting.value = true;
try {
await ipFormRef.value?.validate();
await IPRuleEdit({ ...ipForm.value, sceneJson: ipForm.value.scenes });
message.success('保存成功');
ipVisible.value = false;
reloadAll();
} finally {
submitting.value = false;
}
}
function removeIP(row) {
dialog.error({
title: '删除IP规则',
content: `确认删除 ${row.ipCidr}?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await IPRuleDelete({ id: row.id });
message.success('删除成功');
reloadAll();
},
});
}
onMounted(async () => {
loadOptions();
if (userStore.isCompanyDept) {
await loadPolicyTenantOptions(true);
await setFieldsValue({ tenantId: getDefaultTenantValue(policyTenantOptions.value) });
}
reloadAll();
});
</script>
+88
View File
@@ -0,0 +1,88 @@
import { computed, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { useDictStore } from '@/store/modules/dict';
import { policyTenantOptions } from '@/views/imPolicyShared';
const dict = useDictStore();
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '商户',
componentProps: { options: computed(() => policyTenantOptions.value), clearable: false },
},
{
field: 'publishStatus',
component: 'NSelect',
label: '发布状态',
componentProps: {
options: dict.getOption('ImSecurityPublishStatusOptions'),
clearable: true,
},
},
]);
export const columns = [
{ title: '版本', key: 'policyVersion', width: 90 },
{ title: '策略名称', key: 'policyName', width: 180 },
{
title: '发布状态',
key: 'publishStatus',
width: 110,
render: (r) => dict.getLabel('ImSecurityPublishStatusOptions', r.publishStatus),
},
{
title: '允许加好友',
key: 'globalFriendEnabled',
width: 110,
render: (r) => (r.globalFriendEnabled ? '允许' : '禁止'),
},
{
title: '消息宵禁',
key: 'messageCurfewEnabled',
width: 100,
render: (r) => (r.messageCurfewEnabled ? '启用' : '关闭'),
},
{ title: '发布时间', key: 'publishedAt', width: 180 },
{ title: '更新时间', key: 'updatedAt', width: 180 },
{ title: '备注', key: 'remark', width: 220 },
];
export const ipColumns = [
{
title: '类型',
key: 'ruleType',
width: 100,
render: (r) => (r.ruleType === 'whitelist' ? '白名单' : '黑名单'),
},
{ title: 'IP/CIDR', key: 'ipCidr', width: 180 },
{
title: '动作',
key: 'action',
width: 110,
render: (r) => dict.getLabel('ImSecurityActionOptions', r.action),
},
{
title: '风险等级',
key: 'riskLevel',
width: 100,
render: (r) => dict.getLabel('ImRiskLevelOptions', r.riskLevel),
},
{
title: '状态',
key: 'status',
width: 90,
render: (r) => dict.getLabel('sys_normal_disable', r.status),
},
{ title: '过期时间', key: 'expiresAt', width: 180 },
{ title: '备注', key: 'remark', width: 200 },
];
export function loadOptions() {
dict.loadOptions([
'ImSecurityPublishStatusOptions',
'ImCurfewDateTypeOptions',
'ImCurfewScopeOptions',
'ImSecurityActionOptions',
'ImFriendEntryOptions',
'ImRiskLevelOptions',
'sys_normal_disable',
]);
}
+319
View File
@@ -0,0 +1,319 @@
<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="reload"
@reset="reload"
@keyup.enter="reload"
/>
<BasicTable
ref="tableRef"
:columns="columns"
:request="loadTable"
:row-key="(r) => r.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-space
><n-button
type="primary"
:disabled="!hasPermission(['/imSensitiveWord/edit'])"
@click="openEdit()"
>新增敏感词</n-button
><n-button
:disabled="!hasPermission(['/imSensitiveWord/batchImport'])"
@click="openImport"
>批量导入</n-button
><n-button
:disabled="!hasPermission(['/imSensitiveWord/groupEdit'])"
@click="groupVisible = true"
>新建分组</n-button
></n-space
></template
>
</BasicTable>
</n-card>
<n-modal
v-model:show="editVisible"
preset="dialog"
:show-icon="false"
:title="wordForm.id ? '编辑敏感词' : '新增敏感词'"
:style="{ width: dialogWidth }"
>
<n-form
ref="wordFormRef"
:model="wordForm"
:rules="wordRules"
label-placement="left"
label-width="100"
>
<n-form-item label="敏感词" path="word"
><n-input v-model:value="wordForm.word"
/></n-form-item>
<n-form-item label="分组" path="groupId"
><n-select v-model:value="wordForm.groupId" :options="groupOptions"
/></n-form-item>
<n-grid :cols="2" :x-gap="16"
><n-form-item-gi label="匹配类型"
><n-select v-model:value="wordForm.matchType" :options="matchOptions" /></n-form-item-gi
><n-form-item-gi label="风险等级"
><n-select v-model:value="wordForm.riskLevel" :options="riskOptions" /></n-form-item-gi
></n-grid>
<n-form-item label="生效场景"
><n-select v-model:value="wordForm.scenes" multiple :options="sceneOptions"
/></n-form-item>
<n-grid :cols="2" :x-gap="16"
><n-form-item-gi label="命中动作"
><n-select v-model:value="wordForm.action" :options="actionOptions" /></n-form-item-gi
><n-form-item-gi label="状态"
><n-select v-model:value="wordForm.status" :options="statusOptions" /></n-form-item-gi
></n-grid>
<n-form-item v-if="wordForm.action === 'replace'" label="替换文本"
><n-input v-model:value="wordForm.replacement"
/></n-form-item>
<n-form-item label="优先级"
><n-input-number v-model:value="wordForm.priority" :min="0"
/></n-form-item>
<n-form-item label="备注"
><n-input v-model:value="wordForm.remark" type="textarea"
/></n-form-item>
</n-form>
<template #action
><n-space
><n-button @click="editVisible = false">取消</n-button
><n-button type="primary" :loading="submitting" @click="submitWord"
>保存</n-button
></n-space
></template
>
</n-modal>
<n-modal
v-model:show="importVisible"
preset="dialog"
:show-icon="false"
title="批量导入"
:style="{ width: dialogWidth }"
>
<n-form label-placement="left" label-width="100"
><n-form-item label="分组"
><n-select v-model:value="importForm.groupId" :options="groupOptions" /></n-form-item
><n-form-item label="敏感词"
><n-input
v-model:value="importForm.text"
type="textarea"
:autosize="{ minRows: 10, maxRows: 18 }"
placeholder="每行一个敏感词" /></n-form-item
></n-form>
<template #action
><n-space
><n-button @click="importVisible = false">取消</n-button
><n-button type="primary" :loading="submitting" @click="submitImport"
>导入</n-button
></n-space
></template
>
</n-modal>
<n-modal
v-model:show="groupVisible"
preset="dialog"
:show-icon="false"
title="新建敏感词分组"
:style="{ width: '520px' }"
><n-form label-placement="left" label-width="90"
><n-form-item label="分组编码"><n-input v-model:value="groupForm.groupCode" /></n-form-item
><n-form-item label="分组名称"><n-input v-model:value="groupForm.groupName" /></n-form-item
><n-form-item label="优先级"
><n-input-number v-model:value="groupForm.priority" :min="0" /></n-form-item></n-form
><template #action
><n-space
><n-button @click="groupVisible = false">取消</n-button
><n-button type="primary" @click="submitGroup">保存</n-button></n-space
></template
></n-modal
>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref, watch } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicForm, useForm } from '@/components/Form';
import { BasicTable, TableAction } from '@/components/Table';
import { useDictStore } from '@/store/modules/dict';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import { BatchImport, Delete, Edit, GroupEdit, GroupList, List } from '@/api/imSensitiveWord';
import { columns, groupOptions, loadOptions, schemas } from './model';
import {
getDefaultTenantValue,
loadPolicyTenantOptions,
parseJSON,
policyTenantOptions,
} from '@/views/imPolicyShared';
const dict = useDictStore(),
message = useMessage(),
dialog = useDialog(),
userStore = useUserStore();
const { hasPermission } = usePermission();
const tableRef = ref(),
searchFormRef = ref<any>({}),
wordFormRef = ref();
const editVisible = ref(false),
importVisible = ref(false),
groupVisible = ref(false),
submitting = ref(false);
const dialogWidth = computed(() => adaModalWidth(680));
const matchOptions = dict.getOption('ImSensitiveMatchTypeOptions'),
actionOptions = dict.getOption('ImSensitiveActionOptions'),
sceneOptions = dict.getOption('ImSensitiveSceneOptions'),
riskOptions = dict.getOption('ImRiskLevelOptions'),
statusOptions = dict.getOption('sys_normal_disable');
const tenantId = () => Number(searchFormRef.value?.formModel?.tenantId || 0);
const emptyWord = () => ({
id: 0,
tenantId: tenantId(),
groupId: groupOptions.value[0]?.value || null,
word: '',
matchType: 'contains',
scenes: ['private_message', 'group_message'],
action: 'block',
replacement: '',
riskLevel: 'medium',
priority: 0,
status: 1,
remark: '',
});
const wordForm = ref<any>(emptyWord());
const importForm = ref<any>({ groupId: null, text: '' });
const groupForm = ref<any>({ groupCode: '', groupName: '', priority: 0, status: 1 });
const wordRules = {
word: { required: true, message: '请输入敏感词' },
groupId: { required: true, type: 'number', message: '请选择分组' },
};
const tenantSchema = schemas.value.find((v) => v.field === 'tenantId');
if (tenantSchema) tenantSchema.ifShow = () => userStore.isCompanyDept;
const [register, { setFieldsValue }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4' },
labelWidth: 90,
schemas,
});
const loadTable = (res) => List({ ...searchFormRef.value?.formModel, ...res });
const reload = () => tableRef.value?.reload();
const actionColumn = reactive({
width: 150,
title: '操作',
key: 'action',
fixed: 'right',
render: (row) =>
h(TableAction as any, {
style: 'button',
actions: [
{ label: '编辑', auth: ['/imSensitiveWord/edit'], onClick: () => openEdit(row) },
{
label: '删除',
type: 'error',
auth: ['/imSensitiveWord/delete'],
onClick: () => removeWord(row),
},
],
}),
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
async function loadGroups() {
const res = await GroupList({ tenantId: tenantId() });
groupOptions.value = (res?.list || [])
.filter((v) => v.status === 1)
.map((v) => ({ label: v.groupName, value: v.id }));
}
function openEdit(row?: any) {
wordForm.value = row ? { ...row, scenes: parseJSON(row.sceneJson, []) } : emptyWord();
editVisible.value = true;
}
async function submitWord() {
submitting.value = true;
try {
await wordFormRef.value?.validate();
const f = wordForm.value;
await Edit({ ...f, sceneJson: f.scenes });
message.success('保存成功');
editVisible.value = false;
reload();
} finally {
submitting.value = false;
}
}
function removeWord(row) {
dialog.error({
title: '删除敏感词',
content: `确认删除“${row.word}”?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await Delete({ id: row.id });
message.success('删除成功');
reload();
},
});
}
function openImport() {
importForm.value = { groupId: groupOptions.value[0]?.value || null, text: '' };
importVisible.value = true;
}
async function submitImport() {
const words = importForm.value.text
.split(/\r?\n/)
.map((v) => v.trim())
.filter(Boolean);
if (!importForm.value.groupId || !words.length)
return message.warning('请选择分组并输入敏感词');
submitting.value = true;
try {
const res = await BatchImport({
tenantId: tenantId(),
groupId: importForm.value.groupId,
words,
matchType: 'contains',
sceneJson: ['private_message', 'group_message'],
action: 'block',
replacement: '',
riskLevel: 'medium',
});
message.success(`导入${res.inserted}条,跳过${res.skipped}条`);
importVisible.value = false;
reload();
} finally {
submitting.value = false;
}
}
async function submitGroup() {
if (!groupForm.value.groupCode || !groupForm.value.groupName)
return message.warning('请填写分组编码和名称');
await GroupEdit({ ...groupForm.value, tenantId: tenantId() });
message.success('保存成功');
groupVisible.value = false;
groupForm.value = { groupCode: '', groupName: '', priority: 0, status: 1 };
await loadGroups();
}
watch(
() => searchFormRef.value?.formModel?.tenantId,
() => loadGroups()
);
onMounted(async () => {
loadOptions();
if (userStore.isCompanyDept) {
await loadPolicyTenantOptions(true);
await setFieldsValue({ tenantId: getDefaultTenantValue(policyTenantOptions.value) });
}
await loadGroups();
reload();
});
</script>
+120
View File
@@ -0,0 +1,120 @@
import { computed, h, ref } from 'vue';
import { NTag } from 'naive-ui';
import { FormSchema } from '@/components/Form';
import { useDictStore } from '@/store/modules/dict';
import { policyTenantOptions } from '@/views/imPolicyShared';
import { renderOptionTag } from '@/utils';
const dict = useDictStore();
export const groupOptions = ref<any[]>([]);
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '商户',
componentProps: { options: computed(() => policyTenantOptions.value), clearable: false },
},
{ field: 'word', component: 'NInput', label: '敏感词', componentProps: { clearable: true } },
{
field: 'groupId',
component: 'NSelect',
label: '分组',
componentProps: { options: computed(() => groupOptions.value), clearable: true },
},
{
field: 'matchType',
component: 'NSelect',
label: '匹配类型',
componentProps: {
options: dict.getOption('ImSensitiveMatchTypeOptions'),
clearable: true,
},
},
{
field: 'action',
component: 'NSelect',
label: '命中动作',
componentProps: {
options: dict.getOption('ImSensitiveActionOptions'),
clearable: true,
},
},
{
field: 'riskLevel',
component: 'NSelect',
label: '风险等级',
componentProps: {
options: dict.getOption('ImRiskLevelOptions'),
clearable: true,
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
options: dict.getOption('sys_normal_disable'),
clearable: true,
},
},
]);
export const columns = [
{ title: '敏感词', key: 'word', width: 180 },
{ title: '分组', key: 'groupName', width: 140 },
{
title: '匹配类型',
key: 'matchType',
width: 110,
render: (r) => dict.getLabel('ImSensitiveMatchTypeOptions', r.matchType),
},
{
title: '生效场景',
key: 'sceneJson',
width: 220,
render: (r) => {
try {
return JSON.parse(r.sceneJson || '[]')
.map((v) => dict.getLabel('ImSensitiveSceneOptions', v) || v)
.join('、');
} catch {
return '';
}
},
},
{
title: '命中动作',
key: 'hitAction',
width: 110,
render: (r) => dict.getLabel('ImSensitiveActionOptions', r.action),
},
{
title: '风险等级',
key: 'riskLevel',
width: 100,
render: (r) => renderOptionTag('ImRiskLevelOptions', r.riskLevel),
},
{
title: '状态',
key: 'status',
width: 90,
render: (r) =>
h(
NTag,
{
type: r.status === 1 ? 'success' : 'error',
bordered: false,
},
{ default: () => (r.status === 1 ? '正常' : '停用') }
),
},
{ title: '创建时间', key: 'createdAt', width: 180 },
];
export function loadOptions() {
dict.loadOptions([
'ImSensitiveMatchTypeOptions',
'ImSensitiveActionOptions',
'ImSensitiveSceneOptions',
'ImRiskLevelOptions',
'sys_normal_disable',
]);
}
+83
View File
@@ -0,0 +1,83 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM任务看板" />
</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"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable } from '@/components/Table';
import { useUserStore } from '@/store/modules/user';
import { List } from '@/api/imTaskDashboard';
import { columns, loadOptions, loadTenantOptions, schemas, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
const message = useMessage();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const scrollX = computed(() => adaTableScrollX(columns, 0));
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 };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
await setFieldsValue({});
reloadTable();
}
onMounted(async () => {
loadOptions();
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>
+88
View File
@@ -0,0 +1,88 @@
import { computed, ref } from 'vue';
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 tenantId = 0;
public tenantName = '';
public tenantCode = '';
public tenantState = '';
public dbStatus = '';
public status = '';
public outboxPendingCount = 0;
public outboxFailedCount = 0;
public groupOutboxPending = 0;
public groupOutboxFailed = 0;
public orphanFileCount = 0;
public deletedFileCount = 0;
public lastOutboxCreatedAt = '';
public lastGroupEventAt = '';
public lastFileUpdatedAt = '';
public recommendedNextAction = '';
}
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: dict.getOption('ImTaskDashboardStatusOptions'),
placeholder: '请选择状态',
},
},
]);
export const columns = [
{ title: '商户', key: 'tenantName', align: 'left', width: 160 },
{ title: '企业码', key: 'tenantCode', align: 'left', width: 120 },
{
title: '状态',
key: 'status',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImTaskDashboardStatusOptions', row.status);
},
},
{ title: 'DB', key: 'dbStatus', align: 'left', width: 90 },
{ title: '业务待投递', key: 'outboxPendingCount', align: 'left', width: 110 },
{ title: '业务失败', key: 'outboxFailedCount', align: 'left', width: 100 },
{ title: '群待投递', key: 'groupOutboxPending', align: 'left', width: 100 },
{ title: '群失败', key: 'groupOutboxFailed', align: 'left', width: 90 },
{ title: '孤儿文件', key: 'orphanFileCount', align: 'left', width: 100 },
{ title: '待物理清理', key: 'deletedFileCount', align: 'left', width: 110 },
{ title: '建议动作', key: 'recommendedNextAction', align: 'left', width: 220 },
{ title: '最新业务事件', key: 'lastOutboxCreatedAt', align: 'left', width: 180 },
{ title: '最新群事件', key: 'lastGroupEventAt', align: 'left', width: 180 },
{ title: '最新文件更新', key: 'lastFileUpdatedAt', align: 'left', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImTaskDashboardStatusOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+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>
+8 -8
View File
@@ -6,7 +6,7 @@
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="formValue.id > 0 ? '编辑IM租户 #' + formValue.id : '添加IM租户'"
:title="formValue.id > 0 ? '编辑IM商户 #' + formValue.id : '添加IM商户'"
:style="{
width: dialogWidth,
}"
@@ -23,13 +23,13 @@
>
<n-grid cols="1 s:1 m:2 l:2 xl:2 2xl:2" responsive="screen">
<n-gi span="1">
<n-form-item label="租户编码" path="code">
<n-input placeholder="请输入租户编码" v-model:value="formValue.code" />
<n-form-item label="商户编码" path="code">
<n-input placeholder="请输入商户编码" v-model:value="formValue.code" />
</n-form-item>
</n-gi>
<n-gi span="1">
<n-form-item label="租户名称" path="name">
<n-input placeholder="请输入租户名称" v-model:value="formValue.name" />
<n-form-item label="商户名称" path="name">
<n-input placeholder="请输入商户名称" v-model:value="formValue.name" />
</n-form-item>
</n-gi>
<n-gi span="1">
@@ -83,10 +83,10 @@
</n-form-item>
</n-gi>
<n-gi span="2">
<n-form-item label="租户描述" path="tenantDesc">
<n-form-item label="商户描述" path="tenantDesc">
<n-input
type="textarea"
placeholder="请输入租户描述"
placeholder="请输入商户描述"
v-model:value="formValue.tenantDesc"
/>
</n-form-item>
@@ -126,7 +126,7 @@
const dialogWidth = computed(() => {
return adaModalWidth(840);
});
const stateOptions = computed(() => dict.getOptions('ImTenantStateOptions'));
const stateOptions = dict.getOption('ImTenantStateOptions');
// 提交表单
function confirmForm(e) {
+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) => {
+32 -13
View File
@@ -10,9 +10,9 @@ const dict = useDictStore();
export class State {
public id = 0; // ID
public code = ''; // 租户编码
public name = ''; // 租户名称
public tenantDesc = ''; // 租户描述
public code = ''; // 商户编码
public name = ''; // 商户名称
public tenantDesc = ''; // 商户描述
public dbName = ''; // 业务库名
public dbDsn = ''; // 独立连接串
public state = 1; // 状态
@@ -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) {
@@ -50,19 +69,19 @@ export const rules = {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入租户编码',
message: '请输入商户编码',
},
name: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入租户名称',
message: '请输入商户名称',
},
state: {
required: true,
trigger: ['blur', 'change'],
type: 'number',
message: '请选择租户状态',
message: '请选择商户状态',
},
};
@@ -71,17 +90,17 @@ export const schemas = ref<FormSchema[]>([
{
field: 'code',
component: 'NInput',
label: '租户编码',
label: '商户编码',
componentProps: {
placeholder: '请输入租户编码',
placeholder: '请输入商户编码',
},
},
{
field: 'name',
component: 'NInput',
label: '租户名称',
label: '商户名称',
componentProps: {
placeholder: '请输入租户名称',
placeholder: '请输入商户名称',
},
},
{
@@ -98,7 +117,7 @@ export const schemas = ref<FormSchema[]>([
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImTenantStateOptions')),
options: dict.getOption('ImTenantStateOptions'),
placeholder: '请选择状态',
},
},
@@ -123,13 +142,13 @@ export const columns = [
width: 70,
},
{
title: '租户编码',
title: '商户编码',
key: 'code',
align: 'left',
width: 150,
},
{
title: '租户名称',
title: '商户名称',
key: 'name',
align: 'left',
width: 180,
+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>
+59
View File
@@ -0,0 +1,59 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'登录设备 #' + (displayNo || 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 displayNo = 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;
displayNo.value = record.displayNo;
userId.value = record.userId;
showModal.value = true;
setTimeout(() => {
actionRef.value?.reload();
});
}
defineExpose({ openModal });
</script>
+101
View File
@@ -0,0 +1,101 @@
<template>
<n-modal v-model:show="showModal" preset="dialog" title="重置IM用户密码" :mask-closable="false">
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
:label-width="90"
>
<n-form-item label="编号">
<n-text>{{ displayNo || '-' }}</n-text>
</n-form-item>
<n-form-item label="新密码" path="newPassword">
<n-input
v-model:value="formValue.newPassword"
type="password"
show-password-on="click"
maxlength="72"
autocomplete="new-password"
/>
</n-form-item>
<n-form-item label="确认密码" path="confirmPassword">
<n-input
v-model:value="formValue.confirmPassword"
type="password"
show-password-on="click"
maxlength="72"
autocomplete="new-password"
/>
</n-form-item>
</n-form>
<template #action>
<n-space justify="end">
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :loading="loading" @click="submit">确认重置</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { ResetPassword } from '@/api/imUser';
import type { State } from './model';
const emit = defineEmits(['reload']);
const message = useMessage();
const formRef = ref();
const showModal = ref(false);
const loading = ref(false);
const tenantId = ref(0);
const displayNo = ref(0);
const userId = ref('');
const formValue = reactive({ newPassword: '', confirmPassword: '' });
const rules = {
newPassword: {
required: true,
validator: (_rule, value: string) => {
if (!value || value.length < 3) return new Error('密码长度不能少于3位');
if (new TextEncoder().encode(value).length > 72) return new Error('密码长度不能超过72字节');
return true;
},
trigger: ['blur', 'input'],
},
confirmPassword: {
required: true,
validator: (_rule, value: string) =>
value === formValue.newPassword ? true : new Error('两次输入的密码不一致'),
trigger: ['blur', 'input'],
},
};
function openModal(record: State) {
tenantId.value = record.tenantId;
displayNo.value = record.displayNo;
userId.value = record.userId;
formValue.newPassword = '';
formValue.confirmPassword = '';
showModal.value = true;
}
async function submit() {
await formRef.value?.validate();
loading.value = true;
try {
await ResetPassword({
tenantId: tenantId.value,
userId: userId.value,
newPassword: formValue.newPassword,
});
message.success('密码已重置,用户原有登录状态已失效');
showModal.value = false;
emit('reload');
} finally {
loading.value = false;
}
}
defineExpose({ openModal });
</script>
+14 -7
View File
@@ -5,13 +5,13 @@
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'用户治理 #' + formValue.userId"
:title="'用户治理 #' + (formValue.displayNo || 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.tenantName || '-' }}</n-descriptions-item>
<n-descriptions-item label="编号">{{ formValue.displayNo || '-' }}</n-descriptions-item>
<n-descriptions-item label="昵称">{{ formValue.nickname || '-' }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ stateLabel }}</n-descriptions-item>
</n-descriptions>
@@ -29,7 +29,12 @@
<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-input
v-model:value="actionForm.reason"
type="textarea"
clearable
placeholder="请输入操作原因"
/>
</n-form-item>
</n-form>
</n-spin>
@@ -68,7 +73,9 @@
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 stateLabel = computed(
() => dict.getLabel('ImUserStateOptions', formValue.value.state) || '-'
);
const actionOptions = [
{ label: '封禁用户', value: 'ban' },
@@ -125,8 +132,8 @@
};
loading.value = true;
try {
const res = await apiMap[actionForm.action](params);
message.success(`命令已提交:${res?.commandId || '-'}`);
await apiMap[actionForm.action](params);
message.success('操作已完成');
emit('reload');
closeForm();
} finally {
+496 -23
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="用户管理" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@@ -19,34 +19,278 @@
: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" />
<GovernanceModal ref="governanceRef" @reload="reloadTable" />
<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 } from 'vue';
import { 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 { List } from '@/api/imUser';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
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 GovernanceModal from './governance.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 governanceRef = 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: 170,
width: 600,
title: '操作',
key: 'action',
fixed: 'right',
@@ -56,13 +300,51 @@
actions: [
{
label: '详情',
type: 'default',
onClick: handleView.bind(null, record),
auth: ['/imUser/view'],
},
{
label: '治理',
onClick: handleGovernance.bind(null, record),
auth: ['/imUser/ban'],
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,
},
],
});
@@ -82,7 +364,7 @@
// 加载表格数据
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
if (!params.tenantId) {
if (isPlatformAdmin.value && !params.tenantId) {
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
}
return await List(params);
@@ -98,20 +380,211 @@
viewRef.value.openModal(record);
}
function handleGovernance(record: Recordable) {
governanceRef.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();
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
reloadTable();
} else {
message.warning('暂无可用租户');
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></style>
<style lang="less" scoped>
:deep(.user-dialog) {
width: min(560px, calc(100vw - 32px));
}
</style>
+133 -68
View File
@@ -1,23 +1,31 @@
import { computed, ref } from 'vue';
import { computed, h, ref } from 'vue';
import { 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 { List as PolicyGroupList } from '@/api/imUserPolicyGroup';
import defaultAvatar from '@/assets/images/schoolboy.png';
const dict = useDictStore();
export const tenantOptions = ref<any[]>([]);
export const policyGroupOptions = ref<any[]>([]);
export class State {
public tenantId = 0; // 租户ID
public tenantName = ''; // 租户名称
public userId = 0; // 用户ID
public tenantId = 0; // 商户ID
public tenantName = ''; // 商户名称
public displayNo = 0; // 后台展示编号
public userId = ''; // 用户ID
public username = ''; // 登录账号
public phone = ''; // 手机号
public nickname = ''; // 昵称
public publicAccount = ''; // 公开账号
public gender = ''; // 性别
public region = ''; // 地区
public state = 0; // 账号状态
public policyGroupId = 0; // 用户策略分组ID
public policyGroupName = ''; // 用户策略分组名称
public authVersion = 0; // 认证版本
public passwordUpdatedAt = ''; // 密码更新时间
public authNames = ''; // 登录标识
@@ -28,10 +36,11 @@ export class State {
public updatedAt = ''; // 修改时间
public signature = ''; // 个性签名
public avatarFileId = ''; // 头像文件ID
public avatar = ''; // 头像地址
public registerType = ''; // 注册方式
public birthDate = ''; // 出生日期
public profileJson = ''; // Tinode Public资料
public trustedJson = ''; // Tinode Trusted资料
public tagsJson = ''; // Tinode标签
public accessJson = ''; // 访问权限
public profileVersion = 0; // 资料版本
@@ -42,6 +51,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) {
@@ -57,35 +81,29 @@ export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
label: '商户',
componentProps: {
clearable: false,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
placeholder: '请选择商户',
},
},
{
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: '全部',
},
},
{
@@ -94,28 +112,17 @@ export const schemas = ref<FormSchema[]>([
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImUserStateOptions')),
placeholder: '请选择状态',
options: dict.getOption('ImUserListStateOptions'),
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: '用户名/昵称/手机号/编号',
},
},
]);
@@ -123,55 +130,90 @@ export const schemas = ref<FormSchema[]>([
// 表格列
export const columns = [
{
title: '用户ID',
key: 'userId',
title: '编号',
key: 'displayNo',
align: 'left',
width: 120,
width: 90,
},
{
title: '头像',
key: 'avatar',
align: 'left',
width: 80,
render(row: State) {
return h(NImage, {
width: 40,
height: 40,
src: row.avatar || defaultAvatar,
fallbackSrc: defaultAvatar,
objectFit: 'cover',
previewDisabled: !row.avatar,
});
},
},
{
title: '用户名',
key: 'username',
align: 'left',
width: 150,
},
{
title: '昵称',
key: 'nickname',
align: 'left',
width: 160,
width: 150,
render(row: State) {
return row.nickname || '--';
},
},
{
title: '公开账号',
key: 'publicAccount',
title: '手机号',
key: 'phone',
align: 'left',
width: 150,
width: 140,
render(row: State) {
return row.phone || '--';
},
},
{
title: '注册方式',
key: 'registerType',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImUserRegisterTypeOptions', row.registerType) || '--';
},
},
{
title: '状态',
key: 'state',
align: 'left',
width: 110,
width: 90,
render(row: State) {
return dict.getLabel('ImUserStateOptions', row.state);
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: '创建时间',
@@ -179,17 +221,31 @@ export const columns = [
align: 'left',
width: 180,
},
];
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: '修改时间',
key: 'updatedAt',
title: 'Push Token',
key: 'pushTokenConfigured',
align: 'left',
width: 180,
width: 120,
render(row: DeviceState) {
return row.pushTokenConfigured ? '已配置' : '未配置';
},
},
{ title: 'Push更新时间', key: 'pushUpdatedAt', align: 'left', width: 180 },
];
// 加载字典数据选项
export function loadOptions() {
dict.loadOptions(['ImUserStateOptions']);
dict.loadOptions(['ImUserStateOptions', 'ImUserListStateOptions', 'ImUserRegisterTypeOptions']);
}
export async function loadTenantOptions() {
@@ -200,3 +256,12 @@ export async function loadTenantOptions() {
}));
return tenantOptions.value;
}
export async function loadPolicyGroupOptions(tenantId: number) {
const res = await PolicyGroupList({ tenantId, status: 1, page: 1, perPage: 200 });
policyGroupOptions.value = (res?.list || []).map((item) => ({
label: item.groupName,
value: item.id,
}));
return policyGroupOptions.value;
}
+29 -14
View File
@@ -6,7 +6,7 @@
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'IM用户详情 #' + formValue.userId"
:title="'IM用户详情 #' + (formValue.displayNo || formValue.userId)"
:style="{
width: dialogWidth,
}"
@@ -14,24 +14,42 @@
<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.tenantName }}</n-descriptions-item>
<n-descriptions-item label="用户ID">{{ formValue.userId }}</n-descriptions-item>
<n-descriptions-item label="商户">{{ formValue.tenantName }}</n-descriptions-item>
<n-descriptions-item label="编号">{{ formValue.displayNo || '-' }}</n-descriptions-item>
<n-descriptions-item label="登录账号">{{ formValue.username }}</n-descriptions-item>
<n-descriptions-item label="手机号">{{ formValue.phone || '-' }}</n-descriptions-item>
<n-descriptions-item label="昵称">{{ formValue.nickname || '-' }}</n-descriptions-item>
<n-descriptions-item label="公开账号">{{ formValue.publicAccount || '-' }}</n-descriptions-item>
<n-descriptions-item label="公开账号">{{
formValue.publicAccount || '-'
}}</n-descriptions-item>
<n-descriptions-item label="状态">{{ stateLabel }}</n-descriptions-item>
<n-descriptions-item label="认证版本">{{ formValue.authVersion }}</n-descriptions-item>
<n-descriptions-item label="登录标识">{{ formValue.authNames || '-' }}</n-descriptions-item>
<n-descriptions-item label="凭证">{{ formValue.credentials || '-' }}</n-descriptions-item>
<n-descriptions-item label="登录标识">{{
formValue.authNames || '-'
}}</n-descriptions-item>
<n-descriptions-item label="凭证">{{
formValue.credentials || '-'
}}</n-descriptions-item>
<n-descriptions-item label="性别">{{ formValue.gender || '-' }}</n-descriptions-item>
<n-descriptions-item label="地区">{{ formValue.region || '-' }}</n-descriptions-item>
<n-descriptions-item label="出生日期">{{ formValue.birthDate || '-' }}</n-descriptions-item>
<n-descriptions-item label="出生日期">{{
formValue.birthDate || '-'
}}</n-descriptions-item>
<n-descriptions-item label="密码更新时间">
{{ formValue.passwordUpdatedAt || '-' }}
</n-descriptions-item>
<n-descriptions-item label="最后在线">{{ formValue.lastSeen || '-' }}</n-descriptions-item>
<n-descriptions-item label="资料版本">{{ formValue.profileVersion }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="修改时间">{{ formValue.updatedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="最后在线">{{
formValue.lastSeen || '-'
}}</n-descriptions-item>
<n-descriptions-item label="资料版本">{{
formValue.profileVersion
}}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{
formValue.createdAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="修改时间">{{
formValue.updatedAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="客户端" :span="2">
{{ formValue.userAgent || '-' }}
</n-descriptions-item>
@@ -50,9 +68,6 @@
<n-descriptions-item label="Trusted资料" :span="2">
<n-code :code="formValue.trustedJson || '-'" language="json" word-wrap />
</n-descriptions-item>
<n-descriptions-item label="Tags" :span="2">
<n-code :code="formValue.tagsJson || '-'" language="json" word-wrap />
</n-descriptions-item>
</n-descriptions>
</n-spin>
</n-scrollbar>
@@ -0,0 +1,95 @@
<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="handleReset"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.tenantId + '-' + row.id"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-button
v-if="hasPermission(['/imUserLoginHistory/export'])"
type="primary"
@click="handleExport"
><template #icon
><n-icon><ExportOutlined /></n-icon></template
>导出</n-button
></template
>
</BasicTable>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { ExportOutlined } from '@vicons/antd';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaTableScrollX } from '@/utils/hotgo';
import { Export, List } from '@/api/imUserLoginHistory';
import { imQueryTenantOptions, loadImQueryTenantOptions } from '@/views/imAdminQueryShared';
import { columns, loadOptions, schemas } from './model';
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) tenantSchema.ifShow = () => isPlatformAdmin.value;
const scrollX = computed(() => adaTableScrollX(columns, 0));
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 List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value) defaults.tenantId = imQueryTenantOptions.value[0]?.value;
await setFieldsValue(defaults);
reloadTable();
}
function handleExport() {
const params = { ...searchFormRef.value?.formModel };
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请选择商户');
return;
}
Export(params);
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadImQueryTenantOptions();
if (imQueryTenantOptions.value.length)
await setFieldsValue({ tenantId: imQueryTenantOptions.value[0].value });
else message.warning('暂无可用商户');
}
reloadTable();
});
</script>
+109
View File
@@ -0,0 +1,109 @@
import { computed, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { useDictStore } from '@/store/modules/dict';
import { imQueryTenantOptions } from '@/views/imAdminQueryShared';
const dict = useDictStore();
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '商户',
componentProps: {
clearable: false,
options: computed(() => imQueryTenantOptions.value),
placeholder: '请选择商户',
},
},
{
field: 'userId',
component: 'NInput',
label: '用户ID',
componentProps: { placeholder: '请输入用户ID' },
},
{
field: 'account',
component: 'NInput',
label: '账号',
componentProps: { placeholder: '请输入手机号/脱敏账号' },
},
{
field: 'loginType',
component: 'NSelect',
label: '登录方式',
componentProps: {
clearable: true,
options: dict.getOption('ImLoginTypeOptions'),
placeholder: '全部方式',
},
},
{
field: 'eventType',
component: 'NSelect',
label: '事件类型',
componentProps: {
clearable: true,
options: dict.getOption('ImLoginEventTypeOptions'),
placeholder: '全部事件',
},
},
{
field: 'platform',
component: 'NSelect',
label: '客户端',
componentProps: {
clearable: true,
options: dict.getOption('ImClientPlatformOptions'),
placeholder: '全部客户端',
},
},
{
field: 'ip',
component: 'NInput',
label: 'IP',
componentProps: { placeholder: '请输入IP地址' },
},
{
field: 'loginAt',
component: 'NDatePicker',
label: '登录时间',
componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() },
},
]);
export const columns = [
{ title: 'ID', key: 'id', width: 90 },
{ title: '用户ID', key: 'userId', width: 130 },
{ title: '用户名', key: 'username', width: 150 },
{ title: '账号', key: 'accountMask', width: 170 },
{
title: '登录方式',
key: 'loginType',
width: 120,
render: (row) => dict.getLabel('ImLoginTypeOptions', row.loginType) || row.loginType,
},
{
title: '事件',
key: 'eventType',
width: 120,
render: (row) => dict.getLabel('ImLoginEventTypeOptions', row.eventType) || row.eventType,
},
{
title: '客户端',
key: 'platform',
width: 110,
render: (row) => dict.getLabel('ImClientPlatformOptions', row.platform) || row.platform,
},
{ title: '版本', key: 'appVersion', width: 100 },
{ title: 'IP', key: 'ip', width: 140 },
{ title: '地区', key: 'region', width: 150 },
{ title: '风险', key: 'riskLevel', width: 90 },
{ title: '登录时间', key: 'loginAt', width: 180 },
{ title: '登出时间', key: 'logoutAt', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImLoginTypeOptions', 'ImLoginEventTypeOptions', 'ImClientPlatformOptions']);
}
+497
View File
@@ -0,0 +1,497 @@
<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"
/>
<n-alert type="info" :show-icon="true" class="whitelist-summary">
商户渠道 IP 白名单 APP:{{
whitelistSummary.appList.length
? `已配置 ${whitelistSummary.appList.length} 条`
: '未配置'
}} Web:{{
whitelistSummary.webList.length
? `已配置 ${whitelistSummary.webList.length} 条`
: '未配置'
}}
</n-alert>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle>
<n-space>
<n-button
type="primary"
:disabled="!hasPermission(['/imUserPolicyGroup/edit'])"
@click="openEdit()"
>新增分组</n-button
>
<n-button
:disabled="!hasPermission(['/imUserPolicyGroup/loginIPWhitelist'])"
@click="openLoginIPWhitelist"
>
<template #icon
><n-icon><ShieldCheckmarkOutline /></n-icon
></template>
IP 白名单
</n-button>
</n-space>
</template>
</BasicTable>
</n-card>
<n-modal
v-model:show="loginIPVisible"
preset="dialog"
title="商户渠道 IP 白名单"
:show-icon="false"
:style="{ width: whitelistDialogWidth }"
>
<n-form label-placement="top">
<n-form-item label="APP 登录白名单">
<n-input
v-model:value="loginIPForm.appText"
type="textarea"
:rows="7"
placeholder="每行一个 IPv4、IPv6 或 CIDR,例如 203.0.113.10"
/>
</n-form-item>
<n-form-item label="Web 登录白名单">
<n-input
v-model:value="loginIPForm.webText"
type="textarea"
:rows="7"
placeholder="每行一个 IPv4、IPv6 或 CIDR,例如 198.51.100.0/24"
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="loginIPVisible = false">取消</n-button>
<n-button
type="primary"
:disabled="!hasPermission(['/imUserPolicyGroup/loginIPWhitelistSave'])"
:loading="submitting"
@click="saveLoginIPWhitelist"
>确定</n-button
>
</n-space>
</template>
</n-modal>
<n-modal
v-model:show="editVisible"
preset="dialog"
:show-icon="false"
:title="editForm.id ? '编辑用户分组' : '新增用户分组'"
:style="{ width: dialogWidth }"
>
<n-form
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-placement="left"
label-width="118"
>
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="分组编码" path="groupCode"
><n-input v-model:value="editForm.groupCode" :disabled="!!editForm.id"
/></n-form-item-gi>
<n-form-item-gi label="分组名称" path="groupName"
><n-input v-model:value="editForm.groupName"
/></n-form-item-gi>
<n-form-item-gi label="优先级"
><n-input-number v-model:value="editForm.priority" :min="0"
/></n-form-item-gi>
<n-form-item-gi label="状态"
><n-select v-model:value="editForm.status" :options="statusOptions"
/></n-form-item-gi>
</n-grid>
<n-divider title-placement="left">好友权限</n-divider>
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="允许添加好友"
><n-switch v-model:value="editForm.policy.allow_add_friend"
/></n-form-item-gi>
<n-form-item-gi label="直接成为好友"
><n-switch v-model:value="editForm.policy.direct_add_friend"
/></n-form-item-gi>
<n-form-item-gi label="允许二维码添加"
><n-switch v-model:value="editForm.policy.allow_qrcode_add_friend"
/></n-form-item-gi>
<n-form-item-gi label="群成员资料可见"
><n-switch v-model:value="editForm.policy.allow_group_member_profile"
/></n-form-item-gi>
<n-form-item-gi label="禁用时隐藏资料"
><n-switch v-model:value="editForm.policy.hide_profile_when_friend_disabled"
/></n-form-item-gi>
<n-form-item-gi label="好友数量上限"
><n-input-number v-model:value="editForm.policy.max_friend_count" :min="0"
/></n-form-item-gi>
</n-grid>
<n-divider title-placement="left">消息与群权限</n-divider>
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="允许单聊"
><n-switch v-model:value="editForm.policy.allow_private_message"
/></n-form-item-gi>
<n-form-item-gi label="允许群聊"
><n-switch v-model:value="editForm.policy.allow_group_message"
/></n-form-item-gi>
<n-form-item-gi label="允许创建群"
><n-switch v-model:value="editForm.policy.allow_create_group"
/></n-form-item-gi>
<n-form-item-gi label="允许加入群"
><n-switch v-model:value="editForm.policy.allow_join_group"
/></n-form-item-gi>
<n-form-item-gi label="允许上传文件"
><n-switch v-model:value="editForm.policy.allow_upload_file"
/></n-form-item-gi>
</n-grid>
<n-form-item label="IP白名单"
><n-input
v-model:value="editForm.ipWhitelistText"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="每行一个IP或CIDR"
/></n-form-item>
<n-form-item label="IP黑名单"
><n-input
v-model:value="editForm.ipBlacklistText"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="每行一个IP或CIDR"
/></n-form-item>
<n-form-item label="备注"
><n-input v-model:value="editForm.remark" type="textarea"
/></n-form-item>
</n-form>
<template #action
><n-space
><n-button @click="editVisible = false">取消</n-button
><n-button type="primary" :loading="submitting" @click="submitEdit"
>保存</n-button
></n-space
></template
>
</n-modal>
<n-modal
v-model:show="memberVisible"
preset="dialog"
:show-icon="false"
title="分组成员"
:style="{ width: dialogWidth }"
>
<n-space class="member-toolbar">
<n-input-number v-model:value="memberUserId" :min="1" placeholder="输入用户ID" />
<n-button
type="primary"
:disabled="!hasPermission(['/imUserPolicyGroup/memberAdd'])"
@click="addMember"
>添加成员</n-button
>
</n-space>
<BasicTable
ref="memberTableRef"
:columns="memberColumns"
:request="loadMemberTable"
:row-key="(row) => row.id"
:pagination="{ pageSize: 10 }"
:resizeHeightOffset="-10000"
/>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicForm, useForm } from '@/components/Form';
import { BasicTable, TableAction } from '@/components/Table';
import { useDictStore } from '@/store/modules/dict';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { ShieldCheckmarkOutline } from '@vicons/ionicons5';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
Delete,
Edit,
List,
MemberAdd,
MemberList,
MemberRemove,
LoginIPWhitelist,
LoginIPWhitelistSave,
Status,
} from '@/api/imUserPolicyGroup';
import { columns, defaultPolicy, loadOptions, schemas } from './model';
import {
getDefaultTenantValue,
loadPolicyTenantOptions,
parseJSON,
policyTenantOptions,
} from '@/views/imPolicyShared';
const dict = useDictStore(),
message = useMessage(),
dialog = useDialog(),
userStore = useUserStore();
const { hasPermission } = usePermission();
const actionRef = ref(),
searchFormRef = ref<any>({}),
editFormRef = ref(),
memberTableRef = ref();
const editVisible = ref(false),
memberVisible = ref(false),
loginIPVisible = ref(false),
submitting = ref(false),
memberUserId = ref<number | null>(null),
activeGroupId = ref(0);
const dialogWidth = computed(() => adaModalWidth(820));
const whitelistDialogWidth = computed(() => adaModalWidth(760));
const whitelistSummary = reactive({
tenantId: 0,
appList: [] as string[],
webList: [] as string[],
});
const loginIPForm = reactive({ appText: '', webText: '' });
const statusOptions = dict.getOption('sys_normal_disable');
const emptyForm = () => ({
id: 0,
tenantId: Number(searchFormRef.value?.formModel?.tenantId || 0),
groupCode: '',
groupName: '',
priority: 0,
status: 1,
policy: { ...defaultPolicy },
ipWhitelistText: '',
ipBlacklistText: '',
remark: '',
});
const editForm = ref<any>(emptyForm());
const editRules = {
groupCode: { required: true, message: '请输入分组编码', trigger: 'blur' },
groupName: { required: true, message: '请输入分组名称', trigger: 'blur' },
};
const tenantSchema = schemas.value.find((v) => v.field === 'tenantId');
if (tenantSchema) tenantSchema.ifShow = () => userStore.isCompanyDept;
const [register, { setFieldsValue }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = (res) => List({ ...searchFormRef.value?.formModel, ...res });
const currentTenantId = () => Number(searchFormRef.value?.formModel?.tenantId || 0);
const reloadTable = () => {
actionRef.value?.reload();
refreshLoginIPSummary();
};
const actionColumn = reactive({
width: 300,
title: '操作',
key: 'action',
fixed: 'right',
render: (row) =>
h(TableAction as any, {
style: 'button',
actions: [
{ label: '编辑', auth: ['/imUserPolicyGroup/edit'], onClick: () => openEdit(row) },
{
label: '成员',
auth: ['/imUserPolicyGroup/memberList'],
ifShow: () => row.tenantId > 0,
onClick: () => openMembers(row),
},
{
label: row.status === 1 ? '停用' : '启用',
auth: ['/imUserPolicyGroup/status'],
onClick: () => changeStatus(row),
},
{
label: '删除',
type: 'error',
auth: ['/imUserPolicyGroup/delete'],
onClick: () => removeGroup(row),
},
],
}),
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
function lines(value: string) {
return value
.split(/\r?\n/)
.map((v) => v.trim())
.filter(Boolean);
}
async function fetchLoginIPWhitelist() {
const tenantId = currentTenantId();
if (userStore.isCompanyDept && tenantId <= 0) {
return null;
}
return await LoginIPWhitelist({ tenantId });
}
async function refreshLoginIPSummary() {
const result = await fetchLoginIPWhitelist();
if (!result) {
Object.assign(whitelistSummary, { tenantId: 0, appList: [], webList: [] });
return;
}
Object.assign(whitelistSummary, {
tenantId: Number(result.tenantId || 0),
appList: result.appList || [],
webList: result.webList || [],
});
}
async function openLoginIPWhitelist() {
const result = await fetchLoginIPWhitelist();
if (!result) {
message.warning('请先选择具体商户');
return;
}
Object.assign(loginIPForm, {
appText: (result.appList || []).join('\n'),
webText: (result.webList || []).join('\n'),
});
loginIPVisible.value = true;
}
async function saveLoginIPWhitelist() {
submitting.value = true;
try {
await LoginIPWhitelistSave({
tenantId: currentTenantId(),
appList: lines(loginIPForm.appText),
webList: lines(loginIPForm.webText),
});
message.success('商户渠道 IP 白名单保存成功');
loginIPVisible.value = false;
await refreshLoginIPSummary();
} finally {
submitting.value = false;
}
}
function openEdit(row?: any) {
editForm.value = row
? {
...row,
policy: { ...defaultPolicy, ...parseJSON(row.policyJson, {}) },
ipWhitelistText: parseJSON(row.ipWhitelist, []).join('\n'),
ipBlacklistText: parseJSON(row.ipBlacklist, []).join('\n'),
}
: emptyForm();
editVisible.value = true;
}
async function submitEdit() {
submitting.value = true;
try {
await editFormRef.value?.validate();
const f = editForm.value;
await Edit({
...f,
policyJson: f.policy,
ipWhitelist: lines(f.ipWhitelistText),
ipBlacklist: lines(f.ipBlacklistText),
});
message.success('保存成功');
editVisible.value = false;
reloadTable();
} finally {
submitting.value = false;
}
}
function changeStatus(row) {
dialog.warning({
title: row.status === 1 ? '停用分组' : '启用分组',
content: `确认${row.status === 1 ? '停用' : '启用'}“${row.groupName}”?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Status({ id: row.id, status: row.status === 1 ? 2 : 1 });
message.success('操作成功');
reloadTable();
},
});
}
function removeGroup(row) {
dialog.error({
title: '删除分组',
content: `确认删除“${row.groupName}”?分组有成员时不可删除。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await Delete({ id: row.id });
message.success('删除成功');
reloadTable();
},
});
}
const memberColumns = [
{ title: '用户ID', key: 'userId', width: 140 },
{ title: '有效开始', key: 'validFrom', width: 180 },
{ title: '有效结束', key: 'validUntil', width: 180 },
{ title: '备注', key: 'remark', width: 180 },
{
title: '操作',
key: 'action',
width: 90,
render: (row) =>
h(TableAction as any, {
style: 'button',
actions: [
{
label: '移除',
type: 'error',
auth: ['/imUserPolicyGroup/memberRemove'],
onClick: () => removeMember(row),
},
],
}),
},
];
function openMembers(row) {
activeGroupId.value = row.id;
memberVisible.value = true;
setTimeout(() => memberTableRef.value?.reload(), 0);
}
const loadMemberTable = (res) => MemberList({ groupId: activeGroupId.value, ...res });
async function addMember() {
if (!memberUserId.value) return message.warning('请输入用户ID');
await MemberAdd({ groupId: activeGroupId.value, userId: memberUserId.value });
memberUserId.value = null;
message.success('添加成功');
memberTableRef.value?.reload();
reloadTable();
}
async function removeMember(row) {
await MemberRemove({ id: row.id });
message.success('移除成功');
memberTableRef.value?.reload();
reloadTable();
}
onMounted(async () => {
loadOptions();
if (userStore.isCompanyDept) {
await loadPolicyTenantOptions(true);
await setFieldsValue({ tenantId: getDefaultTenantValue(policyTenantOptions.value) });
}
reloadTable();
});
</script>
<style lang="less" scoped>
.member-toolbar {
margin-bottom: 16px;
}
.whitelist-summary {
margin-bottom: 16px;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
import { computed, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { useDictStore } from '@/store/modules/dict';
import { policyTenantOptions } from '@/views/imPolicyShared';
const dict = useDictStore();
export const defaultPolicy = {
allow_add_friend: true,
direct_add_friend: false,
max_friend_count: 5000,
allow_qrcode_add_friend: true,
allow_group_member_profile: true,
hide_profile_when_friend_disabled: true,
allow_private_message: true,
allow_group_message: true,
allow_create_group: true,
allow_join_group: true,
allow_upload_file: true,
};
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '商户',
componentProps: { options: computed(() => policyTenantOptions.value), clearable: false },
},
{
field: 'keyword',
component: 'NInput',
label: '关键词',
componentProps: { placeholder: '分组编码或名称', clearable: true },
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
options: dict.getOption('sys_normal_disable'),
clearable: true,
},
},
]);
export const columns = [
{ title: '分组编码', key: 'groupCode', width: 150 },
{ title: '分组名称', key: 'groupName', width: 160 },
{ title: '优先级', key: 'priority', width: 90 },
{ title: '成员数', key: 'memberCount', width: 90 },
{
title: '状态',
key: 'status',
width: 90,
render: (row) => dict.getLabel('sys_normal_disable', row.status),
},
{ title: '版本', key: 'version', width: 80 },
{ title: '备注', key: 'remark', width: 220 },
{ title: '更新时间', key: 'updatedAt', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['sys_normal_disable', 'ImPolicyActionOptions', 'ImFriendEntryOptions']);
}
+34 -15
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
@@ -30,6 +30,7 @@
import { useDialog, useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { useUserStore } from '@/store/modules/user';
import { List, Status } from '@/api/imWorkOrder';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
@@ -37,12 +38,19 @@
const message = useMessage();
const dialog = useDialog();
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: 240,
width: 440,
title: '操作',
key: 'action',
fixed: 'right',
@@ -50,7 +58,11 @@
return h(TableAction as any, {
style: 'button',
actions: [
{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imWorkOrder/view'] },
{
label: '详情',
onClick: () => viewRef.value.openModal(record),
auth: ['/imWorkOrder/view'],
},
{
label: '处理中',
onClick: () => handleStatus(record, 'processing'),
@@ -90,7 +102,8 @@
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
if (!params.tenantId || !params.type) return { list: [], page: 1, pageCount: 0, totalCount: 0 };
if ((isPlatformAdmin.value && !params.tenantId) || !params.type)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
return await List(params);
};
@@ -100,7 +113,8 @@
async function handleReset() {
const defaults: Recordable = { type: 'abuse' };
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();
}
@@ -112,23 +126,28 @@
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
return Status({ tenantId: record.tenantId, type: record.type, id: record.id, status }).then(() => {
message.success('操作成功');
reloadTable();
});
return Status({ tenantId: record.tenantId, type: record.type, id: record.id, status }).then(
() => {
message.success('操作成功');
reloadTable();
}
);
},
});
}
onMounted(async () => {
loadOptions();
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value, type: 'abuse' });
reloadTable();
} else {
message.warning('暂无可用租户');
await setFieldsValue({ type: 'abuse' });
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value, type: 'abuse' });
} else {
message.warning('暂无可用商户');
}
}
reloadTable();
});
</script>
+3 -3
View File
@@ -53,11 +53,11 @@ export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
label: '商户',
componentProps: {
clearable: false,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
placeholder: '请选择商户',
},
},
{
@@ -83,7 +83,7 @@ export const schemas = ref<FormSchema[]>([
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImWorkOrderStatusOptions')),
options: dict.getOption('ImWorkOrderStatusOptions'),
placeholder: '请选择状态',
},
},
+28 -10
View File
@@ -12,19 +12,33 @@
<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.tenantName }}</n-descriptions-item>
<n-descriptions-item label="商户">{{ formValue.tenantName }}</n-descriptions-item>
<n-descriptions-item label="类型">{{ typeLabel }}</n-descriptions-item>
<n-descriptions-item label="工单ID">{{ formValue.orderId }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
<n-descriptions-item label="提交人ID">{{ formValue.userId }}</n-descriptions-item>
<n-descriptions-item label="分类">{{ formValue.category || '-' }}</n-descriptions-item>
<n-descriptions-item label="目标类型">{{ formValue.targetType || '-' }}</n-descriptions-item>
<n-descriptions-item label="目标摘要">{{ formValue.targetSummary || '-' }}</n-descriptions-item>
<n-descriptions-item label="联系方式">{{ formValue.contact || '-' }}</n-descriptions-item>
<n-descriptions-item label="包含诊断">{{ formValue.includeDiagnostics ? '是' : '否' }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="修改时间">{{ formValue.updatedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="内容" :span="2">{{ formValue.content || formValue.description || '-' }}</n-descriptions-item>
<n-descriptions-item label="目标类型">{{
formValue.targetType || '-'
}}</n-descriptions-item>
<n-descriptions-item label="目标摘要">{{
formValue.targetSummary || '-'
}}</n-descriptions-item>
<n-descriptions-item label="联系方式">{{
formValue.contact || '-'
}}</n-descriptions-item>
<n-descriptions-item label="包含诊断">{{
formValue.includeDiagnostics ? '是' : '否'
}}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{
formValue.createdAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="修改时间">{{
formValue.updatedAt || '-'
}}</n-descriptions-item>
<n-descriptions-item label="内容" :span="2">{{
formValue.content || formValue.description || '-'
}}</n-descriptions-item>
<n-descriptions-item label="证据/附件" :span="2">
<n-code :code="formValue.evidenceJson || '-'" language="json" word-wrap />
</n-descriptions-item>
@@ -34,7 +48,9 @@
<n-descriptions-item label="客户端上下文" :span="2">
<n-code :code="formValue.clientContextJson || '-'" language="json" word-wrap />
</n-descriptions-item>
<n-descriptions-item label="幂等键" :span="2">{{ formValue.idempotencyKey || '-' }}</n-descriptions-item>
<n-descriptions-item label="幂等键" :span="2">{{
formValue.idempotencyKey || '-'
}}</n-descriptions-item>
</n-descriptions>
</n-spin>
</n-scrollbar>
@@ -61,7 +77,9 @@
const showModal = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(980));
const statusLabel = computed(() => dict.getLabel('ImWorkOrderStatusOptions', formValue.value.status));
const statusLabel = computed(() =>
dict.getLabel('ImWorkOrderStatusOptions', formValue.value.status)
);
const typeLabel = computed(() => (formValue.value.type === 'abuse' ? '举报' : '反馈'));
function closeForm() {
+291
View File
@@ -0,0 +1,291 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM工作台" />
</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.code"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle>
<n-space>
<n-button
type="primary"
:disabled="!hasPermission(['/imWorkbench/edit']) || !currentTenantReady"
@click="handleCreate"
>
新增模块
</n-button>
<n-button
:disabled="!hasPermission(['/imWorkbench/reorder']) || tableRows.length === 0"
:loading="reorderLoading"
@click="handleReorder"
>
保存排序
</n-button>
</n-space>
</template>
</BasicTable>
</n-card>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="formValue.id > 0 ? '编辑工作台模块' : '新增工作台模块'"
:style="{ width: dialogWidth }"
>
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="92"
>
<n-form-item label="编码" path="code">
<n-input
v-model:value="formValue.code"
:disabled="formValue.id > 0"
placeholder="请输入模块编码"
/>
</n-form-item>
<n-form-item label="名称" path="name">
<n-input v-model:value="formValue.name" placeholder="请输入模块名称" />
</n-form-item>
<n-form-item label="路由" path="route">
<n-select
v-model:value="formValue.route"
:options="routeOptions"
placeholder="请选择客户端路由"
/>
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="formValue.sortOrder" :min="0" />
</n-form-item>
<n-form-item label="图标ID">
<n-input v-model:value="formValue.iconFileId" placeholder="请输入图标文件ID" />
</n-form-item>
<n-form-item label="角标Key">
<n-input v-model:value="formValue.unreadKey" placeholder="请输入未读角标Key" />
</n-form-item>
<n-form-item label="权限" path="permission">
<n-select
v-model:value="formValue.permission"
:options="permissionOptions"
placeholder="请选择权限策略"
/>
</n-form-item>
<n-form-item label="状态">
<n-switch v-model:value="enabledValue" />
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { Delete, Edit, List, Reorder } from '@/api/imWorkbench';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
columns,
loadOptions,
loadTenantOptions,
newState,
rules,
schemas,
State,
tenantOptions,
} from './model';
const dict = useDictStore();
const dialog = useDialog();
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const formRef = ref();
const showModal = ref(false);
const submitLoading = ref(false);
const reorderLoading = ref(false);
const tableRows = ref<State[]>([]);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(640));
const routeOptions = dict.getOption('ImWorkbenchRouteOptions');
const permissionOptions = dict.getOption('ImWorkbenchPermissionOptions');
const currentTenantReady = computed(
() => !isPlatformAdmin.value || !!searchFormRef.value?.formModel?.tenantId
);
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const enabledValue = computed({
get: () => formValue.value.enabled === 1,
set: (value: boolean) => {
formValue.value.enabled = value ? 1 : 0;
},
});
const actionColumn = reactive({
width: 150,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imWorkbench/edit'],
onClick: () => handleEdit(record),
},
{
label: '禁用',
auth: ['/imWorkbench/delete'],
disabled: record.enabled === 0,
onClick: () => handleDelete(record),
},
],
});
},
});
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) {
tableRows.value = [];
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
}
const data = await List(params);
tableRows.value = data?.list || [];
return data;
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
await setFieldsValue(defaults);
reloadTable();
}
function handleCreate() {
formValue.value = newState({
tenantId: searchFormRef.value?.formModel?.tenantId || 0,
enabled: 1,
permission: 'enabled',
sortOrder: (tableRows.value.length + 1) * 10,
});
showModal.value = true;
}
function handleEdit(record: State) {
formValue.value = newState(record);
showModal.value = true;
}
function handleDelete(record: State) {
dialog.warning({
title: '确认禁用',
content: `确定禁用工作台模块「${record.name}」吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
await Delete({ tenantId: record.tenantId, id: record.id, code: record.code });
message.success('操作成功');
reloadTable();
},
});
}
async function handleReorder() {
reorderLoading.value = true;
try {
const tenantId =
searchFormRef.value?.formModel?.tenantId || tableRows.value[0]?.tenantId || 0;
await Reorder({
tenantId,
items: tableRows.value.map((item, index) => ({
code: item.code,
sortOrder: item.sortOrder || (index + 1) * 10,
})),
});
message.success('操作成功');
reloadTable();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
reorderLoading.value = false;
}
}
async function handleSubmit() {
submitLoading.value = true;
try {
await formRef.value?.validate();
await Edit({ ...formValue.value });
message.success('操作成功');
showModal.value = false;
reloadTable();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用商户');
}
}
reloadTable();
});
</script>
+143
View File
@@ -0,0 +1,143 @@
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 tenantId = 0;
public tenantName = '';
public id = 0;
public code = '';
public name = '';
public iconFileId = '';
public route = '';
public sortOrder = 0;
public unreadKey = '';
public enabled = 1;
public permission = 'enabled';
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: 'code',
component: 'NInput',
label: '编码',
componentProps: {
clearable: true,
placeholder: '请输入模块编码',
},
},
{
field: 'name',
component: 'NInput',
label: '名称',
componentProps: {
clearable: true,
placeholder: '请输入模块名称',
},
},
{
field: 'enabled',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: dict.getOption('ImWorkbenchAppEnabledOptions'),
placeholder: '请选择状态',
},
},
]);
export const columns = [
{ title: '编码', key: 'code', align: 'left', width: 150 },
{ title: '名称', key: 'name', align: 'left', width: 140 },
{
title: '路由',
key: 'route',
align: 'left',
width: 160,
render(row: State) {
return dict.getLabel('ImWorkbenchRouteOptions', row.route) || row.route;
},
},
{ title: '排序', key: 'sortOrder', align: 'left', width: 90 },
{ title: '图标ID', key: 'iconFileId', align: 'left', width: 150 },
{ title: '角标Key', key: 'unreadKey', align: 'left', width: 130 },
{
title: '状态',
key: 'enabled',
align: 'left',
width: 90,
render(row: State) {
return dict.getLabel('ImWorkbenchAppEnabledOptions', row.enabled);
},
},
{
title: '权限',
key: 'permission',
align: 'left',
width: 120,
render(row: State) {
return dict.getLabel('ImWorkbenchPermissionOptions', row.permission) || row.permission;
},
},
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const rules = {
code: { required: true, trigger: ['blur', 'input'], type: 'string', message: '请输入模块编码' },
name: { required: true, trigger: ['blur', 'input'], type: 'string', message: '请输入模块名称' },
route: { required: true, trigger: ['blur', 'change'], type: 'string', message: '请选择路由' },
permission: {
required: true,
trigger: ['blur', 'change'],
type: 'string',
message: '请选择权限策略',
},
};
export function loadOptions() {
dict.loadOptions([
'ImWorkbenchAppEnabledOptions',
'ImWorkbenchPermissionOptions',
'ImWorkbenchRouteOptions',
]);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}