feat: 支持数据库字典映射并整理公共能力

- 新增 dict 实体表,字典查询改为从数据库读取
- 使用 childrenKey 实现组件一级类型到二级字典的关系映射
- Component 查询后通过 DecodeDictKey 和 AfterLoad 自动补充翻译字段
- 移除 ComponentDto、本地字典常量和本地 seed 文件
- 将 common 能力按 decorators/interceptors/services/swagger 重新分层
- 使用全局 SaveBodyInterceptor 替代 SaveMiddleware 统一处理 save 请求
- 更新 MinIO、组件、字典接口的 Swagger 响应和少量关键注释
- 重写 README.md 和 API.md,补充项目功能、目录结构和接口说明

BREAKING CHANGE: 字典数据依赖数据库 dict 表维护,不再使用本地字典常量。
This commit is contained in:
sunlei 2026-05-13 15:33:38 +08:00
parent f236d86ff3
commit 7291fed682
23 changed files with 749 additions and 483 deletions

175
API.md
View File

@ -1,8 +1,8 @@
# KT Template Online API
后端服务默认监听 `48085`Swagger 地址为 `/api`OpenAPI JSON 地址为 `/api-json`接口除文件下载外,统一返回 `{ code, msg, data }`
后端服务默认监听 `48085`Swagger 地址为 `/api`OpenAPI JSON 地址为 `/api-json`
## 通用响应
除文件下载接口外,接口统一返回:
```json
{
@ -22,50 +22,92 @@
}
```
## Root
## 功能模块
### GET `/`
| 模块 | 说明 |
| --------- | --------------------------------------------------------------------- |
| Component | 组件/图表模板的列表、详情、新增、编辑、逻辑删除 |
| Dict | 数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 |
| MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
| Common | 统一响应 Swagger 注解、字典翻译注解、`POST */save` 请求体规范化拦截器 |
重定向到 Swagger 文档页 `/api#/`HTTP 状态码为 `301`
## 通用规则
### Save 请求体规范化
系统全局注册 `SaveBodyInterceptor`,默认会对 `POST */save` 请求删除 `body.id`,避免新增接口因为前端误传 `id` 而走指定主键保存。
如果个别接口需要保留 `id`,可在对应 Controller 方法上使用 `@SkipSaveBodyNormalize()`
### 数据库字典翻译
字典数据维护在数据库 `dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射。
`dict` 表核心字段:
| 字段 | 类型 | 说明 |
| ----------- | ------- | ------------------------------------------------------ |
| id | string | 字典 ID |
| dictKey | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
| label | string | 展示文本 |
| value | string | 字典值 |
| childrenKey | string | 子字典分组,例如 `COMPONENT_TYPE.value=1` 指向 `CHART` |
| sort | number | 排序 |
| is_deleted | boolean | 逻辑删除标记 |
当前数据库示例关系:
| dictKey | value | label | childrenKey |
| -------------- | ----- | ----- | ----------- |
| COMPONENT_TYPE | 1 | 图表 | CHART |
| COMPONENT_TYPE | 2 | 组件 | COMPONENT |
## 数据结构
### Component
| 字段 | 类型 | 说明 |
| ---------------- | ------- | ---------------------------- |
| ---------------- | ------- | ---------------------------------- |
| id | string | 组件 ID新增时由后端生成 |
| name | string | 组件名称 |
| type | number | 一级类型,`1` 图表,`2` 组件 |
| componentType | number | 二级类型 |
| typeMsg | string | 一级类型文本,列表接口返回 |
| componentTypeMsg | string | 二级类型文本,列表接口返回 |
| image | string | 封面图 |
| template | string | playground 序列化模板内容 |
| type | number | 一级类型,实际含义由 `dict` 表维护 |
| componentType | number | 二级类型,实际含义由 `dict` 表维护 |
| typeMsg | string | 一级类型文本,查询后自动映射 |
| componentTypeMsg | string | 二级类型文本,查询后自动映射 |
| image | string | 封面图或封面图地址 |
| template | string | Playground 序列化模板内容 |
| createTime | string | 创建时间 |
| updateTime | string | 更新时间 |
| is_deleted | boolean | 逻辑删除标记 |
### 字典
### Dict
`COMPONENT_TYPE`
接口返回的字典项结构
| label | value |
| ----- | ----- |
| 图表 | 1 |
| 组件 | 2 |
| 字段 | 类型 | 说明 |
| ----- | ------------- | -------- |
| label | string | 展示文本 |
| value | number/string | 字典值 |
`CHART``未分类(-1)`、`折线图(1)`、`柱状图(2)`、`饼图(3)`、`散点图(4)`、`地图(5)`、`K线图(6)`、`雷达图(7)`、`盒须图(8)`、`热力图(9)`、`关系图(10)`、`路径图(11)`、`树图(12)`、`矩树图(13)`、`旭日图(14)`、`平行坐标系(15)`、`桑基图(16)`、`漏斗图(17)`、`仪表盘(18)`、`象形图(19)`、`河流图(20)`、`水球(21)`、`词云(22)`。
### MinIO
`COMPONENT``未分类(-1)`、`表格(23)`、`表单(24)`、`容器(25)`
`bucketName` 未传时默认读取环境变量 `MINIO_BUCKET`,缺省值为 `kt-template-online`
## Component
## Root
### GET `/`
重定向到 Swagger 文档页 `/api#/`HTTP 状态码为 `301`
## Component 接口
### GET `/component/allList`
获取全部组件。
响应示例:
响应 `data``Component[]`。
示例:
```json
{
@ -91,19 +133,26 @@
### GET `/component/list`
分页获取组件列表。
分页获取组件列表。列表默认过滤 `is_deleted=false`,并支持按名称模糊搜索。
Query
| 参数 | 类型 | 必填 | 说明 |
| ------------- | ------ | ---- | ------------ |
| ------------- | ------ | ---- | ---------------- |
| pageNo | number | 是 | 页码 |
| pageSize | number | 是 | 每页条数 |
| name | string | 否 | 名称模糊搜索 |
| name | string | 否 | 组件名称模糊搜索 |
| type | number | 否 | 一级类型 |
| componentType | number | 否 | 二级类型 |
响应 `data``{ list: Component[], total: number }`。
响应 `data`
```ts
{
list: Component[]
total: number
}
```
### GET `/component/detail`
@ -119,7 +168,7 @@ Query
### POST `/component/save`
新增组件。`SaveMiddleware` 会删除 body 中的 `id`,新增时不需要传 `id`
新增组件。全局 `SaveBodyInterceptor` 会删除 `body.id`,新增时不需要传 `id`
Body
@ -133,7 +182,7 @@ Body
}
```
响应示例:
响应 `data`:新增组件 ID。
```json
{
@ -174,17 +223,17 @@ Query
响应 `data``true` 表示删除成功。
## Dict
## Dict 接口
### GET `/dict/getDictByKey`
根据字典 key 获取字典
根据字典分组获取字典项
Query
| 参数 | 类型 | 必填 | 可选值 |
| ------- | ------ | ---- | -------------------------------------- |
| dictKey | string | 是 | `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
| 参数 | 类型 | 必填 | 说明 |
| ------- | ------ | ---- | ---------------------- |
| dictKey | string | 是 | 字典分组,例如 `CHART` |
响应示例:
@ -194,12 +243,8 @@ Query
"msg": "操作成功",
"data": [
{
"label": "图",
"label": "折线图",
"value": 1
},
{
"label": "组件",
"value": 2
}
]
}
@ -207,23 +252,29 @@ Query
### GET `/dict/getComponentDictByType`
根据一级类型获取二级类型字典。
根据组件一级类型获取对应的二级类型字典。
查询逻辑:先查 `dictKey=COMPONENT_TYPE``value=type` 的字典项,再使用该项的 `childrenKey` 查询子字典。
Query
| 参数 | 类型 | 必填 | 说明 |
| ---- | ------ | ---- | ------------------ |
| type | number | 是 | `1` 图表,`2` 组件 |
| ---- | ------ | ---- | -------- |
| type | number | 是 | 一级类型 |
响应 `data``Array<{ label: string; value: number }>`。
响应 `data``Array<{ label: string; value: number | string }>`。
## MinIO
## MinIO 接口
### GET `/minio/check`
检查 MinIO 连接和 bucket 状态。
Query`bucketName?: string`
Query
| 参数 | 类型 | 必填 | 说明 |
| ---------- | ------ | ---- | ----------- |
| bucketName | string | 否 | bucket 名称 |
响应 `data``{ bucketName: string; exists: boolean }`。
@ -231,7 +282,11 @@ Query`bucketName?: string`
创建 bucket已存在时跳过。
Query`bucketName?: string`
Query
| 参数 | 类型 | 必填 | 说明 |
| ---------- | ------ | ---- | ----------- |
| bucketName | string | 否 | bucket 名称 |
响应 `data`bucket 名称。
@ -268,7 +323,13 @@ Body
获取文件列表。
Query`bucketName?: string`、`prefix?: string`、`recursive?: string`
Query
| 参数 | 类型 | 必填 | 说明 |
| ---------- | ------ | ---- | ----------------------------- |
| bucketName | string | 否 | bucket 名称 |
| prefix | string | 否 | 对象名前缀 |
| recursive | string | 否 | 是否递归,传 `false` 时不递归 |
响应 `data`MinIO 对象数组,常见字段为 `name`、`size`、`etag`、`lastModified`。
@ -276,7 +337,13 @@ Query`bucketName?: string`、`prefix?: string`、`recursive?: string`
获取文件临时访问地址。
Query`objectName: string`、`bucketName?: string`、`expiry?: string`
Query
| 参数 | 类型 | 必填 | 说明 |
| ---------- | ------ | ---- | --------------------------- |
| objectName | string | 是 | 对象名 |
| bucketName | string | 否 | bucket 名称 |
| expiry | string | 否 | 有效期秒数,默认 `86400` 秒 |
响应 `data`:临时访问 URL。
@ -284,12 +351,22 @@ Query`objectName: string`、`bucketName?: string`、`expiry?: string`
下载文件,直接返回文件流。
Query`objectName: string`、`bucketName?: string`
Query
| 参数 | 类型 | 必填 | 说明 |
| ---------- | ------ | ---- | ----------- |
| objectName | string | 是 | 对象名 |
| bucketName | string | 否 | bucket 名称 |
### DELETE `/minio/remove`
删除文件。
Query`objectName: string`、`bucketName?: string`
Query
| 参数 | 类型 | 必填 | 说明 |
| ---------- | ------ | ---- | ----------- |
| objectName | string | 是 | 对象名 |
| bucketName | string | 否 | bucket 名称 |
响应 `data``true` 表示删除成功。

185
README.md
View File

@ -1,35 +1,176 @@
## 技术总览
# KT Template Online API
`Node`、`Ts`、`Nest.js`、`TypeORM`、`MySQL`、`Express`
`kt-template-online-api` 是 KT Template Online 的 Nest.js 后端服务,负责组件模板管理、数据库字典映射和 MinIO 文件能力。
## 项目简介
## 技术栈
此项目为`kt-template-online`服务端,基于`Node`、`Ts`开发
- Node.js + TypeScript
- Nest.js
- TypeORM + MySQL
- MinIO
- Swagger / Knife4j
使用服务端框架`Nest.js`构建项目以及`ORM`框架`TypeORM`快速生成`SQL`语句以及映射`SQL`库表字段关系
## 功能概览
## 运行项目
### Component
```bash
# 运行
$ pnpm start
管理图表/组件模板:
# 开发环境
$ pnpm start:dev
- 组件列表、分页列表、详情
- 新增、编辑、逻辑删除
- 查询阶段自动补充 `typeMsg`、`componentTypeMsg`
# 生产环境
$ pnpm start:prod
### Dict
维护数据库字典:
- 字典项存储在 `dict`
- `COMPONENT_TYPE.children_key` 关联二级字典,例如 `CHART`、`COMPONENT`
- 服务会将数据库字典刷新到进程缓存,供实体 `AfterLoad` 阶段同步翻译字段
### MinIO
提供文件服务:
- 检查/创建 bucket
- 上传文件
- 查询对象列表
- 获取临时访问地址
- 下载和删除对象
### Common
项目通用能力:
- 统一响应 Swagger 注解
- 字典翻译注解
- `POST */save` 请求体规范化拦截器
- 通用响应、分页和查询条件工具
## 目录结构
```text
src
common
decorators/ # 通用装饰器
interceptors/ # 全局/通用拦截器
services/ # 通用服务
swagger/ # Swagger 响应注解封装
index.ts # common 统一出口
component/ # 组件模板模块
dict/ # 数据库字典模块
minio/ # MinIO 文件模块
types/ # 全局类型声明
app.module.ts
main.ts
```
## 测试
## 环境变量
项目默认读取 `.env`,生产环境会读取 `.env.prod`
```env
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=your_password
DB_DATABASE=shy_template
DB_SYNC=true
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=kt-template-online
```
`DB_SYNC=true` 时 TypeORM 会按实体同步表结构。生产环境建议关闭同步,改用迁移脚本维护表结构。
## 数据库字典
`dict` 表是字典翻译的唯一数据源,代码中不再维护本地字典列表。
核心字段:
| 字段 | 说明 |
| -------------- | ----------------------------------------------------- |
| `dict_key` | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
| `label` | 展示文本 |
| `value` | 字典值 |
| `children_key` | 子字典分组,例如一级类型 `1` 指向 `CHART` |
| `sort` | 排序 |
| `is_deleted` | 逻辑删除标记 |
组件类型示例:
| dict_key | value | label | children_key |
| -------------- | ----- | ----- | ------------ |
| COMPONENT_TYPE | 1 | 图表 | CHART |
| COMPONENT_TYPE | 2 | 组件 | COMPONENT |
## 全局 Save 规则
项目注册了 `SaveBodyInterceptor`,会对 `POST */save` 请求统一删除 `body.id`,避免新增接口误用前端传入的主键。
如果某个接口需要保留 `id`,可以使用:
```ts
@SkipSaveBodyNormalize()
```
## 启动项目
安装依赖:
```bash
# unit tests
$ pnpm test
# e2e tests
$ pnpm test:e2e
# test coverage
$ pnpm test:cov
pnpm install
```
开发环境:
```bash
pnpm start:dev
```
普通启动:
```bash
pnpm start
```
生产启动:
```bash
pnpm start:prod
```
## 文档地址
服务默认监听 `48085`
- Swagger UI`http://localhost:48085/api`
- OpenAPI JSON`http://localhost:48085/api-json`
- Knife4j`nestjs-knife4j-plus` 根据 `/api-json` 提供增强文档
接口细节见 [API.md](./API.md)。
## 常用校验
类型检查:
```bash
pnpm exec tsc --noEmit
```
格式化:
```bash
pnpm exec prettier --write "src/**/*.ts" "test/**/*.ts"
```
测试:
```bash
pnpm test
pnpm test:e2e
```

View File

@ -1,13 +1,14 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MinioModule } from 'nestjs-minio-client';
import { ComponentModule } from './component/component.module';
import { DictModule } from './dict/dict.module';
import { MinioClientModule } from './minio/minio.module';
import { SaveMiddleware } from './middleware/save.middleware';
import { SaveBodyInterceptor } from './common';
@Module({
imports: [
@ -51,10 +52,13 @@ import { SaveMiddleware } from './middleware/save.middleware';
DictModule,
MinioClientModule,
],
providers: [AppService, ConfigService],
providers: [
AppService,
ConfigService,
{
provide: APP_INTERCEPTOR,
useClass: SaveBodyInterceptor,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(SaveMiddleware).forRoutes('*/save');
}
}
export class AppModule {}

View File

@ -0,0 +1,92 @@
type DecodeDictKeyOptions = {
fallback?: string;
sourceKey?: string;
targetKey?: string;
};
type DictDecodeRule = DecodeDictKeyOptions & {
targetKey: string;
dictKeys: string[];
};
const DICT_DECODE_RULES = Symbol('DICT_DECODE_RULES');
const DICT_DECODE_CACHE = new Map<string, Map<string, string>>();
const getDictValueKey = (value: unknown) => String(value);
// 字典翻译规则挂在类原型上,实例加载完成后再统一读取并执行。
const getDecodeRules = (target: object): DictDecodeRule[] => {
const prototype = Object.getPrototypeOf(target);
return prototype?.[DICT_DECODE_RULES] || [];
};
// 未指定 dictKey 时会在所有字典分组里查找,适合全局唯一的业务枚举值。
const getTargetDictMaps = (dictKeys: string[]) => {
if (dictKeys.length) {
return dictKeys
.map((dictKey) => DICT_DECODE_CACHE.get(dictKey))
.filter(Boolean);
}
return [...DICT_DECODE_CACHE.values()];
};
// 只登记翻译关系,不在 setter 中翻译,避免实体继承字段和 TypeORM 赋值顺序带来的覆盖问题。
export function DecodeDictKey(
dictKeys?: string | string[],
options: DecodeDictKeyOptions = {},
): PropertyDecorator {
return (target, key: string | symbol) => {
const currentKey = key.toString();
const sourceKey = options.sourceKey || currentKey;
const targetKey = options.targetKey || currentKey;
const sourceDictKeys = Array.isArray(dictKeys)
? dictKeys
: dictKeys
? [dictKeys]
: [];
const rules = target[DICT_DECODE_RULES] || [];
target[DICT_DECODE_RULES] = [
...rules,
{
...options,
sourceKey,
targetKey,
dictKeys: sourceDictKeys,
},
];
};
}
// 在 TypeORM AfterLoad 等实体初始化完成后调用,将源字段值翻译到 targetKey。
export function decodeDictKeys<T extends object>(target: T): T {
getDecodeRules(target).forEach(
({ sourceKey, targetKey, dictKeys, fallback }) => {
const valueKey = getDictValueKey(target[sourceKey]);
const label = getTargetDictMaps(dictKeys)
.map((dictMap) => dictMap.get(valueKey))
.find(Boolean);
target[targetKey] = label || fallback || '';
},
);
return target;
}
// DictService 从数据库刷新缓存后,实体 AfterLoad 可以同步完成字典映射。
export function setDictDecodeCache(
dicts: Array<Dict<{ dictKey: string }>>,
): void {
DICT_DECODE_CACHE.clear();
dicts.forEach(({ dictKey, value, label }) => {
if (!DICT_DECODE_CACHE.has(dictKey)) {
DICT_DECODE_CACHE.set(dictKey, new Map());
}
DICT_DECODE_CACHE.get(dictKey).set(getDictValueKey(value), label);
});
}

View File

@ -1,9 +1,4 @@
export function DecodeDictKey(dict: Dict[]): PropertyDecorator {
return (target, key: string | symbol) => {
Reflect.defineProperty(target, `_${key.toString()}`, {
set(newVal) {
this[key] = dict.find((i) => i.value == newVal).label;
},
});
};
}
export * from './decorators/decode-dict.decorator';
export * from './interceptors/save-body.interceptor';
export * from './services/tool.service';
export * from './swagger/swagger-response';

View File

@ -0,0 +1,46 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import type { Request } from 'express';
const SKIP_SAVE_BODY_NORMALIZE = 'SKIP_SAVE_BODY_NORMALIZE';
export const SkipSaveBodyNormalize = () =>
SetMetadata(SKIP_SAVE_BODY_NORMALIZE, true);
@Injectable()
export class SaveBodyInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
if (this.shouldSkip(context)) {
return next.handle();
}
const request = context.switchToHttp().getRequest<Request>();
if (this.isSaveRequest(request) && request.body) {
// 新增接口统一忽略前端传入的 id避免 TypeORM save 走指定主键写入。
delete request.body.id;
}
return next.handle();
}
private shouldSkip(context: ExecutionContext): boolean {
return this.reflector.getAllAndOverride<boolean>(SKIP_SAVE_BODY_NORMALIZE, [
context.getHandler(),
context.getClass(),
]);
}
private isSaveRequest(request: Request): boolean {
return request.method === 'POST' && request.path.endsWith('/save');
}
}

View File

@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import * as svgCaptcha from 'svg-captcha';
@Injectable()
export class ToolsService {
async captche(size = 4) {
const captcha = svgCaptcha.create({
size,
fontSize: 50,
width: 100,
height: 34,
background: '#ffffff',
});
return captcha;
}
res(code: number, msg: string, data: any): Res {
const retn: Res = {
code,
msg,
data,
};
return retn;
}
page<T = any>(list: T[], total: number): Page<T> {
const retn = {
list,
total,
};
return retn;
}
getWhereStr(alias: string, key) {
return `${alias}.${key.toString()} = :${key.toString()}`;
}
getLikeStr(alias: string, key) {
return `${alias}.${key.toString()} like :${key.toString()}`;
}
getLikeWhere<T = object>(
alias: string,
wheres: Array<keyof T>,
likes: Array<keyof T>,
values: Partial<T>,
operator: 'AND' | 'OR' = 'AND',
): [string, Record<string, unknown>] {
const hasValue = (value: unknown) =>
value !== undefined && value !== null && value !== '';
const conditions: string[] = [];
const params: Record<string, unknown> = {};
wheres.forEach((key) => {
const value = values[key];
if (!hasValue(value)) return;
const paramKey = key.toString();
conditions.push(this.getWhereStr(alias, key));
params[paramKey] = value;
});
likes.forEach((key) => {
const value = values[key];
if (!hasValue(value)) return;
const paramKey = key.toString();
conditions.push(this.getLikeStr(alias, key));
params[paramKey] = `%${value}%`;
});
return [conditions.join(` ${operator} `), params];
}
dictFormat<T = object>(
label: string,
value: any,
other: Partial<T>,
): Dict<T> {
const options = {
label,
value,
...other,
};
return options;
}
}

View File

@ -16,17 +16,16 @@ import {
ApiTags,
PartialType,
} from '@nestjs/swagger';
import { ToolsService } from '@/utils/tool.service';
import { ComponentService } from './component.service';
import { Component } from './component.entity';
import { ComponentDto } from './component.dto';
import {
PaginatedDto,
ApiArrayResponse,
ApiModelResponse,
ApiPageResponse,
ApiSuccessResponse,
} from '@/common/swagger-response';
ToolsService,
} from '@/common';
import { ComponentService } from './component.service';
import { Component } from './component.entity';
const componentExample = {
id: '1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9',
@ -65,11 +64,11 @@ export class ComponentController {
constructor(
private readonly toolsService: ToolsService,
private readonly componentService: ComponentService,
) {} //注入服务
) {}
@Get('allList')
@ApiOperation({ summary: '获取组件列表' })
@ApiArrayResponse(ComponentDto, [componentExample])
@ApiArrayResponse(Component, [componentExample])
async getAllList(@Res() res) {
const list = await this.componentService.all();
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list));
@ -78,11 +77,11 @@ export class ComponentController {
@Get('list')
@ApiOperation({ summary: '获取组件列表分页' })
@ApiQuery({ type: [CompPageDto] })
@ApiPageResponse(ComponentDto, [componentExample], 1)
@ApiPageResponse(Component, [componentExample], 1)
async getList(
@Res() res,
@Query() { pageNo, pageSize, ...args }: PageParams<ComponentDto>,
): Promise<PaginatedDto<ComponentDto>> {
@Query() { pageNo, pageSize, ...args }: PageParams<Component>,
): Promise<PaginatedDto<Component>> {
const list = await this.componentService.page({
pageNo,
pageSize,
@ -164,7 +163,7 @@ export class ComponentController {
@Get('detail')
@ApiOperation({ summary: '组件详情' })
@ApiQuery({ name: 'id', type: String })
@ApiModelResponse(ComponentDto, componentExample)
@ApiModelResponse(Component, componentExample)
async detail(@Res() res, @Query('id') id) {
const detail = await this.componentService.find(id);

View File

@ -1,23 +0,0 @@
import { DecodeDictKey } from '@/common';
import { Component } from './component.entity';
import { DictKeyEnum, DictKeyMap } from '@/utils/constant';
import { ApiProperty } from '@nestjs/swagger';
export class ComponentDto extends Component {
[x: string]: any;
@ApiProperty()
@DecodeDictKey(DictKeyMap.get(DictKeyEnum.COMPONENT_TYPE))
typeMsg: string;
@ApiProperty()
@DecodeDictKey([
...DictKeyMap.get(DictKeyEnum.CHART),
...DictKeyMap.get(DictKeyEnum.COMPONENT),
])
componentTypeMsg: string;
constructor(component: Component) {
super(component);
this._typeMsg = component.type;
this._componentTypeMsg = component.componentType;
}
}

View File

@ -1,4 +1,5 @@
import {
AfterLoad,
Entity,
PrimaryGeneratedColumn,
Column,
@ -6,15 +7,10 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ComponentTypeEnum, ComponentEnum } from '@/utils/constant';
import { DecodeDictKey, decodeDictKeys } from '@/common';
@Entity()
export class Component {
constructor(component?: Component) {
Object.assign(this, component);
}
@ApiPropertyOptional()
@PrimaryGeneratedColumn('uuid')
id: string;
@ -25,27 +21,32 @@ export class Component {
})
name: string;
@ApiProperty({
type: 'enum',
enum: ComponentTypeEnum,
})
@ApiProperty()
@Column({
type: 'enum',
enum: ComponentTypeEnum,
type: 'int',
})
@DecodeDictKey('COMPONENT_TYPE', {
targetKey: 'typeMsg',
})
type: number;
@ApiProperty({
type: 'enum',
enum: ComponentEnum,
})
@ApiProperty()
@Column({
name: 'component_type',
type: 'enum',
enum: ComponentEnum,
type: 'int',
})
// 二级类型值由数据库字典维护;未指定 dictKey 时会在全部字典缓存中匹配。
@DecodeDictKey(undefined, {
targetKey: 'componentTypeMsg',
})
componentType: number;
@ApiPropertyOptional()
typeMsg: string;
@ApiPropertyOptional()
componentTypeMsg: string;
@ApiProperty()
@Column({
type: 'mediumtext',
@ -74,4 +75,10 @@ export class Component {
default: 0,
})
is_deleted: boolean;
@AfterLoad()
decodeDictKeys() {
// 查询结果初始化完成后再翻译,避免构造/赋值阶段覆盖派生字段。
decodeDictKeys(this);
}
}

View File

@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ComponentController } from './component.controller';
import { ComponentService } from './component.service';
import { Component } from './component.entity';
import { ToolsService } from '@/utils/tool.service';
import { ToolsService } from '@/common';
import { DictModule } from '@/dict/dict.module';
@Module({
imports: [TypeOrmModule.forFeature([Component])],
imports: [TypeOrmModule.forFeature([Component]), DictModule],
controllers: [ComponentController],
providers: [ComponentService, ToolsService],
exports: [ComponentService],

View File

@ -2,9 +2,9 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Component } from './component.entity';
import { ToolsService } from '@/utils/tool.service';
import { ToolsService } from '@/common';
import { isNumber, omit, pick } from 'lodash';
import { ComponentDto } from './component.dto';
import { DictService } from '@/dict/dict.service';
@Injectable()
export class ComponentService {
@ -12,9 +12,12 @@ export class ComponentService {
@InjectRepository(Component)
private readonly userRepository: Repository<Component>,
private readonly toolsService: ToolsService,
private readonly dictService: DictService,
) {}
async all(): Promise<Component[]> {
await this.dictService.refreshDecodeCache();
const components = await this.userRepository
.createQueryBuilder('component')
.getMany();
@ -26,6 +29,8 @@ export class ComponentService {
pageSize,
...args
}: PageParams<Component>): Promise<Page<Component>> {
await this.dictService.refreshDecodeCache();
const hasOwnEntity = new Component();
const [wheres, likes] = [['is_deleted'], ['name']] as Array<
@ -69,10 +74,7 @@ export class ComponentService {
.take(pageSize)
.getManyAndCount();
return this.toolsService.page<Component>(
list.map((component) => new ComponentDto(component)),
total,
);
return this.toolsService.page<Component>(list, total);
}
async save(component: Component): Promise<Component> {
@ -104,6 +106,8 @@ export class ComponentService {
}
async find(id: number): Promise<Component> {
await this.dictService.refreshDecodeCache();
const component = await this.userRepository
.createQueryBuilder('component')
.select([

View File

@ -6,11 +6,9 @@ import {
Query,
Res,
} from '@nestjs/common';
import { ToolsService } from '@/utils/tool.service';
import { DictService } from './dict.service';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ComponentTypeEnum, DictKeyEnum, DictKeyType } from '@/utils/constant';
import { ApiArrayResponse } from '@/common/swagger-response';
import { ApiArrayResponse, ToolsService } from '@/common';
import { DictDto } from './dict.dto';
const componentTypeDictExample = [
@ -41,20 +39,20 @@ export class DictController {
constructor(
private readonly toolsService: ToolsService,
private readonly dictService: DictService,
) {} //注入服务
) {}
@ApiOperation({ summary: '根据key获取字典' })
@ApiQuery({ name: 'dictKey', enum: DictKeyEnum })
@ApiQuery({ name: 'dictKey', type: String })
@ApiArrayResponse(DictDto, componentTypeDictExample)
@Get('getDictByKey')
async getDictByKey(@Res() res, @Query('dictKey') dictKey: DictKeyType) {
const dict = this.toolsService.getDictByKey(dictKey);
async getDictByKey(@Res() res, @Query('dictKey') dictKey: string) {
const dict = await this.dictService.getDictByKey(dictKey);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', dict));
}
@ApiOperation({ summary: '根据组件类型获取组件字典' })
@ApiQuery({ name: 'type', enum: ComponentTypeEnum })
@ApiQuery({ name: 'type', type: Number })
@ApiArrayResponse(DictDto, chartDictExample)
@Get('getComponentDictByType')
async getComponentDictByType(@Res() res, @Query('type', ParseIntPipe) type) {

67
src/dict/dict.entity.ts Normal file
View File

@ -0,0 +1,67 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@Entity('dict')
export class DictEntity {
@ApiPropertyOptional()
@PrimaryGeneratedColumn('uuid')
id: string;
@ApiProperty({
example: 'CHART',
})
@Column({
name: 'dict_key',
})
dictKey: string;
@ApiProperty({
example: '折线图',
})
@Column()
label: string;
@ApiProperty({
example: 1,
})
@Column()
value: string;
@ApiPropertyOptional({
example: 'CHART',
})
@Column({
name: 'children_key',
nullable: true,
})
childrenKey: string;
@ApiPropertyOptional({
example: 1,
})
@Column({
default: 0,
})
sort: number;
@Column({
default: 0,
})
is_deleted: boolean;
@CreateDateColumn({
name: 'create_time',
})
createTime: Date;
@UpdateDateColumn({
name: 'update_time',
})
updateTime: Date;
}

View File

@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DictController } from './dict.controller';
import { DictService } from './dict.service';
import { ToolsService } from '@/utils/tool.service';
import { ToolsService } from '@/common';
import { DictEntity } from './dict.entity';
@Module({
imports: [TypeOrmModule.forFeature([DictEntity])],
controllers: [DictController],
providers: [DictService, ToolsService],
exports: [DictService],

View File

@ -1,21 +1,67 @@
import { Injectable } from '@nestjs/common';
import { ToolsService } from '@/utils/tool.service';
import { ComponentTypeEnum, DictKeyEnum } from '@/utils/constant';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { setDictDecodeCache } from '@/common';
import { DictEntity } from './dict.entity';
const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE';
@Injectable()
export class DictService {
constructor(private readonly toolsService: ToolsService) {}
export class DictService implements OnApplicationBootstrap {
constructor(
@InjectRepository(DictEntity)
private readonly dictRepository: Repository<DictEntity>,
) {}
async getComponentDictByType(type: ComponentTypeEnum): Promise<Dict[]> {
switch (type) {
case ComponentTypeEnum.CHART:
return this.toolsService.getDictByKey(DictKeyEnum.CHART);
async onApplicationBootstrap() {
await this.refreshDecodeCache();
}
case ComponentTypeEnum.COMPONENT:
return this.toolsService.getDictByKey(DictKeyEnum.COMPONENT);
async getDictByKey(dictKey: string): Promise<Dict[]> {
const list = await this.dictRepository.find({
where: {
dictKey,
is_deleted: false,
},
order: {
sort: 'ASC',
createTime: 'ASC',
},
});
default:
return this.toolsService.getDictByKey(DictKeyEnum.CHART);
}
return list.map(({ label, value }) => ({
label,
value: Number.isNaN(Number(value)) ? value : Number(value),
}));
}
async getComponentDictByType(type: number): Promise<Dict[]> {
// 一级类型的 childrenKey 决定二级字典来源,避免在代码里维护 1 -> CHART 这类关系。
const componentType = await this.dictRepository.findOne({
where: {
dictKey: COMPONENT_TYPE_DICT_KEY,
value: String(type),
is_deleted: false,
},
});
if (!componentType?.childrenKey) return [];
return this.getDictByKey(componentType.childrenKey);
}
async refreshDecodeCache() {
// AfterLoad 字典翻译必须同步完成,所以这里先把数据库字典刷新到进程缓存。
const list = await this.dictRepository.find({
where: {
is_deleted: false,
},
order: {
sort: 'ASC',
createTime: 'ASC',
},
});
setDictDecodeCache(list);
}
}

View File

@ -1,10 +0,0 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
@Injectable()
export class SaveMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: VoidFunction) {
Reflect.deleteProperty(req.body, 'id');
next();
}
}

View File

@ -19,7 +19,6 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { Response } from 'express';
import { ToolsService } from '@/utils/tool.service';
import { MinioClientService } from './minio.service';
import type { MinioUploadFile } from './minio.service';
import {
@ -27,7 +26,8 @@ import {
ApiArrayResponse,
ApiModelResponse,
ApiSuccessResponse,
} from '@/common/swagger-response';
ToolsService,
} from '@/common';
import {
MinioBucketStatusDto,
MinioObjectDto,
@ -40,7 +40,7 @@ export class MinioClientController {
constructor(
private readonly toolsService: ToolsService,
private readonly minioClientService: MinioClientService,
) {} //注入服务
) {}
@Get('check')
@ApiOperation({ summary: '检查MinIO连接和Bucket状态' })

View File

@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MinioClientController } from './minio.controller';
import { MinioClientService } from './minio.service';
import { ToolsService } from '@/utils/tool.service';
import { ToolsService } from '@/common';
@Module({
imports: [ConfigModule],

View File

@ -81,7 +81,8 @@ export class MinioClientService {
}
const targetBucket = await this.ensureBucket(bucketName);
const targetObjectName = objectName || this.createObjectName(file.originalname);
const targetObjectName =
objectName || this.createObjectName(file.originalname);
const result = await this.client.putObject(
targetBucket,
@ -161,7 +162,10 @@ export class MinioClientService {
);
}
async removeObject(objectName: string, bucketName?: string): Promise<boolean> {
async removeObject(
objectName: string,
bucketName?: string,
): Promise<boolean> {
if (!objectName) {
throw new BadRequestException('objectName不能为空');
}
@ -171,6 +175,7 @@ export class MinioClientService {
}
private createObjectName(originalName: string): string {
// 前端未指定对象名时,生成带时间和随机段的路径,降低同名文件覆盖概率。
const safeName = originalName.replace(/[\\/]/g, '_');
const random = Math.random().toString(36).slice(2, 8);

View File

@ -1,172 +0,0 @@
export enum ComponentTypeEnum {
CHART = 1,
COMPONENT = 2,
}
export enum ComponentEnum {
NOT_CATEGORY = -1,
LINE = 1,
BAR = 2,
PIE = 3,
SCATTER = 4,
MAP = 5,
CANDLESTICK = 6,
RADAR = 7,
BOX_PLOT = 8,
HEATMAP = 9,
GRAPH = 10,
LINES = 11,
TREE = 12,
TREE_MAP = 13,
SUNBURST = 14,
PARALLEL = 15,
SAN_KEY = 16,
FUNNEL = 17,
GAUGE = 18,
PICTORIAL_BAR = 19,
THEME_RIVER = 20,
LIQUID_FILL = 21,
WORD_CLOUD = 22,
TABLE = 23,
FORM = 24,
CONTAINER = 25,
}
export enum DictKeyEnum {
COMPONENT_TYPE = 'COMPONENT_TYPE',
CHART = 'CHART',
COMPONENT = 'COMPONENT',
}
export type DictKeyType = keyof typeof DictKeyEnum;
export const DictKeyMap: Map<DictKeyType, Dict[]> = new Map();
const ComponentTypeDict = [
{
label: '图表',
value: 1,
},
{
label: '组件',
value: 2,
},
];
const ChartDict = [
{
label: '未分类',
value: -1,
},
{
label: '折线图',
value: 1,
},
{
label: '柱状图',
value: 2,
},
{
label: '饼图',
value: 3,
},
{
label: '散点图',
value: 4,
},
{
label: '地图',
value: 5,
},
{
label: 'K线图',
value: 6,
},
{
label: '雷达图',
value: 7,
},
{
label: '盒须图',
value: 8,
},
{
label: '热力图',
value: 9,
},
{
label: '关系图',
value: 10,
},
{
label: '路径图',
value: 11,
},
{
label: '树图',
value: 12,
},
{
label: '矩树图',
value: 13,
},
{
label: '旭日图',
value: 14,
},
{
label: '平行坐标系',
value: 15,
},
{
label: '桑基图',
value: 16,
},
{
label: '漏斗图',
value: 17,
},
{
label: '仪表盘',
value: 18,
},
{
label: '象形图',
value: 19,
},
{
label: '河流图',
value: 20,
},
{
label: '水球',
value: 21,
},
{
label: '词云',
value: 22,
},
];
const ComponentDict = [
{
label: '未分类',
value: -1,
},
{
label: '表格',
value: 23,
},
{
label: '表单',
value: 24,
},
{
label: '容器',
value: 25,
},
];
DictKeyMap.set(DictKeyEnum.COMPONENT_TYPE, ComponentTypeDict);
DictKeyMap.set(DictKeyEnum.CHART, ChartDict);
DictKeyMap.set(DictKeyEnum.COMPONENT, ComponentDict);

View File

@ -1,105 +0,0 @@
import { Injectable } from '@nestjs/common';
import * as svgCaptcha from 'svg-captcha';
import { DictKeyMap, DictKeyEnum } from './constant';
import type { DictKeyType } from './constant';
import { isBoolean } from 'lodash';
@Injectable()
export class ToolsService {
async captche(size = 4) {
const captcha = svgCaptcha.create({
//可配置返回的图片信息
size, //生成几个验证码
fontSize: 50, //文字大小
width: 100, //宽度
height: 34, //高度
background: '#ffffff', //背景颜色
});
return captcha;
}
res(code: number, msg: string, data: any): Res {
const retn: Res = {
code,
msg,
data,
};
return retn;
}
page<T = any>(list: T[], total: number): Page<T> {
const retn = {
list,
total,
};
return retn;
}
getWhereStr(alias: string, key) {
return `${alias}.${key.toString()} = :${key.toString()}`;
}
getLikeStr(alias: string, key) {
return `${alias}.${key.toString()} like :${key.toString()}`;
}
getLikeWhere<T = object>(
alias: string,
wheres: Array<keyof T>,
likes: Array<keyof T>,
values: Partial<T>,
operator = 'AND',
): [string, Record<keyof T, string>] {
const linkOperator = ` ${operator} `;
const wheresEndIndex = wheres.length;
return [
[...wheres, ...likes].reduce((pre, cur, index, source) => {
const isLink = !!source
.slice(0, index)
.some((key) => isBoolean(values[key]) || !!values[key]);
const { getLikeStr, getWhereStr } = this;
if (!isBoolean(values[cur]) && !values[cur]) return pre;
if (!index) getWhereStr(alias, cur);
const matchSqlFn = index >= wheresEndIndex ? getLikeStr : getWhereStr;
const beforeSql = `${pre}${isLink ? linkOperator : ' '}`;
return `${beforeSql}${matchSqlFn(alias, cur)}`;
}, ''),
Object.entries(values).reduce((pre, [key, value]) => {
if (!isBoolean(value) && !value) return pre;
if (likes.includes(key as keyof T))
return { ...pre, ...{ [key]: `%${value}%` } };
return { ...pre, ...{ [key]: value } };
}, {} as Record<keyof T, string>),
];
}
getDictByKey(key: DictKeyType): Dict[] {
if (!DictKeyEnum[key]) {
return [];
}
return DictKeyMap.get(key);
}
dictFormat<T = object>(
label: string,
value: any,
other: Partial<T>,
): Dict<T> {
const options = {
label,
value,
...other,
};
return options;
}
}