feat: 补齐站内信菜单与权限同步能力

This commit is contained in:
sunlei 2026-06-10 13:20:15 +08:00
parent 5f0351132d
commit eab71cf0b6
9 changed files with 745 additions and 0 deletions

13
API.md
View File

@ -172,6 +172,18 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
日志行包含 `timestamp`、`level`、`message`、`method`、`path`、`statusCode`、`durationMs`、`requestId`、`raw` 等字段。
### 系统站内信
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/system/notice/list` | 站内信分页列表,支持 `keyword`、`level`、`status`、`isTop`、`notifyUsers`、`pageNo`、`pageSize` |
| `GET` | `/system/notice/detail/:id` | 查询站内信详情 |
| `POST` | `/system/notice/save` | 新增站内信 |
| `POST` | `/system/notice/update` | 更新站内信 |
| `DELETE` | `/system/notice/:id` | 删除站内信(逻辑删除) |
| `POST` | `/system/notice/toggle` | 切换状态(`id`、`status` |
| `POST` | `/system/notice/top` | 切换置顶(`id`、`isTop` |
## Blog 本地内容
`/blog/*` 是本地博客内容能力,供 `Vue/kt-blog-web` 和 Admin 博客管理使用。
@ -422,6 +434,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
| `sql/blog-menu.sql` | 初始化 Blog 管理菜单 |
| `sql/qqbot-init.sql` | 初始化 QQBot 表、插件命令和字典 |
| `sql/system-log-menu.sql` | 初始化系统日志菜单和权限 |
| `sql/system-notice-menu.sql` | 初始化系统站内信表与菜单权限 |
| `sql/migrate-dict-to-admin-dict.sql` | 旧 `dict` 迁移到 `admin_dict` |
| `sql/migrate-component-to-admin-component.sql` | 旧 `component` 迁移到 `admin_component` |
| `sql/fix-admin-menu-meta.sql` | 修复菜单 meta 被覆盖为空 |

View File

@ -21,6 +21,10 @@ SET `meta` = CASE `name`
WHEN 'SystemDeptCreate' THEN '{"title":"common.create"}'
WHEN 'SystemDeptEdit' THEN '{"title":"common.edit"}'
WHEN 'SystemDeptDelete' THEN '{"title":"common.delete"}'
WHEN 'SystemNotice' THEN '{"icon":"mdi:bell-outline","title":"system.notice.title"}'
WHEN 'SystemNoticeCreate' THEN '{"title":"common.create"}'
WHEN 'SystemNoticeEdit' THEN '{"title":"common.edit"}'
WHEN 'SystemNoticeDelete' THEN '{"title":"common.delete"}'
WHEN 'SystemLog' THEN '{"icon":"lucide:scroll-text","title":"system.log.title"}'
WHEN 'Project' THEN '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}'
WHEN 'VbenDocument' THEN '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}'
@ -45,6 +49,10 @@ WHERE `name` IN (
'SystemDeptCreate',
'SystemDeptEdit',
'SystemDeptDelete',
'SystemNotice',
'SystemNoticeCreate',
'SystemNoticeEdit',
'SystemNoticeDelete',
'SystemLog',
'Project',
'VbenDocument',

View File

@ -0,0 +1,99 @@
-- 增量初始化系统站内信表与菜单权限。
-- 用途:已有库不需要重跑完整 vben-admin-init.sql 时,补齐站内信能力。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `admin_notice` (
`id` bigint NOT NULL,
`title` varchar(255) NOT NULL,
`content` longtext NOT NULL,
`summary` text DEFAULT NULL,
`level` int NOT NULL DEFAULT 1,
`status` int NOT NULL DEFAULT 1,
`is_top` tinyint(1) NOT NULL DEFAULT 0,
`notify_users` text DEFAULT NULL,
`created_by` bigint DEFAULT NULL,
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
KEY `idx_admin_notice_status` (`status`),
KEY `idx_admin_notice_is_top` (`is_top`),
KEY `idx_admin_notice_is_deleted` (`is_deleted`),
KEY `idx_admin_notice_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `admin_menu` (
`id`,
`pid`,
`name`,
`path`,
`component`,
`redirect`,
`auth_code`,
`type`,
`meta`,
`status`,
`sort`
)
VALUES (
2041700000000100206,
2041700000000100002,
'SystemNotice',
'/system/notice',
'/system/notice/list',
NULL,
'System:Notice:List',
'menu',
'{"icon":"mdi:bell-outline","title":"system.notice.title"}',
1,
7
)
ON DUPLICATE KEY UPDATE
`pid` = VALUES(`pid`),
`path` = VALUES(`path`),
`component` = VALUES(`component`),
`redirect` = VALUES(`redirect`),
`auth_code` = VALUES(`auth_code`),
`type` = VALUES(`type`),
`meta` = VALUES(`meta`),
`status` = VALUES(`status`),
`sort` = VALUES(`sort`),
`is_deleted` = 0;
INSERT INTO `admin_menu` (
`id`,
`pid`,
`name`,
`path`,
`component`,
`redirect`,
`auth_code`,
`type`,
`meta`,
`status`,
`sort`
)
VALUES
(2041700000000120211, 2041700000000100206, 'SystemNoticeCreate', NULL, NULL, NULL, 'System:Notice:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120212, 2041700000000100206, 'SystemNoticeEdit', NULL, NULL, NULL, 'System:Notice:Edit', 'button', '{"title":"common.edit"}', 1, 1),
(2041700000000120213, 2041700000000100206, 'SystemNoticeDelete', NULL, NULL, NULL, 'System:Notice:Delete', 'button', '{"title":"common.delete"}', 1, 2)
ON DUPLICATE KEY UPDATE
`pid` = VALUES(`pid`),
`path` = VALUES(`path`),
`component` = VALUES(`component`),
`redirect` = VALUES(`redirect`),
`auth_code` = VALUES(`auth_code`),
`type` = VALUES(`type`),
`meta` = VALUES(`meta`),
`status` = VALUES(`status`),
`sort` = VALUES(`sort`),
`is_deleted` = 0;
INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`)
SELECT role.`id`, menu.`id`
FROM `admin_role` role
JOIN `admin_menu` menu ON menu.`name` IN ('SystemNotice', 'SystemNoticeCreate', 'SystemNoticeEdit', 'SystemNoticeDelete')
WHERE role.`role_code` IN ('super', 'admin')
AND role.`is_deleted` = 0
AND menu.`is_deleted` = 0;

View File

@ -16,6 +16,7 @@ import { AdminMenuService } from './menu/admin-menu.service';
import { AdminRoleController } from './role/admin-role.controller';
import { AdminRole } from './role/admin-role.entity';
import { AdminRoleService } from './role/admin-role.service';
import { NoticeModule } from './notice/notice.module';
import { SystemLogController } from './system-log/system-log.controller';
import { SystemLogService } from './system-log/system-log.service';
import { AdminTimezoneController } from './timezone/admin-timezone.controller';
@ -38,6 +39,7 @@ import { WordpressModule } from '@/wordpress/wordpress.module';
]),
AdminAuthGuardModule,
DictModule,
NoticeModule,
MinioClientModule,
WordpressModule,
],

View File

@ -0,0 +1,89 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import {
CurrentAdminUser,
vbenPage,
vbenSuccess,
} from '@/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminUser } from '../user/admin-user.entity';
import {
AdminNoticeBodyDto,
AdminNoticeQueryDto,
AdminNoticeUpdateDto,
} from './admin-notice.dto';
import { AdminNoticeService } from './admin-notice.service';
@ApiTags('Admin - 站内信管理')
@Controller('system/notice')
@UseGuards(JwtAuthGuard)
export class AdminNoticeController {
constructor(private readonly noticeService: AdminNoticeService) {}
@Get('list')
@ApiOperation({
description:
'查询站内信列表:分页、标题/内容模糊检索、级别、状态、置顶状态、通知用户过滤',
summary: '查询站内信列表',
})
@ApiQuery({ name: 'pageNo', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async list(@Query() query: AdminNoticeQueryDto) {
const page = await this.noticeService.page(query);
return vbenPage(page.items, page.total);
}
@Get('detail/:id')
@ApiOperation({ summary: '查询站内信详情' })
async detail(@Param('id') id: string) {
return vbenSuccess(await this.noticeService.get(id));
}
@Post('save')
@ApiOperation({ summary: '新增站内信' })
async save(
@Body() body: AdminNoticeBodyDto,
@CurrentAdminUser() currentUser: AdminUser,
) {
return vbenSuccess(
await this.noticeService.create(body, currentUser?.id),
);
}
@Post('update')
@ApiOperation({ summary: '编辑站内信' })
async update(@Body() body: AdminNoticeUpdateDto) {
return vbenSuccess(await this.noticeService.update(body));
}
@Delete(':id')
@ApiOperation({ summary: '删除站内信' })
async remove(@Param('id') id: string) {
return vbenSuccess(await this.noticeService.remove(id));
}
@Post('toggle')
@ApiOperation({ summary: '启停站内信' })
@ApiQuery({ name: 'id', type: String })
@ApiQuery({ name: 'status', type: Number })
async toggleStatus(@Query('id') id: string, @Query('status') status: string) {
return vbenSuccess(await this.noticeService.toggleStatus(id, status));
}
@Post('top')
@ApiOperation({ summary: '置顶/取消站内信' })
@ApiQuery({ name: 'id', type: String })
@ApiQuery({ name: 'isTop', type: Number })
async toggleTop(@Query('id') id: string, @Query('isTop') isTop: string) {
return vbenSuccess(await this.noticeService.toggleTop(id, isTop));
}
}

View File

@ -0,0 +1,122 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { FormatDateTime } from '@/common';
export class AdminNoticeDto {
@ApiProperty({
example: '2041700000000300001',
})
id: string;
@ApiProperty({
example: '系统公告',
})
title: string;
@ApiProperty({
example: '站内公告正文内容',
})
content: string;
@ApiPropertyOptional({
example: '站内公告摘要',
})
summary?: string;
@ApiProperty({
example: 1,
})
level: number;
@ApiProperty({
example: 1,
})
status: number;
@ApiProperty({
example: false,
})
isTop: boolean;
@ApiPropertyOptional({
example: '100001,100002',
})
notifyUsers?: string;
@ApiPropertyOptional({
example: '2041700000000100001',
})
createdBy?: string;
@ApiProperty({
example: false,
})
isDeleted: boolean;
@ApiPropertyOptional({
example: '2026-06-03 20:00:00',
})
@FormatDateTime()
createTime?: Date;
@ApiPropertyOptional({
example: '2026-06-03 20:00:00',
})
@FormatDateTime()
updateTime?: Date;
}
export class AdminNoticeQueryDto {
@ApiPropertyOptional()
page?: number | string;
@ApiPropertyOptional()
pageNo?: number | string;
@ApiPropertyOptional()
pageSize?: number | string;
@ApiPropertyOptional()
keyword?: string;
@ApiPropertyOptional()
level?: number | string;
@ApiPropertyOptional()
status?: number | string;
@ApiPropertyOptional()
isTop?: boolean | number | string;
@ApiPropertyOptional()
notifyUsers?: string;
}
export class AdminNoticeBodyDto {
@ApiProperty({ example: '系统公告' })
title: string;
@ApiProperty({ example: '站内公告正文内容' })
content: string;
@ApiPropertyOptional({ example: '站内公告摘要' })
summary?: string;
@ApiPropertyOptional({ example: 1 })
level?: number | string;
@ApiPropertyOptional({ example: 1 })
status?: number | string;
@ApiPropertyOptional({ example: false })
isTop?: boolean | number | string;
@ApiPropertyOptional({ example: '100001,100002' })
notifyUsers?: string;
}
export class AdminNoticeUpdateDto extends PartialType(AdminNoticeBodyDto) {
@ApiProperty({
example: '2041700000000300001',
})
id: string;
}

View File

@ -0,0 +1,114 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
@Entity('admin_notice')
export class AdminNotice {
@ApiPropertyOptional()
@PrimaryColumn({
type: 'bigint',
})
id: string;
@ApiProperty({
example: '系统公告',
})
@Column()
title: string;
@ApiProperty({
example: '这是站内信内容',
})
@Column({
type: 'longtext',
})
content: string;
@ApiPropertyOptional({
example: '系统公告摘要',
})
@Column({
nullable: true,
type: 'text',
})
summary?: string;
@ApiProperty({
example: 1,
})
@Column({
default: 1,
})
level: number;
@ApiProperty({
example: 1,
})
@Column({
default: 1,
})
status: number;
@ApiProperty({
example: false,
})
@Column({
default: false,
name: 'is_top',
type: 'boolean',
})
isTop: boolean;
@ApiPropertyOptional({
example: '100001,100002',
})
@Column({
name: 'notify_users',
nullable: true,
type: 'text',
})
notifyUsers?: string;
@ApiPropertyOptional({
example: '2041700000000100001',
})
@Column({
name: 'created_by',
nullable: true,
})
createdBy?: string;
@ApiProperty({
example: false,
})
@Column({
default: false,
name: 'is_deleted',
})
isDeleted: boolean;
@CreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
@UpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,285 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, Repository } from 'typeorm';
import {
throwVbenError,
ToolsService,
} from '@/common';
import { AdminNotice } from './admin-notice.entity';
import type {
AdminNoticeBodyDto,
AdminNoticeQueryDto,
AdminNoticeUpdateDto,
} from './admin-notice.dto';
@Injectable()
export class AdminNoticeService {
constructor(
@InjectRepository(AdminNotice)
private readonly noticeRepository: Repository<AdminNotice>,
private readonly toolsService: ToolsService,
) {}
async page(query: AdminNoticeQueryDto = {}) {
const pageNo = this.toolsService.toPositiveNumber(
query.pageNo ?? query.page,
1,
);
const pageSize = this.toolsService.toPositiveNumber(query.pageSize, 20);
const builder = this.noticeRepository
.createQueryBuilder('notice')
.where('notice.isDeleted = :isDeleted', { isDeleted: false });
const keyword = this.toolsService.toTrimmedString(query.keyword);
if (keyword) {
builder.andWhere(
new Brackets((subBuilder) => {
subBuilder
.where('notice.title LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('notice.content LIKE :keyword', {
keyword: `%${keyword}%`,
})
.orWhere('notice.summary LIKE :keyword', {
keyword: `%${keyword}%`,
});
}),
);
}
this.applyLikeFilter(builder, 'notifyUsers', query.notifyUsers);
const level = this.normalizeLevel(query.level);
if (Number.isFinite(level)) {
builder.andWhere('notice.level = :level', { level });
}
const status = this.normalizeStatus(query.status);
if (Number.isFinite(status)) {
builder.andWhere('notice.status = :status', { status });
}
const isTop = this.normalizeBoolean(query.isTop);
if (isTop !== undefined) {
builder.andWhere('notice.isTop = :isTop', { isTop });
}
const [items, total] = await builder
.orderBy('notice.isTop', 'DESC')
.addOrderBy('notice.createTime', 'DESC')
.skip((pageNo - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return {
items: items.map((item) => this.serialize(item)),
total,
};
}
async get(id: string) {
const noticeId = this.toolsService.toTrimmedString(id);
if (!noticeId) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST);
const notice = await this.noticeRepository.findOne({
where: {
id: noticeId,
isDeleted: false,
},
});
if (!notice) throwVbenError('站内信不存在', HttpStatus.BAD_REQUEST);
return this.serialize(notice);
}
async create(body: AdminNoticeBodyDto, createdBy?: string) {
const input = this.normalizeInput(body);
const notice = this.noticeRepository.create({
...input,
createdBy,
});
const saved = await this.noticeRepository.save(notice);
return saved.id;
}
async update(body: AdminNoticeUpdateDto) {
const id = this.toolsService.toTrimmedString(body.id);
if (!id) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST);
const notice = await this.noticeRepository.findOne({
where: {
id,
isDeleted: false,
},
});
if (!notice) throwVbenError('站内信不存在', HttpStatus.BAD_REQUEST);
const input = this.normalizeInput({
...body,
content: body.content || notice.content,
title: body.title || notice.title,
});
await this.noticeRepository.save(
this.noticeRepository.merge(notice, input),
);
return null;
}
async remove(id: string) {
const noticeId = this.toolsService.toTrimmedString(id);
if (!noticeId) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST);
const notice = await this.noticeRepository.findOne({
where: {
id: noticeId,
isDeleted: false,
},
});
if (!notice) throwVbenError('站内信不存在', HttpStatus.BAD_REQUEST);
await this.noticeRepository.update(
{
id: noticeId,
},
{
isDeleted: true,
},
);
return null;
}
async toggleStatus(id: string, status: number | string) {
const normalizedStatus = this.normalizeStatus(status);
if (Number.isNaN(normalizedStatus)) {
throwVbenError('status 参数不合法', HttpStatus.BAD_REQUEST);
}
const noticeId = this.toolsService.toTrimmedString(id);
if (!noticeId) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST);
const notice = await this.noticeRepository.findOne({
where: {
id: noticeId,
isDeleted: false,
},
});
if (!notice) throwVbenError('站内信不存在', HttpStatus.BAD_REQUEST);
await this.noticeRepository.save(
this.noticeRepository.merge(notice, {
status: normalizedStatus,
}),
);
return null;
}
async toggleTop(id: string, isTop: boolean | number | string) {
const noticeId = this.toolsService.toTrimmedString(id);
if (!noticeId) throwVbenError('站内信ID不能为空', HttpStatus.BAD_REQUEST);
const normalizedIsTop = this.normalizeBoolean(isTop);
if (normalizedIsTop === undefined) {
throwVbenError('isTop 参数不合法', HttpStatus.BAD_REQUEST);
}
const notice = await this.noticeRepository.findOne({
where: {
id: noticeId,
isDeleted: false,
},
});
if (!notice) throwVbenError('站内信不存在', HttpStatus.BAD_REQUEST);
await this.noticeRepository.save(
this.noticeRepository.merge(notice, {
isTop: normalizedIsTop,
}),
);
return null;
}
private applyLikeFilter(
builder: ReturnType<Repository<AdminNotice>['createQueryBuilder']>,
field: keyof Pick<AdminNotice, 'notifyUsers'>,
value?: string,
) {
const normalizedValue = this.toolsService.toTrimmedString(value);
if (!normalizedValue) return;
builder.andWhere(`notice.${field} LIKE :${field}`, {
[field]: `%${normalizedValue}%`,
});
}
private normalizeBoolean(value: boolean | number | string | undefined) {
if (value === undefined || value === null) return undefined;
if (value === true || value === 1 || `${value}` === '1') return true;
if (value === false || value === 0 || `${value}` === '0') return false;
return undefined;
}
private normalizeLevel(level?: number | string) {
const normalizedLevel = Number(level);
return Number.isFinite(normalizedLevel) ? normalizedLevel : Number.NaN;
}
private normalizeStatus(status?: number | string) {
const normalizedStatus = Number(status);
return normalizedStatus === 0 || normalizedStatus === 1
? normalizedStatus
: NaN;
}
private normalizeNotifyUsers(notifyUsers?: string) {
const normalized = this.toolsService.toTrimmedString(notifyUsers);
if (!normalized) return undefined;
const userIds = normalized
.split(',')
.map((item) => this.toolsService.toTrimmedString(item))
.filter((item) => !!item)
.filter((item, index, array) => array.indexOf(item) === index);
return userIds.length ? userIds.join(',') : undefined;
}
private normalizeInput(body: AdminNoticeBodyDto) {
const title = this.toolsService.toTrimmedString(body.title);
const content = this.toolsService.toTrimmedString(body.content);
const summary = this.toolsService.toTrimmedString(body.summary) || undefined;
const level = this.normalizeLevel(body.level);
const status = this.normalizeStatus(body.status);
const isTop = this.normalizeBoolean(body.isTop);
if (!title) throwVbenError('标题不能为空', HttpStatus.BAD_REQUEST);
if (!content) throwVbenError('内容不能为空', HttpStatus.BAD_REQUEST);
return {
content,
level: Number.isFinite(level) ? level : 1,
notifyUsers: this.normalizeNotifyUsers(body.notifyUsers),
isTop: isTop ?? false,
status: status === 0 || status === 1 ? status : 1,
summary,
title,
};
}
private serialize(notice: AdminNotice) {
return {
content: notice.content,
createTime: notice.createTime,
createdBy: notice.createdBy,
id: notice.id,
isDeleted: notice.isDeleted,
isTop: notice.isTop,
level: notice.level,
notifyUsers: notice.notifyUsers,
status: notice.status,
summary: notice.summary,
title: notice.title,
updateTime: notice.updateTime,
};
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminNoticeController } from './admin-notice.controller';
import { AdminNotice } from './admin-notice.entity';
import { AdminNoticeService } from './admin-notice.service';
@Module({
imports: [TypeOrmModule.forFeature([AdminNotice])],
controllers: [AdminNoticeController],
providers: [AdminNoticeService],
})
export class NoticeModule {}