581 lines
20 KiB
Vue
581 lines
20 KiB
Vue
<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>
|