diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index 24a0fee..5923e78 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -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), diff --git a/src/admin/dict/dict.controller.ts b/src/admin/dict/dict.controller.ts index d507ab7..3c3666c 100644 --- a/src/admin/dict/dict.controller.ts +++ b/src/admin/dict/dict.controller.ts @@ -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))); } } diff --git a/src/admin/dict/dict.dto.ts b/src/admin/dict/dict.dto.ts index 5fbff4f..5861432 100644 --- a/src/admin/dict/dict.dto.ts +++ b/src/admin/dict/dict.dto.ts @@ -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; } diff --git a/src/admin/dict/dict.service.ts b/src/admin/dict/dict.service.ts index 51d280f..62067e1 100644 --- a/src/admin/dict/dict.service.ts +++ b/src/admin/dict/dict.service.ts @@ -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 { const list = await this.dictRepository.find({ where: { @@ -89,4 +248,79 @@ export class DictService implements OnApplicationBootstrap { })), ); } + + private applyLikeFilter( + builder: ReturnType['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 { + 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; + } } diff --git a/src/common/swagger/swagger-response.ts b/src/common/swagger/swagger-response.ts index 4783f8c..3de233a 100644 --- a/src/common/swagger/swagger-response.ts +++ b/src/common/swagger/swagger-response.ts @@ -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',