feat: dict-driven enums — built-in dicts + protection + aggregate query
- dict_type/dict_item 增加 built_in/color_type 字段与唯一约束 - seed 预置 5 个内置枚举字典(幂等) - 内置类型/项删除与内置项 value 修改保护 - 新增 GET /dicts 聚合接口(登录态) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0823c750b2
commit
73ed067a82
@ -0,0 +1,9 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `sys_dict_item` ADD COLUMN `built_in` BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN `color_type` VARCHAR(16) NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `sys_dict_type` ADD COLUMN `built_in` BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `sys_dict_item_tenant_code_dict_type_code_value_key` ON `sys_dict_item`(`tenant_code`, `dict_type_code`, `value`);
|
||||
@ -120,6 +120,7 @@ model DictType {
|
||||
code String @db.VarChar(64)
|
||||
name String @db.VarChar(64)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
builtIn Boolean @default(false) @map("built_in")
|
||||
remark String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
@ -140,11 +141,14 @@ model DictItem {
|
||||
value String @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
builtIn Boolean @default(false) @map("built_in")
|
||||
colorType String? @map("color_type") @db.VarChar(16)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@unique([tenantCode, dictTypeCode, value])
|
||||
@@index([tenantCode, dictTypeCode])
|
||||
@@map("sys_dict_item")
|
||||
}
|
||||
|
||||
@ -70,6 +70,55 @@ const BUTTONS = [
|
||||
{ code: 'login-log:export', name: '登录日志导出', parent: 'login-log' },
|
||||
];
|
||||
|
||||
/** 内置枚举字典(值对齐 src/common/enums.ts,仅展示层 label/颜色/排序可改)。 */
|
||||
const DICTS: { code: string; name: string; items: { label: string; value: string; colorType: string; sort: number }[] }[] = [
|
||||
{
|
||||
code: 'common_status',
|
||||
name: '通用状态',
|
||||
items: [
|
||||
{ label: '启用', value: 'enabled', colorType: 'success', sort: 1 },
|
||||
{ label: '停用', value: 'disabled', colorType: 'info', sort: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'user_status',
|
||||
name: '用户状态',
|
||||
items: [
|
||||
{ label: '启用', value: 'enabled', colorType: 'success', sort: 1 },
|
||||
{ label: '停用', value: 'disabled', colorType: 'info', sort: 2 },
|
||||
{ label: '锁定', value: 'locked', colorType: 'danger', sort: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'resource_type',
|
||||
name: '资源类型',
|
||||
items: [
|
||||
{ label: '菜单', value: 'menu', colorType: 'primary', sort: 1 },
|
||||
{ label: '按钮', value: 'button', colorType: 'warning', sort: 2 },
|
||||
{ label: '接口', value: 'api', colorType: 'info', sort: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'data_scope',
|
||||
name: '数据权限',
|
||||
items: [
|
||||
{ label: '全部', value: 'all', colorType: 'success', sort: 1 },
|
||||
{ label: '本部门', value: 'dept', colorType: 'info', sort: 2 },
|
||||
{ label: '本部门及以下', value: 'dept_and_child', colorType: 'warning', sort: 3 },
|
||||
{ label: '本人', value: 'self', colorType: 'danger', sort: 4 },
|
||||
{ label: '自定义', value: 'custom', colorType: 'primary', sort: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'login_status',
|
||||
name: '登录状态',
|
||||
items: [
|
||||
{ label: '成功', value: 'success', colorType: 'success', sort: 1 },
|
||||
{ label: '失败', value: 'fail', colorType: 'danger', sort: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// 1. 租户
|
||||
await prisma.tenant.upsert({
|
||||
@ -170,7 +219,32 @@ async function main(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`seed 完成:租户 ${TENANT}、用户 ${ADMIN_USERNAME}、角色 ${SUPER_ROLE}、资源 ${allResourceCodes.length} 个(已全量授权)。`);
|
||||
// 8. 内置字典(类型 + 项,幂等)
|
||||
for (const d of DICTS) {
|
||||
await prisma.dictType.upsert({
|
||||
where: { tenantCode_code: { tenantCode: TENANT, code: d.code } },
|
||||
update: { name: d.name, status: 'enabled', builtIn: true },
|
||||
create: { tenantCode: TENANT, code: d.code, name: d.name, status: 'enabled', builtIn: true },
|
||||
});
|
||||
for (const it of d.items) {
|
||||
await prisma.dictItem.upsert({
|
||||
where: { tenantCode_dictTypeCode_value: { tenantCode: TENANT, dictTypeCode: d.code, value: it.value } },
|
||||
update: { label: it.label, colorType: it.colorType, sort: it.sort, status: 'enabled', builtIn: true },
|
||||
create: {
|
||||
tenantCode: TENANT,
|
||||
dictTypeCode: d.code,
|
||||
label: it.label,
|
||||
value: it.value,
|
||||
colorType: it.colorType,
|
||||
sort: it.sort,
|
||||
status: 'enabled',
|
||||
builtIn: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`seed 完成:租户 ${TENANT}、用户 ${ADMIN_USERNAME}、角色 ${SUPER_ROLE}、资源 ${allResourceCodes.length} 个(已全量授权)、内置字典 ${DICTS.length} 个。`);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@ -38,6 +38,6 @@ export class DictItemsController {
|
||||
@RequirePermissions(Permissions.dict.delete)
|
||||
@AuditLog('dict', 'delete')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.softRemove(id);
|
||||
return this.service.removeItem(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { CreateDictItemDto, UpdateDictItemDto } from './dto.js';
|
||||
@ -13,10 +13,22 @@ export class DictItemsService extends BaseCrudService {
|
||||
return this.create({ ...dto });
|
||||
}
|
||||
|
||||
updateItem(id: string, dto: UpdateDictItemDto) {
|
||||
/** 更新前拦截内置项 value 变更(label/sort/colorType 放行)。 */
|
||||
async updateItem(id: string, dto: UpdateDictItemDto) {
|
||||
const item = await this.findById(id);
|
||||
if (item?.builtIn && dto.value !== undefined && dto.value !== item.value) {
|
||||
throw new BadRequestException('内置字典项 value 不可修改');
|
||||
}
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
|
||||
/** 删除前拦截内置项。 */
|
||||
async removeItem(id: string) {
|
||||
const item = await this.findById(id);
|
||||
if (item?.builtIn) throw new BadRequestException('内置字典项不可删除');
|
||||
return this.softRemove(id);
|
||||
}
|
||||
|
||||
listByType(dictTypeCode?: string) {
|
||||
return this.list(dictTypeCode ? { dictTypeCode } : {}, { sort: 'asc' });
|
||||
}
|
||||
|
||||
@ -39,6 +39,6 @@ export class DictTypesController {
|
||||
@RequirePermissions(Permissions.dict.delete)
|
||||
@AuditLog('dict', 'delete')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.softRemove(id);
|
||||
return this.service.removeType(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { CreateDictTypeDto, UpdateDictTypeDto } from './dto.js';
|
||||
@ -16,4 +16,11 @@ export class DictTypesService extends BaseCrudService {
|
||||
updateType(id: string, dto: UpdateDictTypeDto) {
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
|
||||
/** 删除前拦截内置类型。 */
|
||||
async removeType(id: string) {
|
||||
const type = await this.findById(id);
|
||||
if (type?.builtIn) throw new BadRequestException('内置字典类型不可删除');
|
||||
return this.softRemove(id);
|
||||
}
|
||||
}
|
||||
|
||||
13
src/dicts/dicts.controller.ts
Normal file
13
src/dicts/dicts.controller.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { DictsService } from './dicts.service.js';
|
||||
|
||||
/** 字典聚合接口:前端登录后一次性拉取全部启用字典(仅登录态,无需权限码)。 */
|
||||
@Controller('dicts')
|
||||
export class DictsController {
|
||||
constructor(private readonly service: DictsService) {}
|
||||
|
||||
@Get()
|
||||
all() {
|
||||
return this.service.allEnabled();
|
||||
}
|
||||
}
|
||||
@ -3,9 +3,11 @@ import { DictTypesController } from './dict-types.controller.js';
|
||||
import { DictTypesService } from './dict-types.service.js';
|
||||
import { DictItemsController } from './dict-items.controller.js';
|
||||
import { DictItemsService } from './dict-items.service.js';
|
||||
import { DictsController } from './dicts.controller.js';
|
||||
import { DictsService } from './dicts.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [DictTypesController, DictItemsController],
|
||||
providers: [DictTypesService, DictItemsService],
|
||||
controllers: [DictTypesController, DictItemsController, DictsController],
|
||||
providers: [DictTypesService, DictItemsService, DictsService],
|
||||
})
|
||||
export class DictsModule {}
|
||||
|
||||
39
src/dicts/dicts.service.ts
Normal file
39
src/dicts/dicts.service.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
|
||||
export interface DictAggregateItem {
|
||||
label: string;
|
||||
value: string;
|
||||
colorType: string | null;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
export interface DictAggregateType {
|
||||
code: string;
|
||||
name: string;
|
||||
items: DictAggregateItem[];
|
||||
}
|
||||
|
||||
/** 字典聚合查询:当前租户全部启用字典类型 + 启用字典项。 */
|
||||
@Injectable()
|
||||
export class DictsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async allEnabled(): Promise<{ list: DictAggregateType[] }> {
|
||||
const [types, items] = await Promise.all([
|
||||
this.prisma.client.dictType.findMany({ where: { status: 'enabled' }, orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.client.dictItem.findMany({
|
||||
where: { status: 'enabled' },
|
||||
orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
const list = types.map((t) => ({
|
||||
code: t.code,
|
||||
name: t.name,
|
||||
items: items
|
||||
.filter((i) => i.dictTypeCode === t.code)
|
||||
.map((i) => ({ label: i.label, value: i.value, colorType: i.colorType, sort: i.sort })),
|
||||
}));
|
||||
return { list };
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/** 标签色(对应前端 TagType)。 */
|
||||
const COLOR_TYPES = ['primary', 'success', 'warning', 'danger', 'info'] as const;
|
||||
|
||||
export class CreateDictTypeDto {
|
||||
@ApiProperty({ description: '字典类型编码' })
|
||||
@ -55,6 +58,11 @@ export class CreateDictItemDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sort?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '标签色:primary/success/warning/danger/info' })
|
||||
@IsOptional()
|
||||
@IsIn(COLOR_TYPES)
|
||||
colorType?: string;
|
||||
}
|
||||
|
||||
export class UpdateDictItemDto {
|
||||
@ -77,4 +85,9 @@ export class UpdateDictItemDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '标签色:primary/success/warning/danger/info' })
|
||||
@IsOptional()
|
||||
@IsIn(COLOR_TYPES)
|
||||
colorType?: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user