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

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