feat: 完成鸽达达后台安全与审计改造
This commit is contained in:
@@ -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,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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><MessageTable :group="true" /></template>
|
||||
<script lang="ts" setup>
|
||||
import MessageTable from '@/views/imMessageShared/MessageTable.vue';
|
||||
</script>
|
||||
@@ -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,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>
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><MessageTable :group="false" /></template>
|
||||
<script lang="ts" setup>
|
||||
import MessageTable from '@/views/imMessageShared/MessageTable.vue';
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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>
|
||||
@@ -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']);
|
||||
}
|
||||
Reference in New Issue
Block a user