feat: 优化站内信通知面板

This commit is contained in:
sunlei 2026-06-11 11:18:04 +08:00
parent 15e588f80e
commit 60ac59c628
8 changed files with 203 additions and 323 deletions

View File

@ -44,6 +44,10 @@ pnpm run build:antdv-next
真实环境变量不提交,示例配置以 `.env.example` 为准。
## 业务页面
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
## 部署说明
Jenkins 使用 `Jenkinsfile` 执行:

View File

@ -70,7 +70,6 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'SystemMenuDelete',
'SystemMenuEdit',
'SystemNotice',
'SystemNoticeCreate',
'SystemNoticeDelete',
'SystemNoticeEdit',
'SystemRole',

View File

@ -8,37 +8,39 @@ export namespace SystemNoticeApi {
content: string;
createTime?: string;
createdBy?: string;
dedupeKey?: string;
eventType?: string;
firstSeenAt?: string;
id: string;
isDeleted: boolean;
isTop: boolean;
lastSeenAt?: string;
level: number;
metadata?: Recordable<any> | string;
notifyUsers?: string;
notifyRoleCode?: string;
occurrenceCount?: number;
severity?: 'error' | 'fatal' | 'info' | 'warn';
source?: string;
status: 0 | 1;
summary?: string;
title: string;
updateTime?: string;
}
export interface NoticeInput {
content: string;
id?: string;
isTop?: boolean;
level?: number;
notifyUsers?: string;
status?: 0 | 1;
summary?: string;
title: string;
}
export interface NoticeQuery {
[key: string]: any;
eventType?: string;
isTop?: boolean | number | string;
keyword?: string;
level?: number | string;
notifyUsers?: string;
notifyRoleCode?: string;
page?: number;
pageNo?: number;
pageSize?: number;
severity?: string;
source?: string;
status?: 0 | 1 | number | string;
}
@ -60,17 +62,6 @@ async function getNoticeDetail(id: string) {
);
}
async function createNotice(data: SystemNoticeApi.NoticeInput) {
return requestClient.post<string>('/system/notice/save', data);
}
async function updateNotice(id: string, data: SystemNoticeApi.NoticeInput) {
return requestClient.post('/system/notice/update', {
...data,
id,
});
}
async function deleteNotice(id: string) {
return requestClient.delete(`/system/notice/${id}`);
}
@ -97,11 +88,9 @@ async function toggleNoticeTop(id: string, isTop: boolean) {
}
export {
createNotice,
deleteNotice,
getNoticeDetail,
getNoticeList,
toggleNoticeStatus,
toggleNoticeTop,
updateNotice,
};

View File

@ -112,28 +112,45 @@
"unconfigured": "Loki Not Configured"
},
"notice": {
"cancelTop": "Unpin",
"cancelTopSuccess": "Unpinned",
"content": "Content",
"createTime": "Create Time",
"deleteConfirm": "Confirm deleting \"{0}\"?",
"disableSuccess": "Notice disabled",
"enableSuccess": "Notice enabled",
"eventTitle": "Event Title",
"eventType": "Event Type",
"firstSeenAt": "First Seen",
"handleConfirm": "Mark \"{0}\" as handled?",
"handleSuccess": "Marked as handled",
"isTop": "Top",
"keyword": "Keyword",
"lastSeenAt": "Last Seen",
"level": "Level",
"levelText": "Level {level}",
"markHandled": "Handle",
"markTop": "Pin",
"name": "Notice",
"notifyUsers": "Notify Users",
"occurrenceCount": "Count",
"reopen": "Reopen",
"reopenConfirm": "Reopen \"{0}\"?",
"reopenSuccess": "Reopened",
"severity": "Severity",
"severityError": "Error",
"severityFatal": "Fatal",
"severityInfo": "Info",
"severityWarn": "Warning",
"source": "Source",
"sourceApi": "API",
"sourceQqbot": "QQBot",
"status": "Status",
"statusHandled": "Handled",
"statusUnhandled": "Unhandled",
"summary": "Summary",
"title": "Notices",
"top": "Pinned",
"topSuccess": "Pinned",
"topNo": "No",
"topYes": "Yes",
"topSuccess": "Pinned",
"cancelTopSuccess": "Unpinned",
"toggle": "Toggle",
"toggleStatusConfirm": "Confirm toggling \"{0}\" status?",
"topToggle": "Pin Toggle",
"toggleTopConfirm": "Confirm toggling \"{0}\" pin state?"
},
"role": {

View File

@ -114,28 +114,45 @@
"unconfigured": "Loki 未配置"
},
"notice": {
"cancelTop": "取消置顶",
"cancelTopSuccess": "取消置顶成功",
"content": "内容",
"createTime": "创建时间",
"deleteConfirm": "确认删除「{0}」吗?",
"disableSuccess": "已停用站内信",
"enableSuccess": "已启用站内信",
"eventTitle": "事件标题",
"eventType": "事件类型",
"firstSeenAt": "首次发生",
"handleConfirm": "确认将「{0}」标记为已处理吗?",
"handleSuccess": "已标记为已处理",
"isTop": "置顶",
"keyword": "关键字",
"lastSeenAt": "最后发生",
"level": "等级",
"levelText": "等级 {level}",
"markHandled": "标记处理",
"markTop": "置顶",
"name": "站内信",
"notifyUsers": "通知用户",
"occurrenceCount": "次数",
"reopen": "重新打开",
"reopenConfirm": "确认重新打开「{0}」吗?",
"reopenSuccess": "已重新打开",
"severity": "级别",
"severityError": "错误",
"severityFatal": "致命",
"severityInfo": "信息",
"severityWarn": "警告",
"source": "来源",
"sourceApi": "接口",
"sourceQqbot": "QQBot",
"status": "状态",
"statusHandled": "已处理",
"statusUnhandled": "未处理",
"summary": "摘要",
"title": "站内信管理",
"top": "置顶",
"topSuccess": "置顶成功",
"topNo": "否",
"topYes": "是",
"topSuccess": "置顶成功",
"cancelTopSuccess": "取消置顶成功",
"toggle": "启停",
"toggleStatusConfirm": "确认切换「{0}」启停状态吗?",
"topToggle": "置顶切换",
"toggleTopConfirm": "确认切换「{0}」置顶状态吗?"
},
"role": {

View File

@ -1,19 +1,32 @@
import type { VbenFormSchema } from '#/adapter/form';
import { z } from '#/adapter/form';
import { $t } from '#/locales';
export const NOTICE_LEVEL_OPTIONS = [
{ label: '普通', value: 1 },
{ label: '重要', value: 2 },
{ label: '紧急', value: 3 },
export const NOTICE_SEVERITY_OPTIONS = [
{ color: 'blue', label: $t('system.notice.severityInfo'), value: 'info' },
{ color: 'orange', label: $t('system.notice.severityWarn'), value: 'warn' },
{ color: 'red', label: $t('system.notice.severityError'), value: 'error' },
{ color: 'purple', label: $t('system.notice.severityFatal'), value: 'fatal' },
];
export const NOTICE_SOURCE_OPTIONS = [
{ color: 'geekblue', label: $t('system.notice.sourceApi'), value: 'api' },
{ color: 'cyan', label: $t('system.notice.sourceQqbot'), value: 'qqbot' },
];
export const NOTICE_STATUS_OPTIONS = [
{ color: 'success', label: $t('common.enabled'), value: 1 },
{ color: 'default', label: $t('common.disabled'), value: 0 },
{ color: 'error', label: $t('system.notice.statusUnhandled'), value: 1 },
{ color: 'default', label: $t('system.notice.statusHandled'), value: 0 },
];
export function getNoticeSeverityOptions() {
return NOTICE_SEVERITY_OPTIONS;
}
export function getNoticeSourceOptions() {
return NOTICE_SOURCE_OPTIONS;
}
export function getNoticeStatusOptions() {
return NOTICE_STATUS_OPTIONS;
}
@ -32,10 +45,10 @@ export function useSearchSchema(): VbenFormSchema[] {
component: 'Select',
componentProps: {
allowClear: true,
options: NOTICE_LEVEL_OPTIONS,
options: NOTICE_SEVERITY_OPTIONS,
},
fieldName: 'level',
label: $t('system.notice.level'),
fieldName: 'severity',
label: $t('system.notice.severity'),
},
{
component: 'Select',
@ -50,113 +63,18 @@ export function useSearchSchema(): VbenFormSchema[] {
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: $t('common.no'), value: 0 },
{ label: $t('common.yes'), value: 1 },
],
options: NOTICE_SOURCE_OPTIONS,
},
fieldName: 'isTop',
label: $t('system.notice.top'),
},
];
}
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '如 系统公告',
},
fieldName: 'title',
label: $t('system.notice.title'),
rules: z
.string()
.min(1, $t('ui.formRules.required', [$t('system.notice.title')]))
.max(
120,
$t('ui.formRules.maxLength', [$t('system.notice.title'), 120]),
),
},
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '如 站内信内容概要',
},
fieldName: 'summary',
label: $t('system.notice.summary'),
rules: z
.string()
.max(
200,
$t('ui.formRules.maxLength', [$t('system.notice.summary'), 200]),
),
},
{
component: 'TextArea',
componentProps: {
autoSize: {
maxRows: 10,
minRows: 4,
},
allowClear: true,
placeholder: '请输入完整内容',
},
fieldName: 'content',
label: $t('system.notice.content'),
rules: z
.string()
.min(1, $t('ui.formRules.required', [$t('system.notice.content')])),
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: NOTICE_LEVEL_OPTIONS,
},
fieldName: 'level',
label: $t('system.notice.level'),
defaultValue: 1,
},
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '逗号分隔10001,10002留空表示全部用户可见',
},
fieldName: 'notifyUsers',
label: $t('system.notice.notifyUsers'),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{
label: $t('system.notice.topNo'),
value: false,
},
{
label: $t('system.notice.topYes'),
value: true,
},
],
},
defaultValue: false,
fieldName: 'isTop',
label: $t('system.notice.top'),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: NOTICE_STATUS_OPTIONS,
},
defaultValue: 1,
fieldName: 'status',
label: $t('system.notice.status'),
fieldName: 'source',
label: $t('system.notice.source'),
},
{
component: 'Input',
componentProps: {
allowClear: true,
},
fieldName: 'eventType',
label: $t('system.notice.eventType'),
},
];
}

View File

@ -4,15 +4,11 @@ import type { TableColumnType } from 'antdv-next';
import type { SystemNoticeApi } from '#/api/system/notice';
import type {
KtTableApi,
KtTableButton,
KtTableContext,
KtTableRowAction,
} from '#/components/ktTable';
import { h } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Page } from '@vben/common-ui';
import { message, Tag } from 'antdv-next';
@ -25,14 +21,15 @@ import {
import { KtTable, useKtTable } from '#/components/ktTable';
import { $t } from '#/locales';
import { getNoticeStatusOptions, useSearchSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
import {
getNoticeSeverityOptions,
getNoticeSourceOptions,
getNoticeStatusOptions,
useSearchSchema,
} from './data';
const noticeSeverityOptions = getNoticeSeverityOptions();
const noticeSourceOptions = getNoticeSourceOptions();
const noticeStatusOptions = getNoticeStatusOptions();
const columns: Array<TableColumnType<SystemNoticeApi.NoticeItem>> = [
@ -41,37 +38,49 @@ const columns: Array<TableColumnType<SystemNoticeApi.NoticeItem>> = [
fixed: 'left',
key: 'isTop',
title: $t('system.notice.top'),
width: 90,
width: 82,
},
{
dataIndex: 'title',
dataIndex: 'severity',
fixed: 'left',
key: 'title',
title: $t('system.notice.title'),
width: 260,
},
{
dataIndex: 'level',
key: 'level',
title: $t('system.notice.level'),
width: 100,
key: 'severity',
title: $t('system.notice.severity'),
width: 96,
},
{
dataIndex: 'status',
key: 'status',
title: $t('system.notice.status'),
width: 100,
width: 96,
},
{
dataIndex: 'notifyUsers',
key: 'notifyUsers',
title: $t('system.notice.notifyUsers'),
dataIndex: 'title',
key: 'title',
title: $t('system.notice.eventTitle'),
width: 260,
},
{
dataIndex: 'source',
key: 'source',
title: $t('system.notice.source'),
width: 110,
},
{
dataIndex: 'eventType',
key: 'eventType',
title: $t('system.notice.eventType'),
width: 180,
},
{
dataIndex: 'createTime',
key: 'createTime',
title: $t('system.notice.createTime'),
dataIndex: 'occurrenceCount',
key: 'occurrenceCount',
title: $t('system.notice.occurrenceCount'),
width: 110,
},
{
dataIndex: 'lastSeenAt',
key: 'lastSeenAt',
title: $t('system.notice.lastSeenAt'),
width: 190,
},
{
@ -79,7 +88,7 @@ const columns: Array<TableColumnType<SystemNoticeApi.NoticeItem>> = [
dataIndex: 'summary',
key: 'summary',
title: $t('system.notice.summary'),
width: 280,
width: 320,
},
];
@ -87,37 +96,38 @@ const api: KtTableApi<SystemNoticeApi.NoticeItem> = {
list: async (params) => await getNoticeList(params),
};
const buttons: Array<KtTableButton<SystemNoticeApi.NoticeItem>> = [
{
icon: () => h(Plus, { class: 'kt-table__button-icon' }),
key: 'create',
label: $t('ui.actionTitle.create', [$t('system.notice.name')]),
onClick: onCreate,
permissionCodes: ['System:Notice:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<SystemNoticeApi.NoticeItem>> = [
{
key: 'edit',
label: $t('common.edit'),
onClick: onEdit,
permissionCodes: ['System:Notice:Edit'],
},
{
confirm: (row) => $t('system.notice.toggleStatusConfirm', [row.title]),
key: 'toggle',
label: $t('system.notice.toggle'),
confirm: (row) => $t('system.notice.handleConfirm', [row.title]),
key: 'handle',
label: $t('system.notice.markHandled'),
onClick: onToggleStatus,
permissionCodes: ['System:Notice:Edit'],
rowVisible: (row) => row.status === 1,
},
{
confirm: (row) => $t('system.notice.reopenConfirm', [row.title]),
key: 'reopen',
label: $t('system.notice.reopen'),
onClick: onToggleStatus,
permissionCodes: ['System:Notice:Edit'],
rowVisible: (row) => row.status !== 1,
},
{
confirm: (row) => $t('system.notice.toggleTopConfirm', [row.title]),
key: 'top',
label: $t('system.notice.topToggle'),
key: 'markTop',
label: $t('system.notice.markTop'),
onClick: onToggleTop,
permissionCodes: ['System:Notice:Edit'],
rowVisible: (row) => !row.isTop,
},
{
confirm: (row) => $t('system.notice.toggleTopConfirm', [row.title]),
key: 'cancelTop',
label: $t('system.notice.cancelTop'),
onClick: onToggleTop,
permissionCodes: ['System:Notice:Edit'],
rowVisible: (row) => row.isTop,
},
{
confirm: (row) => $t('system.notice.deleteConfirm', [row.title]),
@ -129,19 +139,22 @@ const rowActions: Array<KtTableRowAction<SystemNoticeApi.NoticeItem>> = [
},
];
function getNoticeSeverityOption(
severity: SystemNoticeApi.NoticeItem['severity'],
) {
return noticeSeverityOptions.find((item) => item.value === severity);
}
function getNoticeSourceOption(source?: string) {
return noticeSourceOptions.find((item) => item.value === source);
}
function getNoticeStatusOption(status: SystemNoticeApi.NoticeItem['status']) {
return noticeStatusOptions.find((item) => item.value === status);
}
function getNoticeLevelColor(level: SystemNoticeApi.NoticeItem['level']) {
if (level === 3) return 'red';
if (level === 2) return 'orange';
return 'blue';
}
const [registerTable, tableApi] = useKtTable<SystemNoticeApi.NoticeItem>({
api,
buttons,
columns,
formOptions: {
schema: useSearchSchema(),
@ -152,14 +165,6 @@ const [registerTable, tableApi] = useKtTable<SystemNoticeApi.NoticeItem>({
tableTitle: $t('system.notice.title'),
});
function onCreate() {
formModalApi.setData(undefined).open();
}
function onEdit(row: SystemNoticeApi.NoticeItem) {
formModalApi.setData(row).open();
}
async function onToggleStatus(
row: SystemNoticeApi.NoticeItem,
context: KtTableContext<SystemNoticeApi.NoticeItem>,
@ -167,9 +172,9 @@ async function onToggleStatus(
const nextStatus = row.status === 1 ? 0 : 1;
await toggleNoticeStatus(row.id, nextStatus);
message.success(
nextStatus === 1
? $t('system.notice.enableSuccess')
: $t('system.notice.disableSuccess'),
nextStatus === 0
? $t('system.notice.handleSuccess')
: $t('system.notice.reopenSuccess'),
);
await context.reload();
}
@ -208,15 +213,10 @@ async function onDelete(
hideLoading();
}
}
function onRefresh() {
tableApi.reload();
}
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<KtTable @register="registerTable">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'isTop'">
@ -228,9 +228,26 @@ function onRefresh() {
}}
</Tag>
</template>
<template v-else-if="column.key === 'level'">
<Tag :color="getNoticeLevelColor(record.level)">
{{ $t('system.notice.levelText', { level: record.level }) }}
<template v-else-if="column.key === 'severity'">
<Tag
:color="
getNoticeSeverityOption(record.severity)?.color || 'default'
"
>
{{
getNoticeSeverityOption(record.severity)?.label ||
record.severity ||
'-'
}}
</Tag>
</template>
<template v-else-if="column.key === 'source'">
<Tag :color="getNoticeSourceOption(record.source)?.color || 'blue'">
{{
getNoticeSourceOption(record.source)?.label ||
record.source ||
'-'
}}
</Tag>
</template>
<template v-else-if="column.key === 'status'">
@ -240,6 +257,12 @@ function onRefresh() {
{{ getNoticeStatusOption(record.status)?.label || record.status }}
</Tag>
</template>
<template v-else-if="column.key === 'occurrenceCount'">
{{ record.occurrenceCount || 1 }}
</template>
<template v-else-if="column.key === 'summary'">
{{ record.summary || record.content || '-' }}
</template>
</template>
</KtTable>
</Page>

View File

@ -1,87 +0,0 @@
<script lang="ts" setup>
import type { SystemNoticeApi } from '#/api/system/notice';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { createNotice, updateNotice } from '#/api/system/notice';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits<{
success: [];
}>();
const formData = ref<Partial<SystemNoticeApi.NoticeItem>>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', [$t('system.notice.name')])
: $t('ui.actionTitle.create', [$t('system.notice.name')]);
});
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
modalApi.lock();
const data = (await formApi.getValues()) as SystemNoticeApi.NoticeInput;
try {
const currentId = formData.value?.id;
await (currentId ? updateNotice(currentId, data) : createNotice(data));
modalApi.close();
emit('success');
} finally {
modalApi.lock(false);
}
},
onOpenChange(isOpen) {
if (!isOpen) return;
const data = modalApi.getData<Partial<SystemNoticeApi.NoticeItem>>();
formData.value = data || undefined;
resetForm();
},
});
function getFormValues() {
return {
content: formData.value?.content || '',
isTop: formData.value?.isTop ?? false,
level: formData.value?.level ?? 1,
notifyUsers: formData.value?.notifyUsers || '',
status: formData.value?.status ?? 1,
summary: formData.value?.summary || '',
title: formData.value?.title || '',
};
}
function resetForm() {
formApi.resetForm();
formApi.setValues(getFormValues());
}
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
<template #prepend-footer>
<div class="flex-auto">
<Button type="primary" danger @click="resetForm">
{{ $t('common.reset') }}
</Button>
</div>
</template>
</Modal>
</template>