feat: 将安全策略模板化,新建商户时自动复制初始策略

This commit is contained in:
Austin
2026-09-22 18:32:08 +08:00
parent 9a9e30f94e
commit dc3c45cd53
17 changed files with 1172 additions and 795 deletions
-2
View File
@@ -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) =>
+1 -1
View File
@@ -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]
+700
View File
@@ -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>
+43 -607
View File
@@ -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>
-38
View File
@@ -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',
]);