chore: seed migration objects batch 6
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,5 @@
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
export function List(params) {
|
||||
return http.request({ url: '/imTaskDashboard/list', method: 'GET', params });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -41,11 +41,11 @@ export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'tenantId',
|
||||
component: 'NSelect',
|
||||
label: '租户',
|
||||
label: '商户',
|
||||
componentProps: {
|
||||
clearable: false,
|
||||
options: computed(() => tenantOptions.value),
|
||||
placeholder: '请选择租户',
|
||||
placeholder: '请选择商户',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: '请选择状态',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><MessageTable :group="true" /></template>
|
||||
<script lang="ts" setup>
|
||||
import MessageTable from '@/views/imMessageShared/MessageTable.vue';
|
||||
</script>
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><MessageTable :group="false" /></template>
|
||||
<script lang="ts" setup>
|
||||
import MessageTable from '@/views/imMessageShared/MessageTable.vue';
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '请选择状态',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: '请选择状态',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user