feat: 接入字典管理接口

This commit is contained in:
sunlei 2026-06-03 22:51:18 +08:00
parent cf2c20ba22
commit bd4937a8c7
5 changed files with 452 additions and 18 deletions

View File

@ -171,11 +171,15 @@ VALUES
(2041700000000120101, 2041700000000100201, 'SystemMenuCreate', NULL, NULL, NULL, 'System:Menu:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120102, 2041700000000100201, 'SystemMenuEdit', NULL, NULL, NULL, 'System:Menu:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120103, 2041700000000100201, 'SystemMenuDelete', NULL, NULL, NULL, 'System:Menu:Delete', 'button', '{"title":"common.delete"}', 1, 0),
(2041700000000100202, 2041700000000100002, 'SystemDept', '/system/dept', '/system/dept/list', NULL, 'System:Dept:List', 'menu', '{"icon":"carbon:container-services","title":"system.dept.title"}', 1, 3),
(2041700000000100204, 2041700000000100002, 'SystemDict', '/system/dict', '/system/dict/list', NULL, 'System:Dict:List', 'menu', '{"icon":"carbon:data-structured","title":"system.dict.title"}', 1, 3),
(2041700000000120207, 2041700000000100204, 'SystemDictCreate', NULL, NULL, NULL, 'System:Dict:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120208, 2041700000000100204, 'SystemDictEdit', NULL, NULL, NULL, 'System:Dict:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120209, 2041700000000100204, 'SystemDictDelete', NULL, NULL, NULL, 'System:Dict:Delete', 'button', '{"title":"common.delete"}', 1, 0),
(2041700000000100202, 2041700000000100002, 'SystemDept', '/system/dept', '/system/dept/list', NULL, 'System:Dept:List', 'menu', '{"icon":"carbon:container-services","title":"system.dept.title"}', 1, 4),
(2041700000000120201, 2041700000000100202, 'SystemDeptCreate', NULL, NULL, NULL, 'System:Dept:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120202, 2041700000000100202, 'SystemDeptEdit', NULL, NULL, NULL, 'System:Dept:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120203, 2041700000000100202, 'SystemDeptDelete', NULL, NULL, NULL, 'System:Dept:Delete', 'button', '{"title":"common.delete"}', 1, 0),
(2041700000000100203, 2041700000000100002, 'SystemKtTableDemo', '/system/ktTableDemo', '/system/ktTableDemo/list', NULL, 'System:KtTableDemo:List', 'menu', '{"icon":"lucide:table-2","title":"system.ktTableDemo.title"}', 1, 4),
(2041700000000100203, 2041700000000100002, 'SystemKtTableDemo', '/system/ktTableDemo', '/system/ktTableDemo/list', NULL, 'System:KtTableDemo:List', 'menu', '{"icon":"lucide:table-2","title":"system.ktTableDemo.title"}', 1, 5),
(2041700000000120204, 2041700000000100203, 'SystemKtTableDemoCreate', NULL, NULL, NULL, 'System:KtTableDemo:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120205, 2041700000000100203, 'SystemKtTableDemoEdit', NULL, NULL, NULL, 'System:KtTableDemo:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120206, 2041700000000100203, 'SystemKtTableDemoDelete', NULL, NULL, NULL, 'System:KtTableDemo:Delete', 'button', '{"title":"common.delete"}', 1, 0),

View File

@ -1,16 +1,31 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import { DictService } from './dict.service';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ApiArrayResponse, ToolsService } from '@/common';
import { DictDto } from './dict.dto';
import {
ApiArrayResponse,
ApiPageResponse,
vbenPage,
vbenSuccess,
} from '@/common';
import {
AdminDictBodyDto,
AdminDictDto,
AdminDictQueryDto,
AdminDictUpdateDto,
DictDto,
} from './dict.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
const componentTypeDictExample = [
@ -39,28 +54,78 @@ const chartDictExample = [
@Controller('dict')
@UseGuards(JwtAuthGuard)
export class DictController {
constructor(
private readonly toolsService: ToolsService,
private readonly dictService: DictService,
) {}
constructor(private readonly dictService: DictService) {}
@ApiOperation({ summary: '获取字典分页列表' })
@ApiPageResponse(AdminDictDto, [
{
id: '2041700000000300001',
dictCode: 'COMPONENT_TYPE',
label: '图表',
value: '1',
childrenCode: 'CHART',
sort: 1,
status: 1,
},
])
@Get('list')
async list(@Query() query: AdminDictQueryDto) {
const page = await this.dictService.page(query);
return vbenPage(page.items, page.total);
}
@ApiOperation({ summary: '获取字典编码选项' })
@Get('codes')
async codes() {
return vbenSuccess(await this.dictService.getDictCodeOptions());
}
@ApiOperation({ summary: '根据key获取字典' })
@ApiQuery({ name: 'dictKey', type: String })
@ApiArrayResponse(DictDto, componentTypeDictExample)
@Get('getDictByKey')
async getDictByKey(@Res() res, @Query('dictKey') dictKey: string) {
async getDictByKey(@Query('dictKey') dictKey: string) {
const dict = await this.dictService.getDictByKey(dictKey);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', dict));
return vbenSuccess(dict);
}
@ApiOperation({ summary: '根据组件类型获取组件字典' })
@ApiQuery({ name: 'type', type: Number })
@ApiArrayResponse(DictDto, chartDictExample)
@Get('getComponentDictByType')
async getComponentDictByType(@Res() res, @Query('type', ParseIntPipe) type) {
async getComponentDictByType(@Query('type', ParseIntPipe) type) {
const dict = await this.dictService.getComponentDictByType(type);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', dict));
return vbenSuccess(dict);
}
@ApiOperation({ summary: '新增字典项' })
@Post('save')
@HttpCode(HttpStatus.OK)
async save(@Body() body: AdminDictBodyDto) {
return vbenSuccess(await this.dictService.save(body));
}
@ApiOperation({ summary: '编辑字典项' })
@Post('update')
@HttpCode(HttpStatus.OK)
async update(@Body() body: AdminDictUpdateDto) {
return vbenSuccess(await this.dictService.update(body));
}
@ApiOperation({ summary: '删除字典项' })
@Delete(':id')
async remove(@Param('id') id: string) {
return vbenSuccess(await this.dictService.remove(id));
}
@ApiOperation({ summary: '启停字典项' })
@Post('toggle')
@HttpCode(HttpStatus.OK)
@ApiQuery({ name: 'id', type: String })
@ApiQuery({ name: 'status', type: Number })
async toggle(@Query('id') id: string, @Query('status') status: string) {
return vbenSuccess(await this.dictService.toggle(id, Number(status)));
}
}

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class DictDto {
@ApiProperty({
@ -15,5 +16,120 @@ export class DictDto {
@ApiProperty({
example: 1,
})
value: number;
value: number | string;
}
export class AdminDictDto {
@ApiProperty({
example: '2041700000000300001',
})
id: string;
@ApiProperty({
example: 'COMPONENT_TYPE',
})
dictCode: string;
@ApiProperty({
example: '图表',
})
label: string;
@ApiProperty({
example: '1',
})
value: string;
@ApiPropertyOptional({
example: 'CHART',
})
childrenCode?: string;
@ApiPropertyOptional({
example: 1,
})
sort?: number;
@ApiPropertyOptional({
example: 1,
})
status?: number;
@ApiPropertyOptional({
example: '2026-06-03T12:00:00.000Z',
})
createTime?: Date;
@ApiPropertyOptional({
example: '2026-06-03T12:00:00.000Z',
})
updateTime?: Date;
}
export class AdminDictQueryDto {
@ApiPropertyOptional()
page?: number | string;
@ApiPropertyOptional()
pageNo?: number | string;
@ApiPropertyOptional()
pageSize?: number | string;
@ApiPropertyOptional()
keyword?: string;
@ApiPropertyOptional()
dictCode?: string;
@ApiPropertyOptional()
label?: string;
@ApiPropertyOptional()
value?: string;
@ApiPropertyOptional()
childrenCode?: string;
@ApiPropertyOptional()
status?: number | string;
}
export class AdminDictBodyDto {
@ApiProperty({
example: 'COMPONENT_TYPE',
})
dictCode: string;
@ApiProperty({
example: '图表',
})
label: string;
@ApiProperty({
example: '1',
})
value: string;
@ApiPropertyOptional({
example: 'CHART',
})
childrenCode?: string;
@ApiPropertyOptional({
example: 1,
})
sort?: number;
@ApiPropertyOptional({
example: 1,
})
status?: number;
}
export class AdminDictUpdateDto extends PartialType(AdminDictBodyDto) {
@ApiProperty({
example: '2041700000000300001',
})
id: string;
}

View File

@ -1,8 +1,13 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { HttpStatus, Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { setDictDecodeCache } from '@/common';
import { Brackets, Repository } from 'typeorm';
import { setDictDecodeCache, throwVbenError } from '@/common';
import { AdminDict } from './admin-dict.entity';
import {
AdminDictBodyDto,
AdminDictQueryDto,
AdminDictUpdateDto,
} from './dict.dto';
const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE';
@ -32,6 +37,160 @@ export class DictService implements OnApplicationBootstrap {
}));
}
async page(query: AdminDictQueryDto = {}) {
const pageNo = this.toPositiveNumber(query.pageNo ?? query.page, 1);
const pageSize = this.toPositiveNumber(query.pageSize, 20);
const builder = this.dictRepository
.createQueryBuilder('dict')
.where('dict.isDeleted = :isDeleted', { isDeleted: false });
const keyword = this.normalizeText(query.keyword);
if (keyword) {
builder.andWhere(
new Brackets((subBuilder) => {
subBuilder
.where('dict.dictCode LIKE :keyword', {
keyword: `%${keyword}%`,
})
.orWhere('dict.label LIKE :keyword', {
keyword: `%${keyword}%`,
})
.orWhere('dict.value LIKE :keyword', {
keyword: `%${keyword}%`,
})
.orWhere('dict.childrenCode LIKE :keyword', {
keyword: `%${keyword}%`,
});
}),
);
}
this.applyLikeFilter(builder, 'dictCode', query.dictCode);
this.applyLikeFilter(builder, 'label', query.label);
this.applyLikeFilter(builder, 'value', query.value);
this.applyLikeFilter(builder, 'childrenCode', query.childrenCode);
if (['0', '1'].includes(String(query.status))) {
builder.andWhere('dict.status = :status', {
status: Number(query.status),
});
}
const [items, total] = await builder
.orderBy('dict.dictCode', 'ASC')
.addOrderBy('dict.sort', 'ASC')
.addOrderBy('dict.createTime', 'ASC')
.skip((pageNo - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return {
items: items.map((item) => this.serializeDict(item)),
total,
};
}
async getDictCodeOptions() {
const rows = await this.dictRepository
.createQueryBuilder('dict')
.select('DISTINCT dict.dictCode', 'dictCode')
.where('dict.isDeleted = :isDeleted', { isDeleted: false })
.orderBy('dict.dictCode', 'ASC')
.getRawMany<{ dictCode: string }>();
return rows
.filter((item) => !!item.dictCode)
.map((item) => ({
label: item.dictCode,
value: item.dictCode,
}));
}
async save(body: AdminDictBodyDto) {
const input = this.normalizeInput(body);
const existing = await this.findByCodeValue(input.dictCode, input.value);
if (existing && !existing.isDeleted) {
throwVbenError('同一字典编码下的字典值已存在', HttpStatus.BAD_REQUEST);
}
const entity = existing
? this.dictRepository.merge(existing, {
...input,
isDeleted: false,
})
: this.dictRepository.create(input);
await this.dictRepository.save(entity);
await this.refreshDecodeCache();
return entity.id;
}
async update(body: AdminDictUpdateDto) {
const id = this.normalizeText(body.id);
if (!id) throwVbenError('字典项ID不能为空', HttpStatus.BAD_REQUEST);
const dict = await this.dictRepository.findOne({
where: {
id,
isDeleted: false,
},
});
if (!dict) throwVbenError('字典项不存在', HttpStatus.BAD_REQUEST);
const input = this.normalizeInput({
childrenCode: body.childrenCode ?? dict.childrenCode,
dictCode: body.dictCode ?? dict.dictCode,
label: body.label ?? dict.label,
sort: body.sort ?? dict.sort,
status: body.status ?? dict.status,
value: body.value ?? dict.value,
});
const existing = await this.findByCodeValue(input.dictCode, input.value);
if (existing && existing.id !== id) {
throwVbenError('同一字典编码下的字典值已存在', HttpStatus.BAD_REQUEST);
}
await this.dictRepository.save(
this.dictRepository.merge(dict, {
...input,
}),
);
await this.refreshDecodeCache();
return null;
}
async remove(id: string) {
const normalizedId = this.normalizeText(id);
if (!normalizedId)
throwVbenError('字典项ID不能为空', HttpStatus.BAD_REQUEST);
const dict = await this.dictRepository.findOne({
where: {
id: normalizedId,
isDeleted: false,
},
});
if (!dict) throwVbenError('字典项不存在', HttpStatus.BAD_REQUEST);
await this.dictRepository.update(
{ id: normalizedId },
{
isDeleted: true,
},
);
await this.refreshDecodeCache();
return null;
}
async toggle(id: string, status: number) {
const normalizedStatus = status === 1 ? 1 : 0;
await this.update({
id,
status: normalizedStatus,
});
return null;
}
async getDictItemsByKey(dictKey: string): Promise<AdminDictItem[]> {
const list = await this.dictRepository.find({
where: {
@ -89,4 +248,79 @@ export class DictService implements OnApplicationBootstrap {
})),
);
}
private applyLikeFilter(
builder: ReturnType<Repository<AdminDict>['createQueryBuilder']>,
field: keyof Pick<
AdminDict,
'childrenCode' | 'dictCode' | 'label' | 'value'
>,
value?: string,
) {
const normalizedValue = this.normalizeText(value);
if (!normalizedValue) return;
builder.andWhere(`dict.${field} LIKE :${field}`, {
[field]: `%${normalizedValue}%`,
});
}
private async findByCodeValue(dictCode: string, value: string) {
return this.dictRepository.findOne({
where: {
dictCode,
value,
},
});
}
private normalizeInput(body: AdminDictBodyDto): Partial<AdminDict> {
const dictCode = this.normalizeText(body.dictCode);
const label = this.normalizeText(body.label);
const value = this.normalizeText(body.value);
if (!dictCode) throwVbenError('字典编码不能为空', HttpStatus.BAD_REQUEST);
if (!label) throwVbenError('字典标签不能为空', HttpStatus.BAD_REQUEST);
if (!value) throwVbenError('字典值不能为空', HttpStatus.BAD_REQUEST);
return {
childrenCode: this.normalizeNullableText(body.childrenCode),
dictCode,
label,
sort: Number.isFinite(Number(body.sort)) ? Number(body.sort) : 0,
status: Number(body.status) === 0 ? 0 : 1,
value,
};
}
private normalizeNullableText(value?: number | string | null) {
const text = this.normalizeText(value);
return text || null;
}
private normalizeText(value?: number | string | null) {
return value === undefined || value === null ? '' : String(value).trim();
}
private serializeDict(dict: AdminDict) {
return {
childrenCode: dict.childrenCode,
createTime: dict.createTime,
dictCode: dict.dictCode,
id: dict.id,
label: dict.label,
sort: dict.sort,
status: dict.status,
updateTime: dict.updateTime,
value: dict.value,
};
}
private toPositiveNumber(
value: number | string | undefined,
fallback: number,
) {
const nextValue = Number(value);
return Number.isFinite(nextValue) && nextValue > 0 ? nextValue : fallback;
}
}

View File

@ -717,6 +717,7 @@ function itemExampleByPath(path: string) {
return adminMenuExample();
if (path.includes('/system/dept')) return adminDeptExample();
if (path.includes('/system/role')) return adminRoleExample();
if (path.includes('/dict')) return adminDictExample();
if (path.includes('/component')) return componentExample();
if (path.includes('/user')) return adminUserExample();
if (path.includes('/timezone')) return { timezone: 'Asia/Shanghai' };
@ -742,6 +743,20 @@ function adminLoginExample() {
};
}
function adminDictExample() {
return {
id: '2041700000000300001',
dictCode: 'COMPONENT_TYPE',
label: '图表',
value: '1',
childrenCode: 'CHART',
sort: 1,
status: 1,
createTime: '2026-06-03T12:00:00.000Z',
updateTime: '2026-06-03T12:00:00.000Z',
};
}
function adminUserExample() {
return {
id: '1000000000000000001',