This commit is contained in:
Austin
2026-09-21 09:28:09 +08:00
28 changed files with 1607 additions and 142 deletions
+17
View File
@@ -0,0 +1,17 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imMessageEncryption/list', method: 'GET', params });
}
export function Initialize(params) {
return http.request({ url: '/imMessageEncryption/initialize', method: 'POST', params });
}
export function Rotate(params) {
return http.request({ url: '/imMessageEncryption/rotate', method: 'POST', params });
}
export function Switch(params) {
return http.request({ url: '/imMessageEncryption/switch', method: 'POST', params });
}
+137
View File
@@ -0,0 +1,137 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="消息密钥管理" />
</div>
<n-alert type="warning" :bordered="false" class="mb-3">
密钥明文不会在管理后台展示。启用消息加密前必须先初始化密钥;轮换密钥将在 IM Server
确认加载后自动激活。
</n-alert>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.tenantId"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, h, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { List, Initialize, Rotate, Switch } from '@/api/imMessageEncryption';
import { columns, schemas, State } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
const message = useMessage();
const dialog = useDialog();
const actionRef = ref();
const searchFormRef = ref<any>({});
const actionColumn = reactive({
width: 240,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
const actions: Record<string, any>[] = [];
if (record.keyCount === 0) {
actions.push({
label: '初始化',
type: 'primary',
auth: ['/imMessageEncryption/initialize'],
onClick: () => handleInitialize(record),
});
} else {
actions.push({
label: record.messageEncryptionEnabled ? '停用加密' : '启用加密',
type: record.messageEncryptionEnabled ? 'warning' : 'primary',
auth: ['/imMessageEncryption/switch'],
onClick: () => handleSwitch(record),
});
if (record.pendingVersion === 0) {
actions.push({
label: '轮换密钥',
auth: ['/imMessageEncryption/rotate'],
onClick: () => handleRotate(record),
});
}
}
return h(TableAction as any, { style: 'button', actions });
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const [register] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
return await List({ ...searchFormRef.value?.formModel, ...res });
};
function reloadTable() {
actionRef.value?.reload();
}
function handleInitialize(record: State) {
dialog.warning({
title: '初始化消息密钥',
content: `将为商户“${record.tenantName}”生成首个 AES-256-GCM 数据密钥。密钥不会在页面展示,是否继续?`,
positiveText: '确认初始化',
negativeText: '取消',
onPositiveClick: async () => {
await Initialize({ tenantId: record.tenantId });
message.success('消息密钥初始化成功');
reloadTable();
},
});
}
function handleSwitch(record: State) {
const enabled = record.messageEncryptionEnabled ? 0 : 1;
dialog.warning({
title: enabled ? '启用消息加密' : '停用消息加密',
content: enabled
? `启用后,商户“${record.tenantName}”的新消息将由 IM Server 加密存储。是否继续?`
: `停用后仅影响新消息,历史密文仍按原密钥解密。是否继续?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Switch({ tenantId: record.tenantId, enabled });
message.success(enabled ? '消息加密已启用' : '消息加密已停用');
reloadTable();
},
});
}
function handleRotate(record: State) {
dialog.warning({
title: '轮换消息密钥',
content: `将为商户“${record.tenantName}”生成新版本密钥。IM Server 确认加载前,当前密钥仍保持有效。是否继续?`,
positiveText: '确认轮换',
negativeText: '取消',
onPositiveClick: async () => {
await Rotate({ tenantId: record.tenantId });
message.success('待激活密钥已生成');
reloadTable();
},
});
}
</script>
@@ -0,0 +1,87 @@
import { h, ref } from 'vue';
import { NTag } from 'naive-ui';
import { FormSchema } from '@/components/Form';
export class State {
public tenantId = 0;
public tenantCode = '';
public tenantName = '';
public messageEncryptionEnabled = 0;
public activeVersion = 0;
public pendingVersion = 0;
public keyCount = 0;
public lastRotatedAt = '';
}
export const schemas = ref<FormSchema[]>([
{
field: 'keyword',
component: 'NInput',
label: '商户',
componentProps: {
clearable: true,
placeholder: '商户编码/名称',
},
},
{
field: 'enabled',
component: 'NSelect',
label: '加密状态',
componentProps: {
clearable: true,
options: [
{ label: '已启用', value: 1 },
{ label: '未启用', value: 0 },
],
placeholder: '全部',
},
},
]);
export const columns = [
{ title: '商户编码', key: 'tenantCode', align: 'left', width: 150 },
{ title: '商户名称', key: 'tenantName', align: 'left', width: 180 },
{
title: '消息加密',
key: 'messageEncryptionEnabled',
align: 'left',
width: 110,
render(row: State) {
return h(
NTag,
{ type: row.messageEncryptionEnabled ? 'success' : 'default', bordered: false },
{ default: () => (row.messageEncryptionEnabled ? '已启用' : '未启用') }
);
},
},
{
title: '当前版本',
key: 'activeVersion',
align: 'left',
width: 110,
render(row: State) {
return row.activeVersion > 0 ? `v${row.activeVersion}` : '--';
},
},
{
title: '待激活版本',
key: 'pendingVersion',
align: 'left',
width: 120,
render(row: State) {
return row.pendingVersion > 0
? h(NTag, { type: 'warning', bordered: false }, { default: () => `v${row.pendingVersion}` })
: '--';
},
},
{ title: '版本数', key: 'keyCount', align: 'left', width: 90 },
{
title: '最后轮换时间',
key: 'lastRotatedAt',
align: 'left',
width: 180,
render(row: State) {
return row.lastRotatedAt || '--';
},
},
];
+22
View File
@@ -100,6 +100,24 @@
/>
</n-form-item>
</n-gi>
<n-gi span="1">
<n-form-item label="消息加密存储" path="messageEncryptionEnabled">
<n-switch
v-model:value="formValue.messageEncryptionEnabled"
:checked-value="1"
:unchecked-value="0"
/>
</n-form-item>
</n-gi>
<n-gi span="1">
<n-form-item label="实名审核方式" path="realnameReviewPolicy">
<n-select
v-model:value="formValue.realnameReviewPolicy"
:options="realnameReviewPolicyOptions"
placeholder="请选择实名审核方式"
/>
</n-form-item>
</n-gi>
<n-gi span="2">
<n-form-item label="商户描述" path="tenantDesc">
<n-input
@@ -145,6 +163,10 @@
return adaModalWidth(840);
});
const stateOptions = dict.getOption('ImTenantStateOptions');
const realnameReviewPolicyOptions = [
{ label: '不自动审核', value: 0 },
{ label: '自动审核,失败后转人工', value: 1 },
];
// 提交表单
function confirmForm(e) {
+28
View File
@@ -21,6 +21,8 @@ export class State {
public maxGroupMembers = 0; // 最大群成员数
public aliyunModerationEnabled = 0; // 是否启用阿里云内容审核
public localModerationEnabled = 0; // 是否启用本地内容审核
public messageEncryptionEnabled = 0; // 是否启用消息加密
public realnameReviewPolicy = 0; // 实名审核策略
public dbDsnConfigured = false; // 是否配置独立连接串
public createdBy = 0; // 创建者
public createdBySumma?: null | MemberSumma = null; // 创建者摘要信息
@@ -236,6 +238,32 @@ export const columns = [
);
},
},
{
title: '消息加密',
key: 'messageEncryptionEnabled',
align: 'left',
width: 120,
render(row: State) {
return h(
NTag,
{ type: row.messageEncryptionEnabled === 1 ? 'success' : 'default', bordered: false },
{ default: () => (row.messageEncryptionEnabled === 1 ? '已启用' : '未启用') }
);
},
},
{
title: '实名审核',
key: 'realnameReviewPolicy',
align: 'left',
width: 150,
render(row: State) {
return h(
NTag,
{ type: row.realnameReviewPolicy === 1 ? 'success' : 'default', bordered: false },
{ default: () => (row.realnameReviewPolicy === 1 ? '自动审核' : '人工审核') }
);
},
},
{
title: '创建者',
key: 'createdBy',