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:
liuyun 2026-10-11 14:29:18 +08:00
parent 0823c750b2
commit 73ed067a82
11 changed files with 182 additions and 9 deletions

View File

@ -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`);

View File

@ -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")
}

View File

@ -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()

View File

@ -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);
}
}

View File

@ -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' });
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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();
}
}

View File

@ -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 {}

View 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 };
}
}

View File

@ -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;
}