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

581 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="n-layout-page-header"><n-card :bordered="false" title="安全策略" /></div>
<n-card :bordered="false" class="proCard">
<BasicForm ref="searchFormRef" @register="register" @submit="reloadAll" @reset="reloadAll" />
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="policy" tab="策略版本">
<BasicTable
ref="tableRef"
:columns="columns"
:request="loadTable"
:row-key="(r) => r.id"
:actionColumn="actionColumn"
:scroll-x="policyScrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-space
><n-button
type="primary"
:disabled="!hasPermission(['/imSecurityPolicy/save'])"
@click="openPolicy()"
>新建草稿</n-button
><n-button
:disabled="!hasPermission(['/imSecurityPolicy/restoreDefault'])"
@click="restoreDefault"
>恢复默认</n-button
></n-space
></template
>
</BasicTable>
</n-tab-pane>
<n-tab-pane name="ip" tab="IP黑白名单">
<BasicTable
ref="ipTableRef"
:columns="ipColumns"
:request="loadIPTable"
:row-key="(r) => r.id"
:actionColumn="ipActionColumn"
:scroll-x="ipScrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle
><n-button
type="primary"
:disabled="!hasPermission(['/imSecurityPolicy/ipRuleEdit'])"
@click="openIPRule()"
>新增IP规则</n-button
></template
>
</BasicTable>
</n-tab-pane>
</n-tabs>
</n-card>
<n-modal
v-model:show="policyVisible"
preset="dialog"
:show-icon="false"
:title="policyForm.id ? '编辑安全策略草稿' : '新建安全策略草稿'"
:style="{ width: dialogWidth }"
>
<n-form
ref="policyFormRef"
:model="policyForm"
:rules="policyRules"
:disabled="policyReadOnly"
label-placement="left"
label-width="132"
>
<n-tabs type="segment" animated>
<n-tab-pane name="friend" tab="好友控制">
<n-form-item label="策略名称" path="policyName"
><n-input v-model:value="policyForm.policyName"
/></n-form-item>
<n-form-item label="允许添加好友"
><n-switch
v-model:value="policyForm.globalFriendEnabled"
:checked-value="1"
:unchecked-value="0"
@update:value="onGlobalFriendChange"
/></n-form-item>
<template v-if="!policyForm.globalFriendEnabled">
<n-form-item label="隐藏个人资料"
><n-switch v-model:value="policyForm.friendPolicy.hide_profile" disabled
/></n-form-item>
<n-form-item label="禁止二维码添加"
><n-switch v-model:value="policyForm.friendPolicy.block_qrcode" disabled
/></n-form-item>
<n-form-item label="禁止群成员入口"
><n-switch
v-model:value="policyForm.friendPolicy.block_group_member_entry"
disabled
/></n-form-item>
<n-form-item label="禁用加好友入口">
<n-checkbox-group v-model:value="policyForm.friendPolicy.blocked_entries" disabled>
<n-space>
<n-checkbox
v-for="option in friendEntryOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</n-space>
</n-checkbox-group>
</n-form-item>
<n-form-item label="移动端提示"
><n-input v-model:value="policyForm.friendPolicy.message"
/></n-form-item>
</template>
</n-tab-pane>
<n-tab-pane name="curfew" tab="消息宵禁">
<n-form-item label="启用消息宵禁"
><n-switch
v-model:value="policyForm.messageCurfewEnabled"
:checked-value="1"
:unchecked-value="0"
/></n-form-item>
<n-space vertical size="large">
<n-card
v-for="(rule, index) in policyForm.curfewRules"
:key="rule.ruleCode"
size="small"
:title="`规则 ${index + 1}`"
>
<template v-if="!policyReadOnly" #header-extra
><n-button quaternary type="error" @click="removeCurfew(index)"
>删除</n-button
></template
>
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="规则编码"
><n-input v-model:value="rule.ruleCode" /></n-form-item-gi
><n-form-item-gi label="规则名称"
><n-input v-model:value="rule.ruleName"
/></n-form-item-gi>
<n-form-item-gi label="日期类型"
><n-select
v-model:value="rule.dateType"
:options="dateOptions" /></n-form-item-gi
><n-form-item-gi label="管控范围"
><n-select v-model:value="rule.scope" :options="scopeOptions"
/></n-form-item-gi>
<n-form-item-gi label="开始时间"
><n-time-picker
v-model:formatted-value="rule.startTime"
format="HH:mm"
value-format="HH:mm" /></n-form-item-gi
><n-form-item-gi label="结束时间"
><n-time-picker
v-model:formatted-value="rule.endTime"
format="HH:mm"
value-format="HH:mm"
/></n-form-item-gi>
<n-form-item-gi label="时区"
><n-input v-model:value="rule.timezone" /></n-form-item-gi
><n-form-item-gi label="状态"
><n-select v-model:value="rule.status" :options="statusOptions"
/></n-form-item-gi>
</n-grid>
<n-form-item label="提示文案"><n-input v-model:value="rule.message" /></n-form-item>
<n-form-item label="豁免用户ID"
><n-input v-model:value="rule.whitelistUserText" placeholder="多个ID用逗号分隔"
/></n-form-item>
</n-card>
<n-button v-if="!policyReadOnly" dashed block @click="addCurfew"
>添加宵禁规则</n-button
>
</n-space>
</n-tab-pane>
<n-tab-pane name="advanced" tab="其他策略">
<n-form-item label="密码策略JSON"
><n-input
v-model:value="policyForm.passwordPolicy"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
/></n-form-item>
<n-form-item label="登录限频JSON"
><n-input
v-model:value="policyForm.loginRateLimit"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
/></n-form-item>
<n-form-item label="实名策略JSON"
><n-input
v-model:value="policyForm.realnamePolicy"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
/></n-form-item>
<n-form-item label="备注"
><n-input v-model:value="policyForm.remark" type="textarea"
/></n-form-item>
</n-tab-pane>
</n-tabs>
</n-form>
<template #action
><n-space
><n-button @click="policyVisible = false">取消</n-button
><n-button v-if="!policyReadOnly" type="primary" :loading="submitting" @click="savePolicy"
>保存草稿</n-button
></n-space
></template
>
</n-modal>
<n-modal
v-model:show="ipVisible"
preset="dialog"
:show-icon="false"
:title="ipForm.id ? '编辑IP规则' : '新增IP规则'"
:style="{ width: '620px' }"
><n-form
ref="ipFormRef"
:model="ipForm"
:rules="ipRules"
label-placement="left"
label-width="100"
><n-grid :cols="2" :x-gap="16"
><n-form-item-gi label="规则类型"
><n-select v-model:value="ipForm.ruleType" :options="ipTypeOptions" /></n-form-item-gi
><n-form-item-gi label="IP/CIDR" path="ipCidr"
><n-input v-model:value="ipForm.ipCidr" /></n-form-item-gi
><n-form-item-gi label="动作"
><n-select v-model:value="ipForm.action" :options="actionOptions" /></n-form-item-gi
><n-form-item-gi label="风险等级"
><n-select v-model:value="ipForm.riskLevel" :options="riskOptions" /></n-form-item-gi
><n-form-item-gi label="状态"
><n-select
v-model:value="ipForm.status"
:options="statusOptions" /></n-form-item-gi></n-grid
><n-form-item label="生效场景"
><n-select
v-model:value="ipForm.scenes"
multiple
:options="ipSceneOptions" /></n-form-item
><n-form-item label="备注"
><n-input v-model:value="ipForm.remark" type="textarea" /></n-form-item></n-form
><template #action
><n-space
><n-button @click="ipVisible = false">取消</n-button
><n-button type="primary" :loading="submitting" @click="saveIPRule"
>保存</n-button
></n-space
></template
></n-modal
>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicForm, useForm } from '@/components/Form';
import { BasicTable, TableAction } from '@/components/Table';
import { useDictStore } from '@/store/modules/dict';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
IPRuleDelete,
IPRuleEdit,
IPRuleList,
List,
Publish,
RestoreDefault,
Save,
View,
} from '@/api/imSecurityPolicy';
import { columns, ipColumns, loadOptions, schemas } from './model';
import {
getDefaultTenantValue,
loadPolicyTenantOptions,
parseJSON,
policyTenantOptions,
} from '@/views/imPolicyShared';
const dict = useDictStore(),
message = useMessage(),
dialog = useDialog(),
userStore = useUserStore();
const { hasPermission } = usePermission();
const searchFormRef = ref<any>({}),
tableRef = ref(),
ipTableRef = ref(),
policyFormRef = ref(),
ipFormRef = ref();
const activeTab = ref('policy'),
policyVisible = ref(false),
ipVisible = ref(false),
submitting = ref(false);
const dialogWidth = computed(() => adaModalWidth(900));
const tenantId = () => Number(searchFormRef.value?.formModel?.tenantId || 0);
const dateOptions = dict.getOption('ImCurfewDateTypeOptions'),
scopeOptions = dict.getOption('ImCurfewScopeOptions'),
statusOptions = dict.getOption('sys_normal_disable'),
actionOptions = dict.getOption('ImSecurityActionOptions'),
friendEntryOptions = dict.getOption('ImFriendEntryOptions'),
riskOptions = dict.getOption('ImRiskLevelOptions');
const ipTypeOptions = [
{ label: '白名单', value: 'whitelist' },
{ label: '黑名单', value: 'blacklist' },
],
ipSceneOptions = [
{ label: '登录', value: 'login' },
{ label: '注册', value: 'register' },
{ label: '发消息', value: 'message' },
{ label: '上传文件', value: 'upload' },
];
const newCurfew = (i = 1) => ({
ruleCode: `work_off_${i}`,
ruleName: '下班时段',
dateType: 'weekday',
customDates: [],
weekdays: [1, 2, 3, 4, 5],
timezone: 'Asia/Shanghai',
startTime: '18:00',
endTime: '09:00',
scope: 'private_message',
whitelistUserIds: [],
whitelistGroupIds: [],
whitelistUserText: '',
message: '当前时段暂不能发送消息',
status: 1,
sort: i * 10,
});
const allFriendEntries = [
'search',
'profile_card',
'group_member',
'qrcode',
'invite_auto_add',
'recommendation',
];
const defaultFriendDisabledPolicy = () => ({
hide_profile: true,
block_qrcode: true,
block_group_member_entry: true,
blocked_entries: [...allFriendEntries],
message: '当前时段暂不允许添加好友',
});
const emptyPolicy = () => ({
id: 0,
tenantId: tenantId(),
policyName: '安全策略',
passwordPolicy: '{}',
loginRateLimit: '{}',
sensitiveWordPolicy: '{}',
fileReviewPolicy: '{}',
realnamePolicy: '{}',
riskChallengePolicy: '{}',
globalFriendEnabled: 1,
friendPolicy: defaultFriendDisabledPolicy(),
messageCurfewEnabled: 0,
remark: '',
curfewRules: [] as any[],
});
const policyForm = ref<any>(emptyPolicy());
const policyReadOnly = computed(
() => policyForm.value.id > 0 && policyForm.value.publishStatus !== 'draft'
);
const policyRules = { policyName: { required: true, message: '请输入策略名称' } };
const emptyIP = () => ({
id: 0,
tenantId: tenantId(),
ruleType: 'blacklist',
ipCidr: '',
action: 'block',
scenes: ['login'],
riskLevel: 'high',
status: 1,
remark: '',
});
const ipForm = ref<any>(emptyIP());
const ipRules = { ipCidr: { required: true, message: '请输入IP或CIDR' } };
const tenantSchema = schemas.value.find((v) => v.field === 'tenantId');
if (tenantSchema) tenantSchema.ifShow = () => userStore.isCompanyDept;
const [register, { setFieldsValue }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3' },
labelWidth: 90,
schemas,
});
const loadTable = (res) => List({ ...searchFormRef.value?.formModel, ...res }),
loadIPTable = (res) => IPRuleList({ tenantId: tenantId(), ...res });
const reloadAll = () => {
tableRef.value?.reload();
ipTableRef.value?.reload();
};
const actionColumn = reactive({
width: 220,
title: '操作',
key: 'action',
fixed: 'right',
render: (row) =>
h(TableAction as any, {
style: 'button',
actions: [
{
label: row.publishStatus === 'draft' ? '编辑' : '查看',
auth: ['/imSecurityPolicy/view'],
onClick: () => openPolicy(row),
},
{
label: '发布',
type: 'warning',
auth: ['/imSecurityPolicy/publish'],
ifShow: () => row.publishStatus === 'draft',
onClick: () => publishPolicy(row),
},
],
}),
});
const ipActionColumn = reactive({
width: 160,
title: '操作',
key: 'action',
fixed: 'right',
render: (row) =>
h(TableAction as any, {
style: 'button',
actions: [
{ label: '编辑', auth: ['/imSecurityPolicy/ipRuleEdit'], onClick: () => openIPRule(row) },
{
label: '删除',
type: 'error',
auth: ['/imSecurityPolicy/ipRuleDelete'],
onClick: () => removeIP(row),
},
],
}),
});
const policyScrollX = computed(() => adaTableScrollX(columns, actionColumn.width)),
ipScrollX = computed(() => adaTableScrollX(ipColumns, ipActionColumn.width));
async function openPolicy(row?: any) {
if (!row) {
policyForm.value = emptyPolicy();
policyVisible.value = true;
return;
}
const data = await View({ id: row.id });
policyForm.value = {
...data,
friendPolicy: {
...defaultFriendDisabledPolicy(),
...parseJSON(data.friendDisabledPolicy, {}),
},
passwordPolicy: data.passwordPolicy || '{}',
loginRateLimit: data.loginRateLimit || '{}',
sensitiveWordPolicy: data.sensitiveWordPolicy || '{}',
fileReviewPolicy: data.fileReviewPolicy || '{}',
realnamePolicy: data.realnamePolicy || '{}',
riskChallengePolicy: data.riskChallengePolicy || '{}',
curfewRules: (data.curfewRules || []).map((v) => ({
...v,
customDates: parseJSON(v.customDates, []),
weekdays: parseJSON(v.weekdays, []),
whitelistUserIds: parseJSON(v.whitelistUserIds, []),
whitelistGroupIds: parseJSON(v.whitelistGroupIds, []),
whitelistUserText: parseJSON(v.whitelistUserIds, []).join(','),
})),
};
policyVisible.value = true;
}
function addCurfew() {
policyForm.value.curfewRules.push(newCurfew(policyForm.value.curfewRules.length + 1));
}
function onGlobalFriendChange(enabled: number) {
if (enabled === 0) {
policyForm.value.friendPolicy = {
...policyForm.value.friendPolicy,
hide_profile: true,
block_qrcode: true,
block_group_member_entry: true,
blocked_entries: [...allFriendEntries],
};
}
}
function removeCurfew(i) {
policyForm.value.curfewRules.splice(i, 1);
}
function jsonObject(text, label) {
try {
return JSON.parse(text || '{}');
} catch {
throw new Error(`${label}不是合法JSON`);
}
}
async function savePolicy() {
submitting.value = true;
try {
await policyFormRef.value?.validate();
const f = policyForm.value;
await Save({
...f,
passwordPolicy: jsonObject(f.passwordPolicy, '密码策略'),
loginRateLimit: jsonObject(f.loginRateLimit, '登录限频'),
sensitiveWordPolicy: jsonObject(f.sensitiveWordPolicy, '敏感词策略'),
fileReviewPolicy: jsonObject(f.fileReviewPolicy, '文件审核策略'),
realnamePolicy: jsonObject(f.realnamePolicy, '实名策略'),
riskChallengePolicy: jsonObject(f.riskChallengePolicy, '风控策略'),
friendDisabledPolicy: f.friendPolicy,
curfewRules: f.curfewRules.map((v) => ({
...v,
whitelistUserIds: v.whitelistUserText
.split(',')
.map((x) => Number(x.trim()))
.filter(Boolean),
})),
});
message.success('草稿已保存');
policyVisible.value = false;
reloadAll();
} catch (e: any) {
message.error(e.message || '保存失败');
} finally {
submitting.value = false;
}
}
function publishPolicy(row) {
dialog.warning({
title: '发布安全策略',
content: `发布版本 ${row.policyVersion} 后将由IM后端执行,确认发布?`,
positiveText: '发布',
negativeText: '取消',
onPositiveClick: async () => {
await Publish({ id: row.id, reason: '管理员发布' });
message.success('发布成功');
reloadAll();
},
});
}
function restoreDefault() {
dialog.warning({
title: '恢复默认策略',
content: '将根据全局默认创建一个新草稿,不会直接发布。',
positiveText: '创建草稿',
negativeText: '取消',
onPositiveClick: async () => {
await RestoreDefault({ tenantId: tenantId(), reason: '管理员恢复默认' });
message.success('默认策略草稿已创建');
reloadAll();
},
});
}
function openIPRule(row?: any) {
ipForm.value = row ? { ...row, scenes: parseJSON(row.sceneJson, []) } : emptyIP();
ipVisible.value = true;
}
async function saveIPRule() {
submitting.value = true;
try {
await ipFormRef.value?.validate();
await IPRuleEdit({ ...ipForm.value, sceneJson: ipForm.value.scenes });
message.success('保存成功');
ipVisible.value = false;
reloadAll();
} finally {
submitting.value = false;
}
}
function removeIP(row) {
dialog.error({
title: '删除IP规则',
content: `确认删除 ${row.ipCidr}?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await IPRuleDelete({ id: row.id });
message.success('删除成功');
reloadAll();
},
});
}
onMounted(async () => {
loadOptions();
if (userStore.isCompanyDept) {
await loadPolicyTenantOptions(true);
await setFieldsValue({ tenantId: getDefaultTenantValue(policyTenantOptions.value) });
}
reloadAll();
});
</script>