feat: 完成鸽达达后台安全与审计改造

This commit is contained in:
richjohnrambo
2026-09-07 11:50:29 +08:00
parent ab2b84c9fa
commit bb87d01644
196 changed files with 19970 additions and 280 deletions
+9
View File
@@ -0,0 +1,9 @@
import { http } from '@/utils/http/axios';
export function Overview(params) {
return http.request({ url: '/imDashboard/overview', method: 'GET', params });
}
export function Trends(params) {
return http.request({ url: '/imDashboard/trends', method: 'GET', params });
}
+17
View File
@@ -0,0 +1,17 @@
import { http, jumpExport } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imGroupMessage/list', method: 'GET', params });
}
export function View(params) {
return http.request({ url: '/imGroupMessage/view', method: 'GET', params });
}
export function Hide(params) {
return http.request({ url: '/imGroupMessage/hide', method: 'POST', params });
}
export function Delete(params) {
return http.request({ url: '/imGroupMessage/delete', method: 'POST', params });
}
export function Export(params) {
jumpExport('/imGroupMessage/export', params);
}
+17
View File
@@ -0,0 +1,17 @@
import { http, jumpExport } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imLoginFailureLog/list', method: 'GET', params });
}
export function View(params) {
return http.request({ url: '/imLoginFailureLog/view', method: 'GET', params });
}
export function Export(params) {
jumpExport('/imLoginFailureLog/export', params);
}
export function AddBlacklist(params) {
return http.request({ url: '/imLoginFailureLog/addBlacklist', method: 'POST', params });
}
export function BanUser(params) {
return http.request({ url: '/imLoginFailureLog/banUser', method: 'POST', params });
}
+17
View File
@@ -0,0 +1,17 @@
import { http, jumpExport } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imPrivateMessage/list', method: 'GET', params });
}
export function View(params) {
return http.request({ url: '/imPrivateMessage/view', method: 'GET', params });
}
export function Hide(params) {
return http.request({ url: '/imPrivateMessage/hide', method: 'POST', params });
}
export function Delete(params) {
return http.request({ url: '/imPrivateMessage/delete', method: 'POST', params });
}
export function Export(params) {
jumpExport('/imPrivateMessage/export', params);
}
+18
View File
@@ -0,0 +1,18 @@
import { http } from '@/utils/http/axios';
export const List = (params) =>
http.request({ url: '/imSecurityPolicy/list', method: 'GET', params });
export const View = (params) =>
http.request({ url: '/imSecurityPolicy/view', method: 'GET', params });
export const Save = (params) =>
http.request({ url: '/imSecurityPolicy/save', method: 'POST', params });
export const Publish = (params) =>
http.request({ url: '/imSecurityPolicy/publish', method: 'POST', params });
export const RestoreDefault = (params) =>
http.request({ url: '/imSecurityPolicy/restoreDefault', method: 'POST', params });
export const IPRuleList = (params) =>
http.request({ url: '/imSecurityPolicy/ipRuleList', method: 'GET', params });
export const IPRuleEdit = (params) =>
http.request({ url: '/imSecurityPolicy/ipRuleEdit', method: 'POST', params });
export const IPRuleDelete = (params) =>
http.request({ url: '/imSecurityPolicy/ipRuleDelete', method: 'POST', params });
+16
View File
@@ -0,0 +1,16 @@
import { http } from '@/utils/http/axios';
export const List = (params) =>
http.request({ url: '/imSensitiveWord/list', method: 'GET', params });
export const Edit = (params) =>
http.request({ url: '/imSensitiveWord/edit', method: 'POST', params });
export const Delete = (params) =>
http.request({ url: '/imSensitiveWord/delete', method: 'POST', params });
export const Status = (params) =>
http.request({ url: '/imSensitiveWord/status', method: 'POST', params });
export const BatchImport = (params) =>
http.request({ url: '/imSensitiveWord/batchImport', method: 'POST', params });
export const GroupList = (params) =>
http.request({ url: '/imSensitiveWord/groupList', method: 'GET', params });
export const GroupEdit = (params) =>
http.request({ url: '/imSensitiveWord/groupEdit', method: 'POST', params });
+8
View File
@@ -0,0 +1,8 @@
import { http, jumpExport } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imUserLoginHistory/list', method: 'GET', params });
}
export function Export(params) {
jumpExport('/imUserLoginHistory/export', params);
}
+18
View File
@@ -0,0 +1,18 @@
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 });
+13
View File
@@ -0,0 +1,13 @@
import { ref } from 'vue';
import { List as TenantList } from '@/api/imTenant';
export const imQueryTenantOptions = ref<any[]>([]);
export async function loadImQueryTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
imQueryTenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return imQueryTenantOptions.value;
}
+106
View File
@@ -0,0 +1,106 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="数据概览">
<template #header-extra>
<n-space align="center">
<n-select
v-if="isPlatformAdmin"
v-model:value="tenantId"
:options="imQueryTenantOptions"
class="tenant-select"
placeholder="请选择租户"
/>
<n-select v-model:value="days" :options="dayOptions" class="days-select" />
<n-button type="primary" :loading="loading" @click="loadDashboard">刷新</n-button>
</n-space>
</template>
</n-card>
</div>
<n-card :bordered="false" class="proCard">
<n-grid cols="1 s:2 m:3" responsive="screen" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in metrics" :key="item.key">
<n-statistic :label="item.label" :value="overview[item.key] || 0" />
</n-grid-item>
</n-grid>
<n-divider />
<n-h3>最近 {{ days }} 天增长趋势</n-h3>
<n-data-table
:columns="trendColumns"
:data="trends"
:loading="loading"
:pagination="false"
:bordered="false"
/>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { useUserStore } from '@/store/modules/user';
import { Overview, Trends } from '@/api/imDashboard';
import { imQueryTenantOptions, loadImQueryTenantOptions } from '@/views/imAdminQueryShared';
const message = useMessage();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const tenantId = ref(0);
const days = ref(7);
const loading = ref(false);
const overview = ref<Recordable>({});
const trends = ref<any[]>([]);
const dayOptions = [
{ label: '最近7天', value: 7 },
{ label: '最近14天', value: 14 },
{ label: '最近30天', value: 30 },
];
const metrics = [
{ key: 'totalUsers', label: '总用户数' },
{ key: 'activeUsers', label: '活跃用户' },
{ key: 'totalGroups', label: '总群组数' },
{ key: 'totalConversations', label: '总会话数' },
{ key: 'totalMessages', label: '总消息数' },
{ key: 'todayMessages', label: '今日消息' },
];
const trendColumns = [
{ title: '日期', key: 'date' },
{ title: '新增用户', key: 'userCount' },
{ title: '新增消息', key: 'messageCount' },
];
async function loadDashboard() {
if (isPlatformAdmin.value && !tenantId.value) {
message.warning('请选择租户');
return;
}
loading.value = true;
try {
const params = { tenantId: tenantId.value, days: days.value };
const [summary, trendRes] = await Promise.all([Overview(params), Trends(params)]);
overview.value = summary || {};
trends.value = trendRes?.list || [];
} finally {
loading.value = false;
}
}
onMounted(async () => {
if (isPlatformAdmin.value) {
await loadImQueryTenantOptions();
tenantId.value = imQueryTenantOptions.value[0]?.value || 0;
if (!tenantId.value) message.warning('暂无可用租户');
}
if (!isPlatformAdmin.value || tenantId.value) await loadDashboard();
});
</script>
<style lang="less" scoped>
.tenant-select {
width: 240px;
}
.days-select {
width: 120px;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
<template><MessageTable :group="true" /></template>
<script lang="ts" setup>
import MessageTable from '@/views/imMessageShared/MessageTable.vue';
</script>
+220
View File
@@ -0,0 +1,220 @@
<template>
<div>
<div class="n-layout-page-header"><n-card :bordered="false" title="登录失败日志" /></div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="handleReset"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.tenantId + '-' + row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-button
v-if="hasPermission(['/imLoginFailureLog/export'])"
type="primary"
@click="handleExport"
><template #icon
><n-icon><ExportOutlined /></n-icon></template
>导出</n-button
></template
>
</BasicTable>
</n-card>
<n-modal v-model:show="showDetail" preset="card" title="失败详情" class="detail-modal">
<n-descriptions bordered label-placement="left" :column="1">
<n-descriptions-item label="用户ID">{{ current.userId || '未识别' }}</n-descriptions-item
><n-descriptions-item label="脱敏账号">{{ current.accountMask }}</n-descriptions-item>
<n-descriptions-item label="失败类型">{{ current.failureType }}</n-descriptions-item
><n-descriptions-item label="原因码">{{ current.reason }}</n-descriptions-item>
<n-descriptions-item label="失败详情">{{ current.detail }}</n-descriptions-item
><n-descriptions-item label="IP / 地区"
>{{ current.ip }} / {{ current.region }}</n-descriptions-item
>
<n-descriptions-item label="设备"
>{{ current.platform }} {{ current.appVersion }} /
{{ current.deviceId }}</n-descriptions-item
><n-descriptions-item label="User-Agent">{{ current.userAgent }}</n-descriptions-item>
<n-descriptions-item label="风险"
>{{ current.riskLevel }} / {{ current.riskAction }}</n-descriptions-item
><n-descriptions-item label="请求ID">{{ current.requestId }}</n-descriptions-item>
<n-descriptions-item label="尝试时间">{{ current.attemptAt }}</n-descriptions-item>
</n-descriptions>
</n-modal>
<n-modal v-model:show="showBan" preset="card" title="封禁用户" class="action-modal">
<n-form-item label="封禁原因" required
><n-input
v-model:value="banReason"
type="textarea"
maxlength="500"
show-count
placeholder="请输入封禁原因"
/></n-form-item>
<template #footer
><n-space justify="end"
><n-button @click="showBan = false">取消</n-button
><n-button type="error" :loading="submitting" @click="submitBan"
>确认封禁</n-button
></n-space
></template
>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { ExportOutlined } from '@vicons/antd';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaTableScrollX } from '@/utils/hotgo';
import { AddBlacklist, BanUser, Export, List, View } from '@/api/imLoginFailureLog';
import { imQueryTenantOptions, loadImQueryTenantOptions } from '@/views/imAdminQueryShared';
import { columns, loadOptions, schemas } from './model';
const message = useMessage();
const dialog = useDialog();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const showDetail = ref(false);
const showBan = ref(false);
const submitting = ref(false);
const banReason = ref('');
const current = ref<Recordable>({});
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) tenantSchema.ifShow = () => isPlatformAdmin.value;
const actionColumn = reactive({
width: 260,
title: '操作',
key: 'action',
fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{ label: '详情', auth: ['/imLoginFailureLog/view'], onClick: () => openDetail(record) },
{
label: 'IP黑名单',
type: 'warning',
auth: ['/imLoginFailureLog/addBlacklist'],
ifShow: () => !!record.ip,
onClick: () => addBlacklist(record),
},
{
label: '封禁用户',
type: 'error',
auth: ['/imLoginFailureLog/banUser'],
ifShow: () => Number(record.userId) > 0,
onClick: () => openBan(record),
},
],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const [register, { setFieldsValue }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
if (isPlatformAdmin.value && !params.tenantId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
return List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value) defaults.tenantId = imQueryTenantOptions.value[0]?.value;
await setFieldsValue(defaults);
reloadTable();
}
function handleExport() {
const params = { ...searchFormRef.value?.formModel };
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请选择租户');
return;
}
Export(params);
}
async function openDetail(record) {
current.value = await View({ tenantId: record.tenantId, id: record.id });
showDetail.value = true;
}
function addBlacklist(record) {
dialog.warning({
title: '加入IP黑名单',
content: `确认禁止 ${record.ip} 继续登录吗?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await AddBlacklist({
tenantId: record.tenantId,
ip: record.ip,
remark: `登录失败日志 #${record.id}`,
});
message.success('已加入IP黑名单');
},
});
}
function openBan(record) {
current.value = record;
banReason.value = '';
showBan.value = true;
}
async function submitBan() {
if (!banReason.value.trim()) {
message.warning('请输入封禁原因');
return;
}
submitting.value = true;
try {
const res = await BanUser({
tenantId: current.value.tenantId,
userId: Number(current.value.userId),
reason: banReason.value.trim(),
});
message.success(`封禁命令已提交:${res?.commandId || ''}`);
showBan.value = false;
} finally {
submitting.value = false;
}
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadImQueryTenantOptions();
if (imQueryTenantOptions.value.length)
await setFieldsValue({ tenantId: imQueryTenantOptions.value[0].value });
else message.warning('暂无可用租户');
}
reloadTable();
});
</script>
<style lang="less" scoped>
.detail-modal {
width: min(760px, 92vw);
}
.action-modal {
width: min(520px, 92vw);
}
</style>
+92
View File
@@ -0,0 +1,92 @@
import { computed, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { useDictStore } from '@/store/modules/dict';
import { imQueryTenantOptions } from '@/views/imAdminQueryShared';
const dict = useDictStore();
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
componentProps: {
clearable: false,
options: computed(() => imQueryTenantOptions.value),
placeholder: '请选择租户',
},
},
{
field: 'userId',
component: 'NInput',
label: '用户ID',
componentProps: { placeholder: '请输入用户ID' },
},
{
field: 'account',
component: 'NInput',
label: '账号',
componentProps: { placeholder: '请输入脱敏账号' },
},
{
field: 'failureType',
component: 'NSelect',
label: '失败类型',
componentProps: {
clearable: true,
options: dict.getOption('ImLoginFailureTypeOptions'),
placeholder: '全部类型',
},
},
{
field: 'platform',
component: 'NSelect',
label: '客户端',
componentProps: {
clearable: true,
options: dict.getOption('ImClientPlatformOptions'),
placeholder: '全部客户端',
},
},
{
field: 'ip',
component: 'NInput',
label: 'IP',
componentProps: { placeholder: '请输入IP地址' },
},
{
field: 'attemptAt',
component: 'NDatePicker',
label: '尝试时间',
componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() },
},
]);
export const columns = [
{ title: 'ID', key: 'id', width: 90 },
{ title: '用户ID', key: 'userId', width: 130 },
{ title: '账号', key: 'accountMask', width: 170 },
{
title: '失败类型',
key: 'failureType',
width: 140,
render: (row) => dict.getLabel('ImLoginFailureTypeOptions', row.failureType) || row.failureType,
},
{ title: '原因码', key: 'reason', width: 180 },
{ title: 'IP', key: 'ip', width: 140 },
{ title: '地区', key: 'region', width: 150 },
{
title: '客户端',
key: 'platform',
width: 110,
render: (row) => dict.getLabel('ImClientPlatformOptions', row.platform) || row.platform,
},
{ title: '风险', key: 'riskLevel', width: 90 },
{ title: '风控动作', key: 'riskAction', width: 120 },
{ title: '尝试时间', key: 'attemptAt', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImLoginFailureTypeOptions', 'ImClientPlatformOptions']);
}
@@ -0,0 +1,232 @@
<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 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) => {
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() {
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;
}
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('暂无可用租户');
}
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>
+131
View File
@@ -0,0 +1,131 @@
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: '请输入Topic' },
},
{
field: group ? 'senderUserId' : 'fromUserId',
component: 'NInput',
label: '发送用户',
componentProps: { placeholder: '请输入用户ID' },
},
];
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']);
}
+32
View File
@@ -0,0 +1,32 @@
import { ref } from 'vue';
import { List as TenantList } from '@/api/imTenant';
export const policyTenantOptions = ref<any[]>([{ label: '全局默认', 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,
}));
policyTenantOptions.value = includeGlobal
? [{ label: '全局默认', value: 0 }, ...tenants]
: tenants;
return policyTenantOptions.value;
}
export function parseJSON(value: string, fallback: any) {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
}
export function stringifyJSON(value: any, fallback = '{}') {
try {
return JSON.stringify(value ?? JSON.parse(fallback));
} catch {
return fallback;
}
}
+4
View File
@@ -0,0 +1,4 @@
<template><MessageTable :group="false" /></template>
<script lang="ts" setup>
import MessageTable from '@/views/imMessageShared/MessageTable.vue';
</script>
+568
View File
@@ -0,0 +1,568 @@
<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 { loadPolicyTenantOptions, parseJSON } 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 = computed(() => dict.getOptions('ImCurfewDateTypeOptions')),
scopeOptions = computed(() => dict.getOptions('ImCurfewScopeOptions')),
statusOptions = computed(() => dict.getOptions('sys_normal_disable')),
actionOptions = computed(() => dict.getOptions('ImSecurityActionOptions')),
friendEntryOptions = computed(() => dict.getOptions('ImFriendEntryOptions')),
riskOptions = computed(() => dict.getOptions('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] = 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);
reloadAll();
});
</script>
+88
View File
@@ -0,0 +1,88 @@
import { computed, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { useDictStore } from '@/store/modules/dict';
import { policyTenantOptions } from '@/views/imPolicyShared';
const dict = useDictStore();
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
componentProps: { options: computed(() => policyTenantOptions.value), clearable: false },
},
{
field: 'publishStatus',
component: 'NSelect',
label: '发布状态',
componentProps: {
options: computed(() => dict.getOptions('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',
]);
}
+330
View File
@@ -0,0 +1,330 @@
<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,
Status,
} from '@/api/imSensitiveWord';
import { columns, groupOptions, loadOptions, schemas } from './model';
import { loadPolicyTenantOptions, parseJSON } 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 = computed(() => dict.getOptions('ImSensitiveMatchTypeOptions')),
actionOptions = computed(() => dict.getOptions('ImSensitiveActionOptions')),
sceneOptions = computed(() => dict.getOptions('ImSensitiveSceneOptions')),
riskOptions = computed(() => dict.getOptions('ImRiskLevelOptions')),
statusOptions = computed(() => dict.getOptions('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] = 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: 230,
title: '操作',
key: 'action',
fixed: 'right',
render: (row) =>
h(TableAction as any, {
style: 'button',
actions: [
{ label: '编辑', auth: ['/imSensitiveWord/edit'], onClick: () => openEdit(row) },
{
label: row.status === 1 ? '停用' : '启用',
auth: ['/imSensitiveWord/status'],
onClick: () => changeStatus(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 changeStatus(row) {
Status({ id: row.id, status: row.status === 1 ? 2 : 1 }).then(() => {
message.success('操作成功');
reload();
});
}
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 loadGroups();
reload();
});
</script>
+110
View File
@@ -0,0 +1,110 @@
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 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: computed(() => dict.getOptions('ImSensitiveMatchTypeOptions')),
clearable: true,
},
},
{
field: 'action',
component: 'NSelect',
label: '命中动作',
componentProps: {
options: computed(() => dict.getOptions('ImSensitiveActionOptions')),
clearable: true,
},
},
{
field: 'riskLevel',
component: 'NSelect',
label: '风险等级',
componentProps: {
options: computed(() => dict.getOptions('ImRiskLevelOptions')),
clearable: true,
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
options: computed(() => dict.getOptions('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: 'action',
width: 110,
render: (r) => dict.getLabel('ImSensitiveActionOptions', 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: 'updatedAt', width: 180 },
];
export function loadOptions() {
dict.loadOptions([
'ImSensitiveMatchTypeOptions',
'ImSensitiveActionOptions',
'ImSensitiveSceneOptions',
'ImRiskLevelOptions',
'sys_normal_disable',
]);
}
@@ -0,0 +1,95 @@
<template>
<div>
<div class="n-layout-page-header"><n-card :bordered="false" title="用户登录历史" /></div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="handleReset"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.tenantId + '-' + row.id"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-button
v-if="hasPermission(['/imUserLoginHistory/export'])"
type="primary"
@click="handleExport"
><template #icon
><n-icon><ExportOutlined /></n-icon></template
>导出</n-button
></template
>
</BasicTable>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { ExportOutlined } from '@vicons/antd';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaTableScrollX } from '@/utils/hotgo';
import { Export, List } from '@/api/imUserLoginHistory';
import { imQueryTenantOptions, loadImQueryTenantOptions } from '@/views/imAdminQueryShared';
import { columns, loadOptions, schemas } from './model';
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) tenantSchema.ifShow = () => isPlatformAdmin.value;
const scrollX = computed(() => adaTableScrollX(columns, 0));
const [register, { setFieldsValue }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
if (isPlatformAdmin.value && !params.tenantId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
return List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value) defaults.tenantId = imQueryTenantOptions.value[0]?.value;
await setFieldsValue(defaults);
reloadTable();
}
function handleExport() {
const params = { ...searchFormRef.value?.formModel };
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请选择租户');
return;
}
Export(params);
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadImQueryTenantOptions();
if (imQueryTenantOptions.value.length)
await setFieldsValue({ tenantId: imQueryTenantOptions.value[0].value });
else message.warning('暂无可用租户');
}
reloadTable();
});
</script>
+109
View File
@@ -0,0 +1,109 @@
import { computed, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { useDictStore } from '@/store/modules/dict';
import { imQueryTenantOptions } from '@/views/imAdminQueryShared';
const dict = useDictStore();
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
componentProps: {
clearable: false,
options: computed(() => imQueryTenantOptions.value),
placeholder: '请选择租户',
},
},
{
field: 'userId',
component: 'NInput',
label: '用户ID',
componentProps: { placeholder: '请输入用户ID' },
},
{
field: 'account',
component: 'NInput',
label: '账号',
componentProps: { placeholder: '请输入手机号/脱敏账号' },
},
{
field: 'loginType',
component: 'NSelect',
label: '登录方式',
componentProps: {
clearable: true,
options: dict.getOption('ImLoginTypeOptions'),
placeholder: '全部方式',
},
},
{
field: 'eventType',
component: 'NSelect',
label: '事件类型',
componentProps: {
clearable: true,
options: dict.getOption('ImLoginEventTypeOptions'),
placeholder: '全部事件',
},
},
{
field: 'platform',
component: 'NSelect',
label: '客户端',
componentProps: {
clearable: true,
options: dict.getOption('ImClientPlatformOptions'),
placeholder: '全部客户端',
},
},
{
field: 'ip',
component: 'NInput',
label: 'IP',
componentProps: { placeholder: '请输入IP地址' },
},
{
field: 'loginAt',
component: 'NDatePicker',
label: '登录时间',
componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() },
},
]);
export const columns = [
{ title: 'ID', key: 'id', width: 90 },
{ title: '用户ID', key: 'userId', width: 130 },
{ title: '用户名', key: 'username', width: 150 },
{ title: '账号', key: 'accountMask', width: 170 },
{
title: '登录方式',
key: 'loginType',
width: 120,
render: (row) => dict.getLabel('ImLoginTypeOptions', row.loginType) || row.loginType,
},
{
title: '事件',
key: 'eventType',
width: 120,
render: (row) => dict.getLabel('ImLoginEventTypeOptions', row.eventType) || row.eventType,
},
{
title: '客户端',
key: 'platform',
width: 110,
render: (row) => dict.getLabel('ImClientPlatformOptions', row.platform) || row.platform,
},
{ title: '版本', key: 'appVersion', width: 100 },
{ title: 'IP', key: 'ip', width: 140 },
{ title: '地区', key: 'region', width: 150 },
{ title: '风险', key: 'riskLevel', width: 90 },
{ title: '登录时间', key: 'loginAt', width: 180 },
{ title: '登出时间', key: 'logoutAt', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImLoginTypeOptions', 'ImLoginEventTypeOptions', 'ImClientPlatformOptions']);
}
+364
View File
@@ -0,0 +1,364 @@
<template>
<div>
<div class="n-layout-page-header"><n-card :bordered="false" title="用户分组" /></div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-button
type="primary"
:disabled="!hasPermission(['/imUserPolicyGroup/edit'])"
@click="openEdit()"
>新增分组</n-button
></template
>
</BasicTable>
</n-card>
<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 { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
Delete,
Edit,
List,
MemberAdd,
MemberList,
MemberRemove,
Status,
} from '@/api/imUserPolicyGroup';
import { columns, defaultPolicy, loadOptions, schemas } from './model';
import { loadPolicyTenantOptions, parseJSON } 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),
submitting = ref(false),
memberUserId = ref<number | null>(null),
activeGroupId = ref(0);
const dialogWidth = computed(() => adaModalWidth(820));
const statusOptions = computed(() => dict.getOptions('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] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = (res) => List({ ...searchFormRef.value?.formModel, ...res });
const reloadTable = () => actionRef.value?.reload();
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);
}
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);
reloadTable();
});
</script>
<style lang="less" scoped>
.member-toolbar {
margin-bottom: 16px;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
import { computed, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { useDictStore } from '@/store/modules/dict';
import { policyTenantOptions } from '@/views/imPolicyShared';
const dict = useDictStore();
export const defaultPolicy = {
allow_add_friend: true,
direct_add_friend: false,
max_friend_count: 5000,
allow_qrcode_add_friend: true,
allow_group_member_profile: true,
hide_profile_when_friend_disabled: true,
allow_private_message: true,
allow_group_message: true,
allow_create_group: true,
allow_join_group: true,
allow_upload_file: true,
};
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
componentProps: { options: computed(() => policyTenantOptions.value), clearable: false },
},
{
field: 'keyword',
component: 'NInput',
label: '关键词',
componentProps: { placeholder: '分组编码或名称', clearable: true },
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
options: computed(() => dict.getOptions('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']);
}