feat: 将安全策略模板化,新建商户时自动复制初始策略
This commit is contained in:
@@ -6,8 +6,6 @@ export const View = (params) =>
|
||||
http.request({ url: '/imSecurityPolicy/view', method: 'GET', params });
|
||||
export const Save = (params) =>
|
||||
http.request({ url: '/imSecurityPolicy/save', method: 'POST', params });
|
||||
export const Publish = (params) =>
|
||||
http.request({ url: '/imSecurityPolicy/publish', method: 'POST', params });
|
||||
export const RestoreDefault = (params) =>
|
||||
http.request({ url: '/imSecurityPolicy/restoreDefault', method: 'POST', params });
|
||||
export const GetLoginLockoutConfig = (params) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ const tenantOnlyOptions = ref<any[]>([]);
|
||||
|
||||
export async function loadPolicyTenantOptions(includeGlobal = true) {
|
||||
const tenants = await loadImTenantOptions(tenantOnlyOptions, { page: 1, perPage: 100 });
|
||||
// 商户绑定账号不显示“全局默认”,仅平台管理员(未绑定租户)可配置全局策略
|
||||
// 商户绑定账号不显示“全局默认”,仅平台管理员(未绑定租户)可见;安全策略页已模板化,传 false 仅列商户
|
||||
const canUseGlobal = includeGlobal && !isTenantBound();
|
||||
policyTenantOptions.value = canUseGlobal
|
||||
? [{ label: '全局默认', value: 0 }, ...tenants]
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="安全策略" /></div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<n-space align="center" :size="12" class="mb-3">
|
||||
<n-text>商户</n-text>
|
||||
<n-select
|
||||
v-model:value="tenantId"
|
||||
:options="policyTenantOptions"
|
||||
:clearable="false"
|
||||
:disabled="isTenantBound()"
|
||||
style="width: 280px"
|
||||
placeholder="请选择商户"
|
||||
@update:value="loadPolicy"
|
||||
/>
|
||||
<n-tag v-if="policyStatus === 'fallback'" type="info">未配置,沿用默认模板</n-tag>
|
||||
<n-tag v-else-if="policyStatus === 'current'" type="success">修改保存后即时生效</n-tag>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 固定操作条:锚点导航 + 保存按钮(表单滚动时保持固定) -->
|
||||
<div ref="barHolder">
|
||||
<div ref="barEl" class="policy-sticky-bar">
|
||||
<n-space justify="space-between" align="center" :wrap="false" :size="12">
|
||||
<n-space :size="2">
|
||||
<n-button
|
||||
v-for="section in policySections"
|
||||
:key="section.id"
|
||||
quaternary
|
||||
size="small"
|
||||
@click="scrollTo(section.id)"
|
||||
>{{ section.label }}</n-button
|
||||
>
|
||||
</n-space>
|
||||
<n-space :size="12" :wrap="false">
|
||||
<n-button
|
||||
v-if="hasPermission(['/imSecurityPolicy/save'])"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="policyLoading"
|
||||
@click="savePolicy"
|
||||
>保存</n-button
|
||||
>
|
||||
<n-button
|
||||
v-if="hasPermission(['/imSecurityPolicy/restoreDefault'])"
|
||||
:disabled="policyLoading"
|
||||
@click="restoreDefault"
|
||||
>恢复默认</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<n-form
|
||||
ref="policyFormRef"
|
||||
:model="policyForm"
|
||||
:rules="policyRules"
|
||||
label-placement="left"
|
||||
label-width="100"
|
||||
:disabled="policyLoading"
|
||||
>
|
||||
<n-grid cols="1 l:2" :x-gap="12" :y-gap="12">
|
||||
<!-- 好友控制 -->
|
||||
<n-grid-item>
|
||||
<n-card id="sec-friend" size="small" title="好友控制" class="policy-section h-full">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-form-item-gi label="策略名称" path="policyName" span="2">
|
||||
<n-input v-model:value="policyForm.policyName" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="允许添加好友">
|
||||
<n-switch
|
||||
v-model:value="policyForm.globalFriendEnabled"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
@update:value="onGlobalFriendChange"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<template v-if="!policyForm.globalFriendEnabled">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-form-item-gi label="隐藏资料">
|
||||
<n-switch v-model:value="policyForm.friendPolicy.hide_profile" disabled />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="禁二维码">
|
||||
<n-switch v-model:value="policyForm.friendPolicy.block_qrcode" disabled />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="禁群成员">
|
||||
<n-switch
|
||||
v-model:value="policyForm.friendPolicy.block_group_member_entry"
|
||||
disabled
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<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-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 内容审核 -->
|
||||
<n-grid-item>
|
||||
<n-card id="sec-moderation" size="small" title="内容审核" class="policy-section h-full">
|
||||
<n-alert type="info" :bordered="false" class="mb-3">
|
||||
阿里云内容审核和本地内容审核的启用状态在“商户管理”中按商户设置。
|
||||
</n-alert>
|
||||
<n-form-item label="审核场景">
|
||||
<n-checkbox-group v-model:value="enabledModerationScenes">
|
||||
<n-space>
|
||||
<n-checkbox
|
||||
v-for="option in moderationSceneOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-form-item-gi
|
||||
v-for="risk in moderationRiskOptions"
|
||||
:key="risk.value"
|
||||
:label="risk.label"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="policyForm.moderationPolicy.risk_actions[risk.value]"
|
||||
:options="moderationActionOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="第三方异常">
|
||||
<n-radio-group v-model:value="policyForm.moderationPolicy.failure_mode">
|
||||
<n-space
|
||||
><n-radio value="allow">放行并告警</n-radio
|
||||
><n-radio value="block">拦截并稍后重试</n-radio></n-space
|
||||
>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="拦截提示">
|
||||
<n-input
|
||||
v-model:value="policyForm.moderationPolicy.block_message"
|
||||
maxlength="100"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 消息宵禁(规则较多,跨双列) -->
|
||||
<n-grid-item span="1 l:2">
|
||||
<n-card id="sec-curfew" size="small" title="消息宵禁" class="policy-section">
|
||||
<n-form-item label="启用宵禁">
|
||||
<n-switch
|
||||
v-model:value="policyForm.messageCurfewEnabled"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space vertical size="small">
|
||||
<n-card
|
||||
v-for="(rule, index) in policyForm.curfewRules"
|
||||
:key="rule.ruleCode"
|
||||
size="small"
|
||||
:title="`规则 ${Number(index) + 1}`"
|
||||
>
|
||||
<template #header-extra
|
||||
><n-button
|
||||
quaternary
|
||||
type="error"
|
||||
:disabled="policyLoading"
|
||||
@click="removeCurfew(index)"
|
||||
>删除</n-button
|
||||
></template
|
||||
>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<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-grid :cols="2" :x-gap="12">
|
||||
<n-form-item-gi label="提示文案">
|
||||
<n-input v-model:value="rule.message" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="豁免用户ID">
|
||||
<n-input v-model:value="rule.whitelistUserText" placeholder="多个ID用逗号分隔" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
<n-button dashed block :disabled="policyLoading" @click="addCurfew"
|
||||
>添加宵禁规则</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 登录失败策略(独立配置,保存即时生效) -->
|
||||
<n-grid-item>
|
||||
<n-card id="sec-lockout" size="small" title="登录失败策略" class="policy-section h-full">
|
||||
<n-form-item label="登录失败锁定">
|
||||
<n-switch v-model:value="lockoutForm.enabled" />
|
||||
</n-form-item>
|
||||
<template v-if="lockoutForm.enabled">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-form-item-gi label="最大失败次数">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.passwordMaxAttempts"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:precision="0"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="锁定(分钟)">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.lockMinutes"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
:precision="0"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="窗口(分钟)">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.windowMinutes"
|
||||
:min="1"
|
||||
:max="240"
|
||||
:precision="0"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
<n-form-item label=" ">
|
||||
<n-text depth="3">
|
||||
同一账号在计数窗口内连续输错密码达到最大次数后锁定,可在“日志审计-登录失败日志”中解锁。点击顶部“保存”后立即生效。
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 其他策略 -->
|
||||
<n-grid-item>
|
||||
<n-card id="sec-other" size="small" title="其他策略" class="policy-section h-full">
|
||||
<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.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-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import {
|
||||
GetLoginLockoutConfig,
|
||||
List,
|
||||
RestoreDefault,
|
||||
Save,
|
||||
SaveLoginLockoutConfig,
|
||||
View,
|
||||
} from '@/api/imSecurityPolicy';
|
||||
import { getDefaultTenantValue, loadPolicyTenantOptions, parseJSON, policyTenantOptions } from '@/views/imPolicyShared';
|
||||
import { isTenantBound } from '@/views/imTenantOptions';
|
||||
|
||||
const dict = useDictStore(),
|
||||
message = useMessage(),
|
||||
dialog = useDialog();
|
||||
const { hasPermission } = usePermission();
|
||||
const policyFormRef = ref();
|
||||
const tenantId = ref(0),
|
||||
policyStatus = ref(''),
|
||||
policyLoading = ref(false),
|
||||
submitting = ref(false);
|
||||
|
||||
const policySections = [
|
||||
{ id: 'sec-friend', label: '好友控制' },
|
||||
{ id: 'sec-moderation', label: '内容审核' },
|
||||
{ id: 'sec-curfew', label: '消息宵禁' },
|
||||
{ id: 'sec-lockout', label: '登录失败' },
|
||||
{ id: 'sec-other', label: '其他策略' },
|
||||
];
|
||||
function scrollTo(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// 操作条固定:页面滚动在 naive-ui 内部滚动容器中且布局嵌套多层 overflow 容器,CSS sticky 不可靠,
|
||||
// 改用滚动监听 + fixed 定位;barHolder 作占位避免 fixed 后表单上跳
|
||||
const barHolder = ref<HTMLElement>();
|
||||
const barEl = ref<HTMLElement>();
|
||||
const FIXED_TOP = 64; // 顶部 header 高度
|
||||
let barRaf = 0;
|
||||
function updateBarFixed() {
|
||||
barRaf = 0;
|
||||
const holder = barHolder.value,
|
||||
bar = barEl.value;
|
||||
if (!holder || !bar) return;
|
||||
const rect = holder.getBoundingClientRect();
|
||||
if (rect.top <= FIXED_TOP) {
|
||||
if (!bar.classList.contains('is-fixed')) {
|
||||
holder.style.height = `${bar.offsetHeight}px`;
|
||||
bar.style.width = `${holder.offsetWidth}px`;
|
||||
bar.classList.add('is-fixed');
|
||||
}
|
||||
bar.style.left = `${rect.left}px`;
|
||||
} else if (bar.classList.contains('is-fixed')) {
|
||||
bar.classList.remove('is-fixed');
|
||||
bar.style.width = '';
|
||||
bar.style.left = '';
|
||||
holder.style.height = '';
|
||||
}
|
||||
}
|
||||
function onDocScroll() {
|
||||
if (!barRaf) barRaf = requestAnimationFrame(updateBarFixed);
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('scroll', onDocScroll, true);
|
||||
window.addEventListener('resize', onDocScroll);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('scroll', onDocScroll, true);
|
||||
window.removeEventListener('resize', onDocScroll);
|
||||
if (barRaf) cancelAnimationFrame(barRaf);
|
||||
});
|
||||
|
||||
const dateOptions = dict.getOption('ImCurfewDateTypeOptions'),
|
||||
scopeOptions = dict.getOption('ImCurfewScopeOptions'),
|
||||
statusOptions = dict.getOption('sys_normal_disable'),
|
||||
friendEntryOptions = dict.getOption('ImFriendEntryOptions');
|
||||
dict.loadOptions([
|
||||
'ImCurfewDateTypeOptions',
|
||||
'ImCurfewScopeOptions',
|
||||
'sys_normal_disable',
|
||||
'ImFriendEntryOptions',
|
||||
]);
|
||||
|
||||
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 moderationSceneOptions = [
|
||||
{ label: '用户昵称', value: 'user_nickname' },
|
||||
{ label: '用户签名', value: 'user_signature' },
|
||||
{ label: '群名称', value: 'group_name' },
|
||||
{ label: '群昵称', value: 'group_nickname' },
|
||||
{ label: '单聊消息', value: 'private_message' },
|
||||
{ label: '群聊消息', value: 'group_message' },
|
||||
{ label: '群公告', value: 'group_announcement' },
|
||||
];
|
||||
const moderationRiskOptions = [
|
||||
{ label: '高风险动作', value: 'high' },
|
||||
{ label: '中风险动作', value: 'medium' },
|
||||
{ label: '低风险动作', value: 'low' },
|
||||
{ label: '无风险动作', value: 'none' },
|
||||
];
|
||||
const moderationActionOptions = [
|
||||
{ label: '拦截', value: 'block' },
|
||||
{ label: '仅审计', value: 'audit_only' },
|
||||
{ label: '放行', value: 'allow' },
|
||||
];
|
||||
const defaultModerationPolicy = () => ({
|
||||
scenes: Object.fromEntries(moderationSceneOptions.map((item) => [item.value, true])),
|
||||
risk_actions: { high: 'block', medium: 'block', low: 'audit_only', none: 'allow' },
|
||||
failure_mode: 'allow',
|
||||
block_message: '内容不符合平台规范,请修改后重试',
|
||||
});
|
||||
const emptyPolicy = () => ({
|
||||
id: 0,
|
||||
tenantId: tenantId.value,
|
||||
policyName: '安全策略',
|
||||
passwordPolicy: '{}',
|
||||
sensitiveWordPolicy: '{}',
|
||||
moderationPolicy: defaultModerationPolicy(),
|
||||
fileReviewPolicy: '{}',
|
||||
realnamePolicy: '{}',
|
||||
riskChallengePolicy: '{}',
|
||||
globalFriendEnabled: 1,
|
||||
friendPolicy: defaultFriendDisabledPolicy(),
|
||||
messageCurfewEnabled: 0,
|
||||
remark: '',
|
||||
curfewRules: [] as any[],
|
||||
});
|
||||
const policyForm = ref<any>(emptyPolicy());
|
||||
const enabledModerationScenes = computed({
|
||||
get: () =>
|
||||
Object.keys(policyForm.value.moderationPolicy?.scenes || {}).filter(
|
||||
(key) => policyForm.value.moderationPolicy.scenes[key]
|
||||
),
|
||||
set: (values: string[]) => {
|
||||
policyForm.value.moderationPolicy.scenes = Object.fromEntries(
|
||||
moderationSceneOptions.map((item) => [item.value, values.includes(item.value)])
|
||||
);
|
||||
},
|
||||
});
|
||||
const policyRules = { policyName: { required: true, message: '请输入策略名称' } };
|
||||
const emptyLockout = () => ({
|
||||
enabled: false,
|
||||
passwordMaxAttempts: 5,
|
||||
lockMinutes: 1440,
|
||||
windowMinutes: 60,
|
||||
policyId: 0,
|
||||
});
|
||||
const lockoutForm = ref<any>(emptyLockout());
|
||||
|
||||
// 将接口返回的策略数据映射为平铺表单
|
||||
function applyPolicyData(data: any) {
|
||||
policyForm.value = {
|
||||
...data,
|
||||
friendPolicy: {
|
||||
...defaultFriendDisabledPolicy(),
|
||||
...parseJSON(data.friendDisabledPolicy, {}),
|
||||
},
|
||||
passwordPolicy: data.passwordPolicy || '{}',
|
||||
sensitiveWordPolicy: data.sensitiveWordPolicy || '{}',
|
||||
moderationPolicy: {
|
||||
...defaultModerationPolicy(),
|
||||
...parseJSON(data.sensitiveWordPolicy, {}),
|
||||
scenes: {
|
||||
...defaultModerationPolicy().scenes,
|
||||
...(parseJSON(data.sensitiveWordPolicy, {}).scenes || {}),
|
||||
},
|
||||
risk_actions: {
|
||||
...defaultModerationPolicy().risk_actions,
|
||||
...(parseJSON(data.sensitiveWordPolicy, {}).risk_actions || {}),
|
||||
},
|
||||
},
|
||||
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(','),
|
||||
})),
|
||||
};
|
||||
// 登录失败锁定配置已独立卡片管理,避免随策略保存回传覆盖
|
||||
delete policyForm.value.loginRateLimit;
|
||||
}
|
||||
|
||||
// 页面加载策略:加载商户当前生效的唯一策略记录;无记录时由后端回落全局默认配置展示
|
||||
// 选择商户事件后先禁用整页表单,数据加载完成才恢复可编辑;序号守卫防止连续切换时旧响应覆盖新数据
|
||||
let loadSeq = 0;
|
||||
async function loadPolicy() {
|
||||
const tid = Number(tenantId.value || 0);
|
||||
if (!tid) return;
|
||||
const seq = ++loadSeq;
|
||||
policyLoading.value = true;
|
||||
policyStatus.value = '';
|
||||
try {
|
||||
let published: any = null;
|
||||
try {
|
||||
// List 对无记录返回空数组而非报错,避免 system 等无策略商户误弹“安全策略不存在”
|
||||
const res = await List({ tenantId: tid, publishStatus: 'published', page: 1, perPage: 1 });
|
||||
published = res?.list?.[0];
|
||||
} catch {
|
||||
published = null;
|
||||
}
|
||||
if (published?.id) {
|
||||
const data = await View({ id: published.id });
|
||||
if (seq !== loadSeq) return;
|
||||
applyPolicyData(data);
|
||||
policyStatus.value = 'current';
|
||||
loadLockoutConfig(policyForm.value.tenantId || tid);
|
||||
return;
|
||||
}
|
||||
// 无自建策略:展示全局默认配置(后端 View 回落 tenant_id=0 记录),保存后成为本商户自己的策略
|
||||
try {
|
||||
const data = await View({ tenantId: tid });
|
||||
if (seq !== loadSeq) return;
|
||||
applyPolicyData(data);
|
||||
policyStatus.value = 'fallback';
|
||||
loadLockoutConfig(policyForm.value.tenantId || tid);
|
||||
return;
|
||||
} catch {
|
||||
// 全局默认也不存在时落空表单
|
||||
}
|
||||
if (seq !== loadSeq) return;
|
||||
policyForm.value = emptyPolicy();
|
||||
loadLockoutConfig(tid);
|
||||
} finally {
|
||||
if (seq === loadSeq) policyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLockoutConfig(tenant = 0) {
|
||||
try {
|
||||
const data = await GetLoginLockoutConfig({ tenantId: tenant });
|
||||
lockoutForm.value = {
|
||||
enabled: !!data.enabled,
|
||||
passwordMaxAttempts: data.passwordMaxAttempts || 5,
|
||||
lockMinutes: data.lockMinutes || 1440,
|
||||
windowMinutes: data.windowMinutes || 60,
|
||||
policyId: data.policyId || 0,
|
||||
};
|
||||
} catch {
|
||||
lockoutForm.value = emptyLockout();
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存即生效:后端原地更新当前生效记录并投递策略变更事件,IM 端消费后刷新缓存
|
||||
async function savePolicy() {
|
||||
submitting.value = true;
|
||||
try {
|
||||
await policyFormRef.value?.validate();
|
||||
const f = policyForm.value;
|
||||
await Save({
|
||||
tenantId: f.tenantId || tenantId.value,
|
||||
policyName: f.policyName,
|
||||
passwordPolicy: jsonObject(f.passwordPolicy, '密码策略'),
|
||||
sensitiveWordPolicy: f.moderationPolicy,
|
||||
fileReviewPolicy: jsonObject(f.fileReviewPolicy, '文件审核策略'),
|
||||
realnamePolicy: jsonObject(f.realnamePolicy, '实名策略'),
|
||||
riskChallengePolicy: jsonObject(f.riskChallengePolicy, '风控策略'),
|
||||
globalFriendEnabled: f.globalFriendEnabled,
|
||||
friendDisabledPolicy: f.friendPolicy,
|
||||
messageCurfewEnabled: f.messageCurfewEnabled,
|
||||
remark: f.remark,
|
||||
curfewRules: f.curfewRules.map((v) => ({
|
||||
...v,
|
||||
whitelistUserIds: v.whitelistUserText
|
||||
.split(',')
|
||||
.map((x) => Number(x.trim()))
|
||||
.filter(Boolean),
|
||||
})),
|
||||
});
|
||||
// 登录失败策略随顶部保存一并提交(独立接口,保存即时生效)
|
||||
try {
|
||||
await SaveLoginLockoutConfig({
|
||||
tenantId: f.tenantId || tenantId.value,
|
||||
enabled: lockoutForm.value.enabled,
|
||||
passwordMaxAttempts: lockoutForm.value.passwordMaxAttempts,
|
||||
lockMinutes: lockoutForm.value.lockMinutes,
|
||||
windowMinutes: lockoutForm.value.windowMinutes,
|
||||
});
|
||||
} catch {
|
||||
message.error('策略已保存,但登录失败策略保存失败');
|
||||
}
|
||||
message.success('已保存并即时生效');
|
||||
await loadPolicy();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDefault() {
|
||||
dialog.warning({
|
||||
title: '恢复默认策略',
|
||||
content: '将把当前商户的安全策略重置为平台默认模板配置,并立即生效。',
|
||||
positiveText: '重置并生效',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await RestoreDefault({ tenantId: tenantId.value, reason: '管理员恢复默认' });
|
||||
message.success('已重置为默认模板配置');
|
||||
await loadPolicy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 安全策略已模板化:不提供“全局默认”选项,仅针对具体商户配置
|
||||
await loadPolicyTenantOptions(false);
|
||||
tenantId.value = Number(getDefaultTenantValue(policyTenantOptions.value) || 0);
|
||||
await loadPolicy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.policy-sticky-bar {
|
||||
background: #fff;
|
||||
padding: 8px 0;
|
||||
margin: 12px 0;
|
||||
border-bottom: 1px solid #efeff5;
|
||||
}
|
||||
.policy-sticky-bar.is-fixed {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
}
|
||||
.policy-section {
|
||||
scroll-margin-top: 128px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,291 +1,28 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="安全策略" /></div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="IP黑白名单" /></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>
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<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-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 && policyActiveTab !== 'lockout'"
|
||||
label-placement="left"
|
||||
label-width="132"
|
||||
>
|
||||
<n-tabs v-model:value="policyActiveTab" 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="moderation" tab="内容审核">
|
||||
<n-alert type="info" :bordered="false">
|
||||
阿里云内容审核和本地内容审核的启用状态在“商户管理”中按商户设置。
|
||||
</n-alert>
|
||||
<n-form-item label="审核场景">
|
||||
<n-checkbox-group v-model:value="enabledModerationScenes">
|
||||
<n-space>
|
||||
<n-checkbox
|
||||
v-for="option in moderationSceneOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-form-item-gi
|
||||
v-for="risk in moderationRiskOptions"
|
||||
:key="risk.value"
|
||||
:label="risk.label"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="policyForm.moderationPolicy.risk_actions[risk.value]"
|
||||
:options="moderationActionOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="第三方异常">
|
||||
<n-radio-group v-model:value="policyForm.moderationPolicy.failure_mode">
|
||||
<n-space
|
||||
><n-radio value="allow">放行并告警</n-radio
|
||||
><n-radio value="block">拦截并稍后重试</n-radio></n-space
|
||||
>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="拦截提示">
|
||||
<n-input
|
||||
v-model:value="policyForm.moderationPolicy.block_message"
|
||||
maxlength="100"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="lockout" tab="登录失败策略">
|
||||
<n-form-item label="登录失败锁定"
|
||||
><n-switch v-model:value="lockoutForm.enabled"
|
||||
/></n-form-item>
|
||||
<template v-if="lockoutForm.enabled">
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-form-item-gi label="最大失败次数">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.passwordMaxAttempts"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:precision="0"
|
||||
/> </n-form-item-gi
|
||||
><n-form-item-gi label="锁定时长(分钟)">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.lockMinutes"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
:precision="0"
|
||||
/> </n-form-item-gi
|
||||
><n-form-item-gi label="计数窗口(分钟)">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.windowMinutes"
|
||||
:min="1"
|
||||
:max="240"
|
||||
:precision="0"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
<n-form-item label=" ">
|
||||
<n-text depth="3">
|
||||
同一账号在计数窗口内连续输错密码达到最大次数后锁定,锁定期间登录将被拒绝,可在“日志审计-登录失败日志”中解锁。保存后立即生效,无需发布策略。
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
<n-space justify="end">
|
||||
<n-button
|
||||
v-if="hasPermission(['/imSecurityPolicy/loginLockoutConfig'])"
|
||||
type="primary"
|
||||
:loading="lockoutSaving"
|
||||
@click="saveLockoutConfig"
|
||||
>保存</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.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"
|
||||
@@ -337,21 +74,9 @@
|
||||
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 {
|
||||
GetLoginLockoutConfig,
|
||||
IPRuleDelete,
|
||||
IPRuleEdit,
|
||||
IPRuleList,
|
||||
List,
|
||||
Publish,
|
||||
RestoreDefault,
|
||||
Save,
|
||||
SaveLoginLockoutConfig,
|
||||
View,
|
||||
} from '@/api/imSecurityPolicy';
|
||||
import { columns, ipColumns, loadOptions, schemas } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import { IPRuleDelete, IPRuleEdit, IPRuleList } from '@/api/imSecurityPolicy';
|
||||
import { ipColumns, loadOptions, schemas } from './model';
|
||||
import {
|
||||
getDefaultTenantValue,
|
||||
loadPolicyTenantOptions,
|
||||
@@ -360,26 +85,16 @@
|
||||
} from '@/views/imPolicyShared';
|
||||
const dict = useDictStore(),
|
||||
message = useMessage(),
|
||||
dialog = useDialog(),
|
||||
userStore = useUserStore();
|
||||
dialog = useDialog();
|
||||
const { hasPermission } = usePermission();
|
||||
const searchFormRef = ref<any>({}),
|
||||
tableRef = ref(),
|
||||
ipTableRef = ref(),
|
||||
policyFormRef = ref(),
|
||||
ipFormRef = ref();
|
||||
const activeTab = ref('policy'),
|
||||
policyActiveTab = ref('friend'),
|
||||
policyVisible = ref(false),
|
||||
ipVisible = ref(false),
|
||||
const 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'),
|
||||
const statusOptions = dict.getOption('sys_normal_disable'),
|
||||
actionOptions = dict.getOption('ImSecurityActionOptions'),
|
||||
friendEntryOptions = dict.getOption('ImFriendEntryOptions'),
|
||||
riskOptions = dict.getOption('ImRiskLevelOptions');
|
||||
const ipTypeOptions = [
|
||||
{ label: '白名单', value: 'whitelist' },
|
||||
@@ -391,138 +106,6 @@
|
||||
{ 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 moderationSceneOptions = [
|
||||
{ label: '用户昵称', value: 'user_nickname' },
|
||||
{ label: '用户签名', value: 'user_signature' },
|
||||
{ label: '群名称', value: 'group_name' },
|
||||
{ label: '群昵称', value: 'group_nickname' },
|
||||
{ label: '单聊消息', value: 'private_message' },
|
||||
{ label: '群聊消息', value: 'group_message' },
|
||||
{ label: '群公告', value: 'group_announcement' },
|
||||
];
|
||||
const moderationRiskOptions = [
|
||||
{ label: '高风险动作', value: 'high' },
|
||||
{ label: '中风险动作', value: 'medium' },
|
||||
{ label: '低风险动作', value: 'low' },
|
||||
{ label: '无风险动作', value: 'none' },
|
||||
];
|
||||
const moderationActionOptions = [
|
||||
{ label: '拦截', value: 'block' },
|
||||
{ label: '仅审计', value: 'audit_only' },
|
||||
{ label: '放行', value: 'allow' },
|
||||
];
|
||||
const defaultModerationPolicy = () => ({
|
||||
scenes: Object.fromEntries(moderationSceneOptions.map((item) => [item.value, true])),
|
||||
risk_actions: { high: 'block', medium: 'block', low: 'audit_only', none: 'allow' },
|
||||
failure_mode: 'allow',
|
||||
block_message: '内容不符合平台规范,请修改后重试',
|
||||
});
|
||||
const emptyPolicy = () => ({
|
||||
id: 0,
|
||||
tenantId: tenantId(),
|
||||
policyName: '安全策略',
|
||||
passwordPolicy: '{}',
|
||||
sensitiveWordPolicy: '{}',
|
||||
moderationPolicy: defaultModerationPolicy(),
|
||||
fileReviewPolicy: '{}',
|
||||
realnamePolicy: '{}',
|
||||
riskChallengePolicy: '{}',
|
||||
globalFriendEnabled: 1,
|
||||
friendPolicy: defaultFriendDisabledPolicy(),
|
||||
messageCurfewEnabled: 0,
|
||||
remark: '',
|
||||
curfewRules: [] as any[],
|
||||
});
|
||||
const policyForm = ref<any>(emptyPolicy());
|
||||
const enabledModerationScenes = computed({
|
||||
get: () =>
|
||||
Object.keys(policyForm.value.moderationPolicy?.scenes || {}).filter(
|
||||
(key) => policyForm.value.moderationPolicy.scenes[key]
|
||||
),
|
||||
set: (values: string[]) => {
|
||||
policyForm.value.moderationPolicy.scenes = Object.fromEntries(
|
||||
moderationSceneOptions.map((item) => [item.value, values.includes(item.value)])
|
||||
);
|
||||
},
|
||||
});
|
||||
const policyReadOnly = computed(
|
||||
() => policyForm.value.id > 0 && policyForm.value.publishStatus !== 'draft'
|
||||
);
|
||||
const policyRules = { policyName: { required: true, message: '请输入策略名称' } };
|
||||
const emptyLockout = () => ({
|
||||
enabled: false,
|
||||
passwordMaxAttempts: 5,
|
||||
lockMinutes: 1440,
|
||||
windowMinutes: 60,
|
||||
policyId: 0,
|
||||
});
|
||||
const lockoutForm = ref<any>(emptyLockout());
|
||||
const lockoutSaving = ref(false);
|
||||
async function loadLockoutConfig(tenant = 0) {
|
||||
try {
|
||||
const data = await GetLoginLockoutConfig({ tenantId: tenant });
|
||||
lockoutForm.value = {
|
||||
enabled: !!data.enabled,
|
||||
passwordMaxAttempts: data.passwordMaxAttempts || 5,
|
||||
lockMinutes: data.lockMinutes || 1440,
|
||||
windowMinutes: data.windowMinutes || 60,
|
||||
policyId: data.policyId || 0,
|
||||
};
|
||||
} catch {
|
||||
lockoutForm.value = emptyLockout();
|
||||
}
|
||||
}
|
||||
async function saveLockoutConfig() {
|
||||
lockoutSaving.value = true;
|
||||
try {
|
||||
await SaveLoginLockoutConfig({
|
||||
tenantId: policyForm.value.tenantId || tenantId(),
|
||||
enabled: lockoutForm.value.enabled,
|
||||
passwordMaxAttempts: lockoutForm.value.passwordMaxAttempts,
|
||||
lockMinutes: lockoutForm.value.lockMinutes,
|
||||
windowMinutes: lockoutForm.value.windowMinutes,
|
||||
});
|
||||
message.success('已保存,配置即时生效');
|
||||
policyVisible.value = false;
|
||||
reloadAll();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
} finally {
|
||||
lockoutSaving.value = false;
|
||||
}
|
||||
}
|
||||
const emptyIP = () => ({
|
||||
id: 0,
|
||||
tenantId: tenantId(),
|
||||
@@ -537,45 +120,25 @@
|
||||
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 = () => true;
|
||||
if (tenantSchema) {
|
||||
tenantSchema.ifShow = () => true;
|
||||
// 商户下拉选中即自动触发查询(无需查询/重置按钮)
|
||||
const props: any = (tenantSchema.componentProps = tenantSchema.componentProps || {});
|
||||
props.onChange = () => ipTableRef.value?.reload();
|
||||
}
|
||||
const [register, { setFieldsValue }] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
// 商户下拉选中即自动查询,不需要查询/重置按钮
|
||||
showActionButtonGroup: false,
|
||||
});
|
||||
// 表格挂载时自动请求早于租户默认值就绪(tenantId 尚为 0),此时跳过请求,由 onMounted 的 reloadAll 加载首屏
|
||||
const loadTable = (res) =>
|
||||
tenantId() ? List({ ...searchFormRef.value?.formModel, ...res }) : Promise.resolve({}),
|
||||
loadIPTable = (res) =>
|
||||
tenantId() ? IPRuleList({ tenantId: tenantId(), ...res }) : Promise.resolve({});
|
||||
const reloadAll = () => {
|
||||
tableRef.value?.reload();
|
||||
// 表格挂载时自动请求早于租户默认值就绪(tenantId 尚为 0),此时跳过请求,由 onMounted 的 reload 加载首屏
|
||||
const loadIPTable = (res) =>
|
||||
tenantId() ? IPRuleList({ tenantId: tenantId(), ...res }) : Promise.resolve({});
|
||||
function reloadTable() {
|
||||
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: '操作',
|
||||
@@ -595,134 +158,7 @@
|
||||
],
|
||||
}),
|
||||
});
|
||||
const policyScrollX = computed(() => adaTableScrollX(columns, actionColumn.width)),
|
||||
ipScrollX = computed(() => adaTableScrollX(ipColumns, ipActionColumn.width));
|
||||
async function openPolicy(row?: any) {
|
||||
policyActiveTab.value = 'friend';
|
||||
if (!row) {
|
||||
policyForm.value = emptyPolicy();
|
||||
policyVisible.value = true;
|
||||
loadLockoutConfig(tenantId());
|
||||
return;
|
||||
}
|
||||
const data = await View({ id: row.id });
|
||||
policyForm.value = {
|
||||
...data,
|
||||
friendPolicy: {
|
||||
...defaultFriendDisabledPolicy(),
|
||||
...parseJSON(data.friendDisabledPolicy, {}),
|
||||
},
|
||||
passwordPolicy: data.passwordPolicy || '{}',
|
||||
sensitiveWordPolicy: data.sensitiveWordPolicy || '{}',
|
||||
moderationPolicy: {
|
||||
...defaultModerationPolicy(),
|
||||
...parseJSON(data.sensitiveWordPolicy, {}),
|
||||
scenes: {
|
||||
...defaultModerationPolicy().scenes,
|
||||
...(parseJSON(data.sensitiveWordPolicy, {}).scenes || {}),
|
||||
},
|
||||
risk_actions: {
|
||||
...defaultModerationPolicy().risk_actions,
|
||||
...(parseJSON(data.sensitiveWordPolicy, {}).risk_actions || {}),
|
||||
},
|
||||
},
|
||||
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(','),
|
||||
})),
|
||||
};
|
||||
// 登录失败锁定配置已迁移至独立tab管理,避免随草稿保存回传覆盖
|
||||
delete policyForm.value.loginRateLimit;
|
||||
policyVisible.value = true;
|
||||
loadLockoutConfig(policyForm.value.tenantId);
|
||||
}
|
||||
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, '密码策略'),
|
||||
sensitiveWordPolicy: f.moderationPolicy,
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
const ipScrollX = computed(() => adaTableScrollX(ipColumns, ipActionColumn.width));
|
||||
function openIPRule(row?: any) {
|
||||
ipForm.value = row ? { ...row, scenes: parseJSON(row.sceneJson, []) } : emptyIP();
|
||||
ipVisible.value = true;
|
||||
@@ -734,7 +170,7 @@
|
||||
await IPRuleEdit({ ...ipForm.value, sceneJson: ipForm.value.scenes });
|
||||
message.success('保存成功');
|
||||
ipVisible.value = false;
|
||||
reloadAll();
|
||||
reloadTable();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -748,7 +184,7 @@
|
||||
onPositiveClick: async () => {
|
||||
await IPRuleDelete({ id: row.id });
|
||||
message.success('删除成功');
|
||||
reloadAll();
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -756,6 +192,6 @@
|
||||
loadOptions();
|
||||
await loadPolicyTenantOptions(true);
|
||||
await setFieldsValue({ tenantId: getDefaultTenantValue(policyTenantOptions.value) });
|
||||
reloadAll();
|
||||
reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -12,41 +12,7 @@ export const schemas = ref<FormSchema[]>([
|
||||
label: '商户',
|
||||
componentProps: { options: computed(() => policyTenantOptions.value), clearable: false, disabled: isTenantBound() },
|
||||
},
|
||||
{
|
||||
field: 'publishStatus',
|
||||
component: 'NSelect',
|
||||
label: '发布状态',
|
||||
componentProps: {
|
||||
options: dict.getOption('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: '类型',
|
||||
@@ -78,11 +44,7 @@ export const ipColumns = [
|
||||
];
|
||||
export function loadOptions() {
|
||||
dict.loadOptions([
|
||||
'ImSecurityPublishStatusOptions',
|
||||
'ImCurfewDateTypeOptions',
|
||||
'ImCurfewScopeOptions',
|
||||
'ImSecurityActionOptions',
|
||||
'ImFriendEntryOptions',
|
||||
'ImRiskLevelOptions',
|
||||
'sys_normal_disable',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user