mirror of
https://github.com/KwiTsukasa/kt-template-online-api.git
synced 2026-05-27 15:44:54 +08:00
feat(api): 完善后台管理能力
This commit is contained in:
parent
3364465267
commit
887ab392b6
@ -10,3 +10,8 @@ MINIO_PORT=9000
|
|||||||
MINIO_ACCESS_KEY=minioadmin
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=minioadmin
|
||||||
MINIO_BUCKET=kt-template-online
|
MINIO_BUCKET=kt-template-online
|
||||||
|
|
||||||
|
ADMIN_TOKEN_SECRET=change-me
|
||||||
|
ADMIN_COOKIE_SECURE=false
|
||||||
|
SNOWFLAKE_WORKER_ID=1
|
||||||
|
SNOWFLAKE_DATACENTER_ID=1
|
||||||
|
|||||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
@ -0,0 +1 @@
|
|||||||
|
node scripts/validate-commit-msg.mjs "$1"
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@ -0,0 +1 @@
|
|||||||
|
pnpm run verify:commit
|
||||||
97
API.md
97
API.md
@ -26,38 +26,54 @@
|
|||||||
|
|
||||||
| 模块 | 说明 |
|
| 模块 | 说明 |
|
||||||
| --------- | --------------------------------------------------------------------- |
|
| --------- | --------------------------------------------------------------------- |
|
||||||
| Component | 组件/图表模板的列表、详情、新增、编辑、逻辑删除 |
|
| Component | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` |
|
||||||
| Dict | 数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 |
|
| Dict | 基于新 `admin_dict` 表的数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 |
|
||||||
|
| Admin | Vben Admin 真实接口,包含认证、用户、菜单、角色、部门、时区和上传适配 |
|
||||||
| MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
| MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
||||||
| Common | 统一响应 Swagger 注解、字典翻译注解、`POST */save` 请求体规范化拦截器 |
|
| Common | 统一响应 Swagger 注解、字典翻译注解、`POST */save` 请求体规范化拦截器 |
|
||||||
|
|
||||||
## 通用规则
|
## 通用规则
|
||||||
|
|
||||||
|
### 数字 ID
|
||||||
|
|
||||||
|
后台主键统一使用 Snowflake 数字 ID。数据库字段使用 `BIGINT`;接口 JSON 中按字符串返回,例如 `"2041739550026043392"`,避免 JavaScript 直接用 `number` 承载 64 位长整型导致精度丢失。
|
||||||
|
|
||||||
|
如果旧版本曾经写入 `admin_user.id=0`,请先执行 `sql/fix-admin-user-zero-id.sql` 修复已有脏数据,再重启后端服务。
|
||||||
|
|
||||||
### Save 请求体规范化
|
### Save 请求体规范化
|
||||||
|
|
||||||
系统全局注册 `SaveBodyInterceptor`,默认会对 `POST */save` 请求删除 `body.id`,避免新增接口因为前端误传 `id` 而走指定主键保存。
|
系统全局注册 `SaveBodyInterceptor`,默认会对 `POST */save` 请求删除 `body.id`,避免新增接口因为前端误传 `id` 而走指定主键保存。
|
||||||
|
|
||||||
如果个别接口需要保留 `id`,可在对应 Controller 方法上使用 `@SkipSaveBodyNormalize()`。
|
如果个别接口需要保留 `id`,可在对应 Controller 方法上使用 `@SkipSaveBodyNormalize()`。
|
||||||
|
|
||||||
|
### 后台认证
|
||||||
|
|
||||||
|
Admin 与 Component 业务接口统一走 `JwtAuthGuard`。请求可以通过 `Authorization: Bearer <accessToken>` 传递 accessToken,也可以携带登录接口写入的 httpOnly `admin_access_token` cookie。未认证时接口返回 HTTP `401`。
|
||||||
|
|
||||||
|
`ADMIN_COOKIE_SECURE=false` 适用于当前内网 HTTP 访问;如果后续切到 HTTPS 域名,可以改为 `true`,cookie 会使用 `Secure + SameSite=None`。
|
||||||
|
|
||||||
|
`@Public()` 可用于保留不需要认证的接口口子,目前登录、刷新 token、退出登录和部分示例状态测试接口放行。
|
||||||
|
|
||||||
### 数据库字典翻译
|
### 数据库字典翻译
|
||||||
|
|
||||||
字典数据维护在数据库 `dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射。
|
组件数据维护在 `admin_component` 表中,字典数据维护在新的 `admin_dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射;旧 `/dict/*` 接口路径保持兼容。
|
||||||
|
|
||||||
`dict` 表核心字段:
|
`admin_dict` 表核心字段:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| ----------- | ------- | ------------------------------------------------------ |
|
| ----------- | ------- | ------------------------------------------------------ |
|
||||||
| id | string | 字典 ID |
|
| id | string | 字典数字 ID |
|
||||||
| dictKey | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
|
| dictCode | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
|
||||||
| label | string | 展示文本 |
|
| label | string | 展示文本 |
|
||||||
| value | string | 字典值 |
|
| value | string | 字典值 |
|
||||||
| childrenKey | string | 子字典分组,例如 `COMPONENT_TYPE.value=1` 指向 `CHART` |
|
| childrenCode | string | 子字典分组,例如 `COMPONENT_TYPE.value=1` 指向 `CHART` |
|
||||||
| sort | number | 排序 |
|
| sort | number | 排序 |
|
||||||
| is_deleted | boolean | 逻辑删除标记 |
|
| status | number | 启停状态,`1` 启用 |
|
||||||
|
| isDeleted | boolean | 逻辑删除标记 |
|
||||||
|
|
||||||
当前数据库示例关系:
|
当前数据库示例关系:
|
||||||
|
|
||||||
| dictKey | value | label | childrenKey |
|
| dictCode | value | label | childrenCode |
|
||||||
| -------------- | ----- | ----- | ----------- |
|
| -------------- | ----- | ----- | ----------- |
|
||||||
| COMPONENT_TYPE | 1 | 图表 | CHART |
|
| COMPONENT_TYPE | 1 | 图表 | CHART |
|
||||||
| COMPONENT_TYPE | 2 | 组件 | COMPONENT |
|
| COMPONENT_TYPE | 2 | 组件 | COMPONENT |
|
||||||
@ -68,7 +84,7 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| ---------------- | ------- | ---------------------------------- |
|
| ---------------- | ------- | ---------------------------------- |
|
||||||
| id | string | 组件 ID,新增时由后端生成 |
|
| id | string | 组件数字 ID,新增时由后端生成 |
|
||||||
| name | string | 组件名称 |
|
| name | string | 组件名称 |
|
||||||
| type | number | 一级类型,实际含义由 `dict` 表维护 |
|
| type | number | 一级类型,实际含义由 `dict` 表维护 |
|
||||||
| componentType | number | 二级类型,实际含义由 `dict` 表维护 |
|
| componentType | number | 二级类型,实际含义由 `dict` 表维护 |
|
||||||
@ -101,6 +117,8 @@
|
|||||||
|
|
||||||
## Component 接口
|
## Component 接口
|
||||||
|
|
||||||
|
组件接口仍保持 `/component/*` 路径兼容,但模块已迁入 Admin 目录并要求后台登录态。`kt-template-online-web` 和 `kt-template-online-playground` 收到 `401` 后会跳转到 `kt-template-admin` 登录页,登录完成再回到原页面。
|
||||||
|
|
||||||
### GET `/component/allList`
|
### GET `/component/allList`
|
||||||
|
|
||||||
获取全部组件。
|
获取全部组件。
|
||||||
@ -115,7 +133,7 @@
|
|||||||
"msg": "操作成功",
|
"msg": "操作成功",
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": "1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9",
|
"id": "2041739550026043392",
|
||||||
"name": "基础折线图",
|
"name": "基础折线图",
|
||||||
"type": 1,
|
"type": 1,
|
||||||
"componentType": 1,
|
"componentType": 1,
|
||||||
@ -188,7 +206,7 @@ Body:
|
|||||||
{
|
{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"msg": "操作成功",
|
"msg": "操作成功",
|
||||||
"data": "1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9"
|
"data": "2041739550026043392"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -200,7 +218,7 @@ Body:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9",
|
"id": "2041739550026043392",
|
||||||
"name": "基础折线图",
|
"name": "基础折线图",
|
||||||
"type": 1,
|
"type": 1,
|
||||||
"componentType": 1,
|
"componentType": 1,
|
||||||
@ -254,7 +272,7 @@ Query:
|
|||||||
|
|
||||||
根据组件一级类型获取对应的二级类型字典。
|
根据组件一级类型获取对应的二级类型字典。
|
||||||
|
|
||||||
查询逻辑:先查 `dictKey=COMPONENT_TYPE` 且 `value=type` 的字典项,再使用该项的 `childrenKey` 查询子字典。
|
查询逻辑:先查 `dictCode=COMPONENT_TYPE` 且 `value=type` 的字典项,再使用该项的 `childrenCode` 查询子字典。
|
||||||
|
|
||||||
Query:
|
Query:
|
||||||
|
|
||||||
@ -264,6 +282,57 @@ Query:
|
|||||||
|
|
||||||
响应 `data`:`Array<{ label: string; value: number | string }>`。
|
响应 `data`:`Array<{ label: string; value: number | string }>`。
|
||||||
|
|
||||||
|
## Vben Admin 真实接口
|
||||||
|
|
||||||
|
这些接口用于 `Vue/kt-template-admin`,响应格式与 Vben 请求拦截器对齐:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
核心接口:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/auth/login` | 登录,返回 `accessToken`,并写入 access token 与刷新 token cookie |
|
||||||
|
| POST | `/auth/refresh` | 通过刷新 token cookie 刷新 accessToken,并更新 token cookie |
|
||||||
|
| POST | `/auth/logout` | 退出登录并清理 access token 与刷新 token cookie |
|
||||||
|
| GET | `/auth/codes` | 获取当前用户权限码 |
|
||||||
|
| GET | `/user/info` | 获取当前用户信息 |
|
||||||
|
| GET | `/menu/all` | 获取当前用户可访问菜单 |
|
||||||
|
| GET | `/system/menu/list` | 获取系统菜单树 |
|
||||||
|
| GET | `/system/menu/name-exists` | 校验菜单 name 是否重复 |
|
||||||
|
| GET | `/system/menu/path-exists` | 校验菜单 path 是否重复 |
|
||||||
|
| POST | `/system/menu` | 新增菜单 |
|
||||||
|
| PUT | `/system/menu/:id` | 更新菜单 |
|
||||||
|
| DELETE | `/system/menu/:id` | 删除菜单及子菜单 |
|
||||||
|
| GET | `/system/role/list` | 分页查询角色 |
|
||||||
|
| POST | `/system/role` | 新增角色 |
|
||||||
|
| PUT | `/system/role/:id` | 更新角色 |
|
||||||
|
| DELETE | `/system/role/:id` | 删除角色 |
|
||||||
|
| GET | `/system/dept/list` | 获取部门树 |
|
||||||
|
| POST | `/system/dept` | 新增部门 |
|
||||||
|
| PUT | `/system/dept/:id` | 更新部门 |
|
||||||
|
| DELETE | `/system/dept/:id` | 删除部门 |
|
||||||
|
| GET | `/timezone/getTimezoneOptions` | 获取时区选项 |
|
||||||
|
| GET | `/timezone/getTimezone` | 获取当前用户时区 |
|
||||||
|
| POST | `/timezone/setTimezone` | 设置当前用户时区 |
|
||||||
|
| POST | `/upload` | Vben Upload 适配接口,真实上传到 MinIO 并返回 `{ url }` |
|
||||||
|
| GET | `/table/list` | Vben 示例远程表格数据 |
|
||||||
|
| GET | `/status` | Vben 状态码测试接口 |
|
||||||
|
| GET | `/demo/bigint` | Vben BigInt JSON 测试接口 |
|
||||||
|
|
||||||
|
初始化 SQL:
|
||||||
|
|
||||||
|
- `sql/vben-admin-init.sql`:创建 `admin_*` 表并导入基础用户、角色、菜单、部门、字典数据,同时创建空的 `admin_component` 表。
|
||||||
|
- `sql/migrate-dict-to-admin-dict.sql`:将旧 `dict` 表数据迁移到 `admin_dict`。
|
||||||
|
- `sql/migrate-component-to-admin-component.sql`:将旧 `component` 表数据迁移到 `admin_component`,并把旧表改名为备份表。
|
||||||
|
- `sql/fix-admin-menu-meta.sql`:修复基础后台菜单 `meta` 被旧数据或错误保存覆盖为空的问题。
|
||||||
|
|
||||||
## MinIO 接口
|
## MinIO 接口
|
||||||
|
|
||||||
### GET `/minio/check`
|
### GET `/minio/check`
|
||||||
|
|||||||
27
README.md
27
README.md
@ -15,8 +15,9 @@
|
|||||||
|
|
||||||
| 模块 | 说明 |
|
| 模块 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `component` | 组件/图表模板的列表、详情、新增、编辑、逻辑删除 |
|
| `component` | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` |
|
||||||
| `dict` | 数据库字典查询,维护组件一级类型和二级类型关系 |
|
| `dict` | 基于新 `admin_dict` 表的字典查询,维护组件一级类型和二级类型关系 |
|
||||||
|
| `admin` | Vben Admin 真实接口,包含登录、用户、菜单、角色、部门、时区、上传和示例表格 |
|
||||||
| `minio` | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
| `minio` | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
||||||
| `common` | 响应注解、字典翻译、`POST */save` 请求体规范化等通用能力 |
|
| `common` | 响应注解、字典翻译、`POST */save` 请求体规范化等通用能力 |
|
||||||
|
|
||||||
@ -25,8 +26,7 @@
|
|||||||
```text
|
```text
|
||||||
src
|
src
|
||||||
common/ # 通用装饰器、拦截器、服务、Swagger 封装
|
common/ # 通用装饰器、拦截器、服务、Swagger 封装
|
||||||
component/ # 组件模板模块
|
admin/ # Vben Admin 后台认证、组件、字典、菜单、角色、部门等接口
|
||||||
dict/ # 字典模块
|
|
||||||
minio/ # MinIO 文件模块
|
minio/ # MinIO 文件模块
|
||||||
types/ # 全局类型声明
|
types/ # 全局类型声明
|
||||||
app.module.ts # 全局模块、数据库、MinIO、拦截器注册
|
app.module.ts # 全局模块、数据库、MinIO、拦截器注册
|
||||||
@ -50,9 +50,15 @@ MINIO_PORT=9000
|
|||||||
MINIO_ACCESS_KEY=minioadmin
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=minioadmin
|
||||||
MINIO_BUCKET=kt-template-online
|
MINIO_BUCKET=kt-template-online
|
||||||
|
|
||||||
|
ADMIN_TOKEN_SECRET=change-me
|
||||||
|
ADMIN_COOKIE_SECURE=false
|
||||||
|
SNOWFLAKE_WORKER_ID=1
|
||||||
|
SNOWFLAKE_DATACENTER_ID=1
|
||||||
```
|
```
|
||||||
|
|
||||||
`DB_SYNC=true` 会让 TypeORM 根据实体同步表结构。生产环境建议关闭同步,改用迁移脚本维护表结构。
|
`DB_SYNC=true` 会让 TypeORM 根据实体同步表结构。生产环境建议关闭同步,改用迁移脚本维护表结构。
|
||||||
|
内网 HTTP 访问时保持 `ADMIN_COOKIE_SECURE=false`;如果未来切到 HTTPS 域名,再改为 `true`。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@ -93,7 +99,14 @@ pnpm test:e2e # e2e 测试
|
|||||||
|
|
||||||
## 核心规则
|
## 核心规则
|
||||||
|
|
||||||
- `dict` 表是字典翻译数据源,`Component.typeMsg` 和 `Component.componentTypeMsg` 查询后自动映射。
|
- `admin_component` 表保存组件/图表模板,`admin_dict` 表是统一字典翻译数据源,`Component.typeMsg` 和 `Component.componentTypeMsg` 查询后自动映射;旧 `/dict/*` 接口路径保持兼容。
|
||||||
|
- 业务主键统一由 Snowflake 生成数字 ID,数据库使用 `BIGINT`,接口按字符串返回以避免前端长整型精度问题。
|
||||||
|
- 如果基础后台菜单的 `meta` 被旧数据覆盖为空,执行 `sql/fix-admin-menu-meta.sql` 可以恢复初始化菜单的 `title/icon/order` 等元数据。
|
||||||
|
- 旧 `component` 表迁移到 `admin_component` 时,执行 `sql/migrate-component-to-admin-component.sql`,脚本会把旧表重命名为备份表。
|
||||||
|
- 如果旧版本曾写入 `admin_user.id=0`,先执行 `sql/fix-admin-user-zero-id.sql` 修复脏数据,再重启服务。
|
||||||
|
- Admin 与 Component 业务接口统一走 `JwtAuthGuard`;登录、刷新 token、退出登录和部分示例状态测试接口通过 `@Public()` 放行。
|
||||||
|
- `kt-template-admin` 登录会写入 access token 与刷新 token cookie,`kt-template-online-web` 和 `kt-template-online-playground` 可在回跳后通过刷新 token 重新持久化登录态。
|
||||||
|
- `kt-template-admin` 开发环境通过 `/api` 代理到本服务 `48085`,已关闭 Vben Nitro Mock。
|
||||||
- `POST /component/save` 新增组件,`POST /component/update` 编辑组件。
|
- `POST /component/save` 新增组件,`POST /component/update` 编辑组件。
|
||||||
- 全局 `SaveBodyInterceptor` 会删除 `POST */save` 请求体里的 `id`,避免新增接口误用前端主键。
|
- 全局 `SaveBodyInterceptor` 会删除 `POST */save` 请求体里的 `id`,避免新增接口误用前端主键。
|
||||||
- 如个别 `save` 接口必须保留 `id`,在 Controller 方法上使用 `@SkipSaveBodyNormalize()`。
|
- 如个别 `save` 接口必须保留 `id`,在 Controller 方法上使用 `@SkipSaveBodyNormalize()`。
|
||||||
@ -101,8 +114,8 @@ pnpm test:e2e # e2e 测试
|
|||||||
|
|
||||||
## 联调关系
|
## 联调关系
|
||||||
|
|
||||||
- `kt-template-online-web` 读取 `/component/list`、`/component/detail`、`/dict/*` 展示组件列表,并生成 Playground 跳转链接。
|
- `kt-template-online-web` 读取 `/component/list`、`/component/detail`、`/dict/*` 展示组件列表,并生成 Playground 跳转链接;组件接口返回 `401` 时跳转到 `kt-template-admin` 登录。
|
||||||
- `kt-template-online-playground` 读取 `/dict/*` 初始化分类,保存时上传截图到 `/minio/upload`,再调用 `/component/save` 或 `/component/update`。
|
- `kt-template-online-playground` 读取 `/dict/*` 初始化分类,保存时上传截图到 `/minio/upload`,再调用 `/component/save` 或 `/component/update`;组件接口返回 `401` 时跳转到 `kt-template-admin` 登录并在回跳后刷新 token。
|
||||||
- 前端项目通过 Vite 代理把 `/api` 转发到 `http://localhost:48085/`。
|
- 前端项目通过 Vite 代理把 `/api` 转发到 `http://localhost:48085/`。
|
||||||
|
|
||||||
## 轻量验证
|
## 轻量验证
|
||||||
|
|||||||
@ -9,12 +9,15 @@
|
|||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"prepare": "husky",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "cross-env NODE_ENV=production node dist/main",
|
"start:prod": "cross-env NODE_ENV=production node dist/main",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"verify:commit": "pnpm run lint && pnpm run typecheck",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
@ -55,6 +58,7 @@
|
|||||||
"eslint-config-prettier": "^8.10.2",
|
"eslint-config-prettier": "^8.10.2",
|
||||||
"eslint-plugin-prettier": "^4.2.5",
|
"eslint-plugin-prettier": "^4.2.5",
|
||||||
"eslint-plugin-typeorm": "0.0.19",
|
"eslint-plugin-typeorm": "0.0.19",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"jest": "29.3.1",
|
"jest": "29.3.1",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
|
|||||||
@ -99,6 +99,9 @@ importers:
|
|||||||
eslint-plugin-typeorm:
|
eslint-plugin-typeorm:
|
||||||
specifier: 0.0.19
|
specifier: 0.0.19
|
||||||
version: 0.0.19
|
version: 0.0.19
|
||||||
|
husky:
|
||||||
|
specifier: ^9.1.7
|
||||||
|
version: 9.1.7
|
||||||
jest:
|
jest:
|
||||||
specifier: 29.3.1
|
specifier: 29.3.1
|
||||||
version: 29.3.1(@types/node@18.11.18)(ts-node@10.9.2(@types/node@18.11.18)(typescript@4.9.5))
|
version: 29.3.1(@types/node@18.11.18)(ts-node@10.9.2(@types/node@18.11.18)(typescript@4.9.5))
|
||||||
@ -1913,6 +1916,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
|
|
||||||
|
husky@9.1.7:
|
||||||
|
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -5954,6 +5962,8 @@ snapshots:
|
|||||||
|
|
||||||
human-signals@2.1.0: {}
|
human-signals@2.1.0: {}
|
||||||
|
|
||||||
|
husky@9.1.7: {}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|||||||
24
scripts/validate-commit-msg.mjs
Normal file
24
scripts/validate-commit-msg.mjs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const messageFile = process.argv[2];
|
||||||
|
const firstLine = readFileSync(messageFile, 'utf8').split(/\r?\n/)[0].trim();
|
||||||
|
|
||||||
|
const releaseMessages = /^(?:Merge|Revert|fixup!|squash!)/;
|
||||||
|
const ktCommitPattern =
|
||||||
|
/^(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\([\w./-]+\))?: .+/;
|
||||||
|
const hasChineseText = /[\u4E00-\u9FFF]/;
|
||||||
|
|
||||||
|
if (releaseMessages.test(firstLine)) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ktCommitPattern.test(firstLine) || !hasChineseText.test(firstLine)) {
|
||||||
|
console.error(
|
||||||
|
[
|
||||||
|
'提交信息格式不正确。',
|
||||||
|
'要求:英文类型前缀 + 可选 scope + 冒号空格 + 中文描述。',
|
||||||
|
'示例:feat(api): 增加提交校验',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
51
sql/fix-admin-menu-meta.sql
Normal file
51
sql/fix-admin-menu-meta.sql
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
-- 修复基础后台菜单 meta 被写空的问题。
|
||||||
|
-- 只覆盖初始化 SQL 中维护的系统菜单,不影响自定义菜单。
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
UPDATE `admin_menu`
|
||||||
|
SET `meta` = CASE `name`
|
||||||
|
WHEN 'Dashboard' THEN '{"order":-1,"title":"page.dashboard.title"}'
|
||||||
|
WHEN 'Analytics' THEN '{"affixTab":true,"title":"page.dashboard.analytics"}'
|
||||||
|
WHEN 'Workspace' THEN '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}'
|
||||||
|
WHEN 'System' THEN '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}'
|
||||||
|
WHEN 'SystemRole' THEN '{"icon":"carbon:user-role","title":"system.role.title"}'
|
||||||
|
WHEN 'SystemRoleCreate' THEN '{"title":"common.create"}'
|
||||||
|
WHEN 'SystemRoleEdit' THEN '{"title":"common.edit"}'
|
||||||
|
WHEN 'SystemRoleDelete' THEN '{"title":"common.delete"}'
|
||||||
|
WHEN 'SystemMenu' THEN '{"icon":"carbon:menu","title":"system.menu.title"}'
|
||||||
|
WHEN 'SystemMenuCreate' THEN '{"title":"common.create"}'
|
||||||
|
WHEN 'SystemMenuEdit' THEN '{"title":"common.edit"}'
|
||||||
|
WHEN 'SystemMenuDelete' THEN '{"title":"common.delete"}'
|
||||||
|
WHEN 'SystemDept' THEN '{"icon":"carbon:container-services","title":"system.dept.title"}'
|
||||||
|
WHEN 'SystemDeptCreate' THEN '{"title":"common.create"}'
|
||||||
|
WHEN 'SystemDeptEdit' THEN '{"title":"common.edit"}'
|
||||||
|
WHEN 'SystemDeptDelete' THEN '{"title":"common.delete"}'
|
||||||
|
WHEN 'Project' THEN '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}'
|
||||||
|
WHEN 'VbenDocument' THEN '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}'
|
||||||
|
WHEN 'VbenGithub' THEN '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}'
|
||||||
|
WHEN 'About' THEN '{"icon":"lucide:copyright","order":9999,"title":"demos.vben.about"}'
|
||||||
|
ELSE `meta`
|
||||||
|
END
|
||||||
|
WHERE `name` IN (
|
||||||
|
'Dashboard',
|
||||||
|
'Analytics',
|
||||||
|
'Workspace',
|
||||||
|
'System',
|
||||||
|
'SystemRole',
|
||||||
|
'SystemRoleCreate',
|
||||||
|
'SystemRoleEdit',
|
||||||
|
'SystemRoleDelete',
|
||||||
|
'SystemMenu',
|
||||||
|
'SystemMenuCreate',
|
||||||
|
'SystemMenuEdit',
|
||||||
|
'SystemMenuDelete',
|
||||||
|
'SystemDept',
|
||||||
|
'SystemDeptCreate',
|
||||||
|
'SystemDeptEdit',
|
||||||
|
'SystemDeptDelete',
|
||||||
|
'Project',
|
||||||
|
'VbenDocument',
|
||||||
|
'VbenGithub',
|
||||||
|
'About'
|
||||||
|
);
|
||||||
17
sql/fix-admin-user-zero-id.sql
Normal file
17
sql/fix-admin-user-zero-id.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- 修复旧 Snowflake 主键逻辑异常时写入的 admin_user.id = 0 数据。
|
||||||
|
-- 执行后再重启后端;新代码会在插入前自动补齐合法数字 ID。
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
SET @new_admin_user_id := 2041700000000099999;
|
||||||
|
|
||||||
|
UPDATE `admin_user_role`
|
||||||
|
SET `user_id` = @new_admin_user_id
|
||||||
|
WHERE `user_id` = 0;
|
||||||
|
|
||||||
|
UPDATE `admin_user`
|
||||||
|
SET `id` = @new_admin_user_id
|
||||||
|
WHERE `id` = 0;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
67
sql/migrate-component-to-admin-component.sql
Normal file
67
sql/migrate-component-to-admin-component.sql
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
-- 将旧 component 表迁移为 admin_component。
|
||||||
|
-- 旧表若存在会先复制数据,再重命名为 component_bak_before_admin_prefix_yyyyMMddHHmmss 备份表。
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_component` (
|
||||||
|
`id` bigint NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`type` int NOT NULL,
|
||||||
|
`component_type` int NOT NULL,
|
||||||
|
`image` mediumtext NOT NULL,
|
||||||
|
`template` mediumtext NOT NULL,
|
||||||
|
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_admin_component_type` (`type`),
|
||||||
|
KEY `idx_admin_component_component_type` (`component_type`),
|
||||||
|
KEY `idx_admin_component_deleted` (`is_deleted`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
SET @component_old_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'component'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @component_row := 0;
|
||||||
|
SET @component_copy_sql = IF(
|
||||||
|
@component_old_exists = 1,
|
||||||
|
'INSERT IGNORE INTO `admin_component` (`id`, `name`, `type`, `component_type`, `image`, `template`, `is_deleted`, `create_time`, `update_time`)
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN CAST(`id` AS CHAR) REGEXP ''^[0-9]+$'' AND CAST(`id` AS UNSIGNED) > 0 THEN CAST(`id` AS UNSIGNED)
|
||||||
|
ELSE 2041700000000500000 + (@component_row := @component_row + 1)
|
||||||
|
END,
|
||||||
|
`name`,
|
||||||
|
`type`,
|
||||||
|
`component_type`,
|
||||||
|
`image`,
|
||||||
|
`template`,
|
||||||
|
`is_deleted`,
|
||||||
|
`create_time`,
|
||||||
|
`update_time`
|
||||||
|
FROM `component`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE component_copy_stmt FROM @component_copy_sql;
|
||||||
|
EXECUTE component_copy_stmt;
|
||||||
|
DEALLOCATE PREPARE component_copy_stmt;
|
||||||
|
|
||||||
|
SET @component_backup_table = CONCAT(
|
||||||
|
'component_bak_before_admin_prefix_',
|
||||||
|
DATE_FORMAT(NOW(), '%Y%m%d%H%i%s')
|
||||||
|
);
|
||||||
|
SET @component_rename_sql = IF(
|
||||||
|
@component_old_exists = 1,
|
||||||
|
CONCAT('RENAME TABLE `component` TO `', @component_backup_table, '`'),
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE component_rename_stmt FROM @component_rename_sql;
|
||||||
|
EXECUTE component_rename_stmt;
|
||||||
|
DEALLOCATE PREPARE component_rename_stmt;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
41
sql/migrate-dict-to-admin-dict.sql
Normal file
41
sql/migrate-dict-to-admin-dict.sql
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
-- 旧 dict 表到新 admin_dict 表的数据迁移脚本。
|
||||||
|
-- 使用方式:目标库里仍存在旧 `dict` 表时,在执行 `vben-admin-init.sql` 创建新表后执行本脚本。
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET @dict_snowflake_base := 2041700000000400000;
|
||||||
|
SET @dict_snowflake_row := 0;
|
||||||
|
|
||||||
|
INSERT INTO `admin_dict` (
|
||||||
|
`id`,
|
||||||
|
`dict_code`,
|
||||||
|
`label`,
|
||||||
|
`value`,
|
||||||
|
`children_code`,
|
||||||
|
`sort`,
|
||||||
|
`status`,
|
||||||
|
`is_deleted`,
|
||||||
|
`create_time`,
|
||||||
|
`update_time`
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN CAST(`id` AS CHAR) REGEXP '^[0-9]+$' THEN CAST(`id` AS UNSIGNED)
|
||||||
|
ELSE @dict_snowflake_base + (@dict_snowflake_row := @dict_snowflake_row + 1)
|
||||||
|
END,
|
||||||
|
`dict_key`,
|
||||||
|
`label`,
|
||||||
|
`value`,
|
||||||
|
`children_key`,
|
||||||
|
COALESCE(`sort`, 0),
|
||||||
|
IF(COALESCE(`is_deleted`, 0) = 0, 1, 0),
|
||||||
|
COALESCE(`is_deleted`, 0),
|
||||||
|
COALESCE(`create_time`, CURRENT_TIMESTAMP(6)),
|
||||||
|
COALESCE(`update_time`, CURRENT_TIMESTAMP(6))
|
||||||
|
FROM `dict`
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`label` = VALUES(`label`),
|
||||||
|
`children_code` = VALUES(`children_code`),
|
||||||
|
`sort` = VALUES(`sort`),
|
||||||
|
`status` = VALUES(`status`),
|
||||||
|
`is_deleted` = VALUES(`is_deleted`),
|
||||||
|
`update_time` = VALUES(`update_time`);
|
||||||
250
sql/vben-admin-init.sql
Normal file
250
sql/vben-admin-init.sql
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
-- Vben Admin 后台初始化 SQL
|
||||||
|
-- 用途:为 kt-template-admin 提供用户、角色、菜单、部门、字典表结构和基础数据。
|
||||||
|
-- 说明:应用启动不会自动写入这些数据;请按目标环境手动导入本文件。
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_menu` (
|
||||||
|
`id` bigint NOT NULL,
|
||||||
|
`pid` bigint NOT NULL DEFAULT 0,
|
||||||
|
`name` varchar(120) NOT NULL,
|
||||||
|
`path` varchar(255) DEFAULT NULL,
|
||||||
|
`component` varchar(255) DEFAULT NULL,
|
||||||
|
`redirect` varchar(255) DEFAULT NULL,
|
||||||
|
`auth_code` varchar(120) DEFAULT NULL,
|
||||||
|
`type` varchar(32) NOT NULL DEFAULT 'menu',
|
||||||
|
`meta` longtext DEFAULT NULL,
|
||||||
|
`status` int NOT NULL DEFAULT 1,
|
||||||
|
`sort` int NOT NULL DEFAULT 0,
|
||||||
|
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_admin_menu_name` (`name`),
|
||||||
|
KEY `idx_admin_menu_pid` (`pid`),
|
||||||
|
KEY `idx_admin_menu_path` (`path`),
|
||||||
|
KEY `idx_admin_menu_auth_code` (`auth_code`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_role` (
|
||||||
|
`id` bigint NOT NULL,
|
||||||
|
`role_code` varchar(255) NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`remark` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`status` int NOT NULL DEFAULT 1,
|
||||||
|
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_admin_role_code` (`role_code`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_user` (
|
||||||
|
`id` bigint NOT NULL,
|
||||||
|
`username` varchar(255) NOT NULL,
|
||||||
|
`password` varchar(255) NOT NULL,
|
||||||
|
`real_name` varchar(255) NOT NULL,
|
||||||
|
`home_path` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`timezone` varchar(255) NOT NULL DEFAULT 'Asia/Shanghai',
|
||||||
|
`status` int NOT NULL DEFAULT 1,
|
||||||
|
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_admin_user_username` (`username`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_dept` (
|
||||||
|
`id` bigint NOT NULL,
|
||||||
|
`pid` bigint NOT NULL DEFAULT 0,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`status` int NOT NULL DEFAULT 1,
|
||||||
|
`remark` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_admin_dept_pid` (`pid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_dict` (
|
||||||
|
`id` bigint NOT NULL,
|
||||||
|
`dict_code` varchar(255) NOT NULL,
|
||||||
|
`label` varchar(255) NOT NULL,
|
||||||
|
`value` varchar(255) NOT NULL,
|
||||||
|
`children_code` varchar(255) DEFAULT NULL,
|
||||||
|
`sort` int NOT NULL DEFAULT 0,
|
||||||
|
`status` int NOT NULL DEFAULT 1,
|
||||||
|
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_admin_dict_code_value` (`dict_code`, `value`),
|
||||||
|
KEY `idx_admin_dict_code` (`dict_code`),
|
||||||
|
KEY `idx_admin_dict_children_code` (`children_code`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_component` (
|
||||||
|
`id` bigint NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`type` int NOT NULL,
|
||||||
|
`component_type` int NOT NULL,
|
||||||
|
`image` mediumtext NOT NULL,
|
||||||
|
`template` mediumtext NOT NULL,
|
||||||
|
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_admin_component_type` (`type`),
|
||||||
|
KEY `idx_admin_component_component_type` (`component_type`),
|
||||||
|
KEY `idx_admin_component_deleted` (`is_deleted`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_user_role` (
|
||||||
|
`user_id` bigint NOT NULL,
|
||||||
|
`role_id` bigint NOT NULL,
|
||||||
|
PRIMARY KEY (`user_id`, `role_id`),
|
||||||
|
KEY `idx_admin_user_role_role_id` (`role_id`),
|
||||||
|
CONSTRAINT `fk_admin_user_role_user` FOREIGN KEY (`user_id`) REFERENCES `admin_user` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_admin_user_role_role` FOREIGN KEY (`role_id`) REFERENCES `admin_role` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_role_menu` (
|
||||||
|
`role_id` bigint NOT NULL,
|
||||||
|
`menu_id` bigint NOT NULL,
|
||||||
|
PRIMARY KEY (`role_id`, `menu_id`),
|
||||||
|
KEY `idx_admin_role_menu_menu_id` (`menu_id`),
|
||||||
|
CONSTRAINT `fk_admin_role_menu_role` FOREIGN KEY (`role_id`) REFERENCES `admin_role` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_admin_role_menu_menu` FOREIGN KEY (`menu_id`) REFERENCES `admin_menu` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`)
|
||||||
|
VALUES
|
||||||
|
(2041700000000100001, 0, 'Dashboard', '/dashboard', NULL, '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', 1, 0),
|
||||||
|
(2041700000000100101, 2041700000000100001, 'Analytics', '/analytics', '/dashboard/analytics/index', NULL, NULL, 'menu', '{"affixTab":true,"title":"page.dashboard.analytics"}', 1, 0),
|
||||||
|
(2041700000000100102, 2041700000000100001, 'Workspace', '/workspace', '/dashboard/workspace/index', NULL, NULL, 'menu', '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}', 1, 0),
|
||||||
|
(2041700000000100002, 0, 'System', '/system', NULL, NULL, NULL, 'catalog', '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}', 1, 9997),
|
||||||
|
(2041700000000100200, 2041700000000100002, 'SystemRole', '/system/role', '/system/role/list', NULL, 'System:Role:List', 'menu', '{"icon":"carbon:user-role","title":"system.role.title"}', 1, 0),
|
||||||
|
(2041700000000120001, 2041700000000100200, 'SystemRoleCreate', NULL, NULL, NULL, 'System:Role:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||||
|
(2041700000000120002, 2041700000000100200, 'SystemRoleEdit', NULL, NULL, NULL, 'System:Role:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||||
|
(2041700000000120003, 2041700000000100200, 'SystemRoleDelete', NULL, NULL, NULL, 'System:Role:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||||
|
(2041700000000100201, 2041700000000100002, 'SystemMenu', '/system/menu', '/system/menu/list', NULL, 'System:Menu:List', 'menu', '{"icon":"carbon:menu","title":"system.menu.title"}', 1, 0),
|
||||||
|
(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, 0),
|
||||||
|
(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),
|
||||||
|
(2041700000000100009, 0, 'Project', '/vben-admin', NULL, NULL, NULL, 'catalog', '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}', 1, 9998),
|
||||||
|
(2041700000000100901, 2041700000000100009, 'VbenDocument', '/vben-admin/document', 'IFrameView', NULL, NULL, 'embedded', '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}', 1, 0),
|
||||||
|
(2041700000000100902, 2041700000000100009, 'VbenGithub', '/vben-admin/github', 'IFrameView', NULL, NULL, 'link', '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}', 1, 0),
|
||||||
|
(2041700000000100010, 0, 'About', '/about', '_core/about/index', NULL, NULL, 'menu', '{"icon":"lucide:copyright","order":9999,"title":"demos.vben.about"}', 1, 9999)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`pid` = VALUES(`pid`),
|
||||||
|
`path` = VALUES(`path`),
|
||||||
|
`component` = VALUES(`component`),
|
||||||
|
`redirect` = VALUES(`redirect`),
|
||||||
|
`auth_code` = VALUES(`auth_code`),
|
||||||
|
`type` = VALUES(`type`),
|
||||||
|
`meta` = VALUES(`meta`),
|
||||||
|
`status` = VALUES(`status`),
|
||||||
|
`sort` = VALUES(`sort`),
|
||||||
|
`is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT INTO `admin_role` (`id`, `role_code`, `name`, `remark`, `status`)
|
||||||
|
VALUES
|
||||||
|
(2041700000000010001, 'super', '超级管理员', '拥有所有后台权限', 1),
|
||||||
|
(2041700000000010002, 'admin', '管理员', '拥有系统管理与工作台权限', 1),
|
||||||
|
(2041700000000010003, 'user', '普通用户', '仅拥有基础查看权限', 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`name` = VALUES(`name`),
|
||||||
|
`remark` = VALUES(`remark`),
|
||||||
|
`status` = VALUES(`status`),
|
||||||
|
`is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `home_path`, `timezone`, `status`)
|
||||||
|
VALUES
|
||||||
|
(2041700000000000001, 'vben', '123456', 'Vben', '/workspace', 'Asia/Shanghai', 1),
|
||||||
|
(2041700000000000002, 'admin', '123456', 'Admin', '/workspace', 'Asia/Shanghai', 1),
|
||||||
|
(2041700000000000003, 'jack', '123456', 'Jack', '/analytics', 'Asia/Shanghai', 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`password` = VALUES(`password`),
|
||||||
|
`real_name` = VALUES(`real_name`),
|
||||||
|
`home_path` = VALUES(`home_path`),
|
||||||
|
`timezone` = VALUES(`timezone`),
|
||||||
|
`status` = VALUES(`status`),
|
||||||
|
`is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT INTO `admin_dept` (`id`, `pid`, `name`, `status`, `remark`)
|
||||||
|
VALUES
|
||||||
|
(2041700000000200001, 0, 'KT 总部', 1, '根部门'),
|
||||||
|
(2041700000000200002, 2041700000000200001, '研发中心', 1, '产品研发与平台建设'),
|
||||||
|
(2041700000000200003, 2041700000000200001, '运营中心', 1, '模板运营与内容管理')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`pid` = VALUES(`pid`),
|
||||||
|
`name` = VALUES(`name`),
|
||||||
|
`status` = VALUES(`status`),
|
||||||
|
`remark` = VALUES(`remark`),
|
||||||
|
`is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`)
|
||||||
|
VALUES
|
||||||
|
(2041700000000300001, 'COMPONENT_TYPE', '图表', '1', 'CHART', 1, 1),
|
||||||
|
(2041700000000300002, 'COMPONENT_TYPE', '组件', '2', 'COMPONENT', 2, 1),
|
||||||
|
(2041700000000300101, 'CHART', '未分类', '-1', NULL, 0, 1),
|
||||||
|
(2041700000000300102, 'CHART', '折线图', '1', NULL, 1, 1),
|
||||||
|
(2041700000000300103, 'CHART', '柱状图', '2', NULL, 2, 1),
|
||||||
|
(2041700000000300104, 'CHART', '饼图', '3', NULL, 3, 1),
|
||||||
|
(2041700000000300201, 'COMPONENT', '未分类', '-1', NULL, 0, 1),
|
||||||
|
(2041700000000300202, 'COMPONENT', '基础组件', '1', NULL, 1, 1),
|
||||||
|
(2041700000000300203, 'COMPONENT', '业务组件', '2', NULL, 2, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`label` = VALUES(`label`),
|
||||||
|
`children_code` = VALUES(`children_code`),
|
||||||
|
`sort` = VALUES(`sort`),
|
||||||
|
`status` = VALUES(`status`),
|
||||||
|
`is_deleted` = 0;
|
||||||
|
|
||||||
|
DELETE FROM `admin_user_role`
|
||||||
|
WHERE `user_id` IN (
|
||||||
|
2041700000000000001,
|
||||||
|
2041700000000000002,
|
||||||
|
2041700000000000003
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `admin_user_role` (`user_id`, `role_id`)
|
||||||
|
VALUES
|
||||||
|
(2041700000000000001, 2041700000000010001),
|
||||||
|
(2041700000000000002, 2041700000000010002),
|
||||||
|
(2041700000000000003, 2041700000000010003);
|
||||||
|
|
||||||
|
DELETE FROM `admin_role_menu`
|
||||||
|
WHERE `role_id` IN (
|
||||||
|
2041700000000010001,
|
||||||
|
2041700000000010002,
|
||||||
|
2041700000000010003
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `admin_role_menu` (`role_id`, `menu_id`)
|
||||||
|
SELECT 2041700000000010001, `id`
|
||||||
|
FROM `admin_menu`
|
||||||
|
WHERE `is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT INTO `admin_role_menu` (`role_id`, `menu_id`)
|
||||||
|
SELECT 2041700000000010002, `id`
|
||||||
|
FROM `admin_menu`
|
||||||
|
WHERE `is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT INTO `admin_role_menu` (`role_id`, `menu_id`)
|
||||||
|
VALUES
|
||||||
|
(2041700000000010003, 2041700000000100001),
|
||||||
|
(2041700000000010003, 2041700000000100101),
|
||||||
|
(2041700000000010003, 2041700000000100102),
|
||||||
|
(2041700000000010003, 2041700000000100009),
|
||||||
|
(2041700000000010003, 2041700000000100901),
|
||||||
|
(2041700000000010003, 2041700000000100902),
|
||||||
|
(2041700000000010003, 2041700000000100010);
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
64
src/admin/admin.module.ts
Normal file
64
src/admin/admin.module.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AdminAuthController } from './auth/admin-auth.controller';
|
||||||
|
import { AdminAuthService } from './auth/admin-auth.service';
|
||||||
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
|
import { AdminTokenService } from './auth/admin-token.service';
|
||||||
|
import { ComponentController } from './component/component.controller';
|
||||||
|
import { Component } from './component/component.entity';
|
||||||
|
import { ComponentService } from './component/component.service';
|
||||||
|
import { AdminDeptController } from './dept/admin-dept.controller';
|
||||||
|
import { AdminDept } from './dept/admin-dept.entity';
|
||||||
|
import { AdminDeptService } from './dept/admin-dept.service';
|
||||||
|
import { DictModule } from './dict/dict.module';
|
||||||
|
import { AdminExampleController } from './example/admin-example.controller';
|
||||||
|
import { AdminMenuController } from './menu/admin-menu.controller';
|
||||||
|
import { AdminMenu } from './menu/admin-menu.entity';
|
||||||
|
import { AdminMenuService } from './menu/admin-menu.service';
|
||||||
|
import { AdminRoleController } from './role/admin-role.controller';
|
||||||
|
import { AdminRole } from './role/admin-role.entity';
|
||||||
|
import { AdminRoleService } from './role/admin-role.service';
|
||||||
|
import { AdminTimezoneController } from './timezone/admin-timezone.controller';
|
||||||
|
import { AdminTimezoneService } from './timezone/admin-timezone.service';
|
||||||
|
import { AdminUserController } from './user/admin-user.controller';
|
||||||
|
import { AdminUser } from './user/admin-user.entity';
|
||||||
|
import { AdminUserService } from './user/admin-user.service';
|
||||||
|
import { ToolsService } from '@/common';
|
||||||
|
import { MinioClientModule } from '@/minio/minio.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
AdminUser,
|
||||||
|
AdminRole,
|
||||||
|
AdminMenu,
|
||||||
|
AdminDept,
|
||||||
|
Component,
|
||||||
|
]),
|
||||||
|
DictModule,
|
||||||
|
MinioClientModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
AdminAuthController,
|
||||||
|
AdminUserController,
|
||||||
|
AdminMenuController,
|
||||||
|
AdminRoleController,
|
||||||
|
AdminDeptController,
|
||||||
|
ComponentController,
|
||||||
|
AdminTimezoneController,
|
||||||
|
AdminExampleController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AdminAuthService,
|
||||||
|
ComponentService,
|
||||||
|
AdminDeptService,
|
||||||
|
AdminMenuService,
|
||||||
|
AdminRoleService,
|
||||||
|
AdminTimezoneService,
|
||||||
|
AdminTokenService,
|
||||||
|
AdminUserService,
|
||||||
|
JwtAuthGuard,
|
||||||
|
ToolsService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
72
src/admin/auth/admin-auth.controller.ts
Normal file
72
src/admin/auth/admin-auth.controller.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { CurrentAdminUser, Public, vbenSuccess } from '@/common';
|
||||||
|
import { AdminMenuService } from '../menu/admin-menu.service';
|
||||||
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
|
import { AdminUserService } from '../user/admin-user.service';
|
||||||
|
import { AdminAuthService } from './admin-auth.service';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('admin-auth')
|
||||||
|
@Controller()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AdminAuthController {
|
||||||
|
constructor(
|
||||||
|
private readonly authService: AdminAuthService,
|
||||||
|
private readonly menuService: AdminMenuService,
|
||||||
|
private readonly userService: AdminUserService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('auth/login')
|
||||||
|
@Public()
|
||||||
|
async login(
|
||||||
|
@Body() body: { password?: string; username?: string },
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const { accessToken, refreshToken, user } = await this.authService.login(
|
||||||
|
body.username,
|
||||||
|
body.password,
|
||||||
|
);
|
||||||
|
this.authService.setAccessTokenCookie(res, accessToken);
|
||||||
|
this.authService.setRefreshTokenCookie(res, refreshToken);
|
||||||
|
return vbenSuccess({
|
||||||
|
...this.userService.serializeUser(user),
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('auth/refresh')
|
||||||
|
@Public()
|
||||||
|
async refresh(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const refreshToken = this.authService.getRefreshTokenFromRequest(req);
|
||||||
|
const refreshed = await this.authService.refresh(refreshToken);
|
||||||
|
this.authService.setAccessTokenCookie(res, refreshed.accessToken);
|
||||||
|
this.authService.setRefreshTokenCookie(res, refreshed.refreshToken);
|
||||||
|
return refreshed.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('auth/logout')
|
||||||
|
@Public()
|
||||||
|
logout(@Res({ passthrough: true }) res: Response) {
|
||||||
|
this.authService.clearAccessTokenCookie(res);
|
||||||
|
this.authService.clearRefreshTokenCookie(res);
|
||||||
|
return vbenSuccess('');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('auth/codes')
|
||||||
|
async getAccessCodes(@CurrentAdminUser() user: AdminUser) {
|
||||||
|
return vbenSuccess(await this.menuService.getAccessCodes(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/admin/auth/admin-auth.service.ts
Normal file
156
src/admin/auth/admin-auth.service.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { throwVbenError } from '@/common';
|
||||||
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
|
import { AdminTokenService } from './admin-token.service';
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_COOKIE = 'admin_access_token';
|
||||||
|
const REFRESH_TOKEN_COOKIE = 'jwt';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminAuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AdminUser)
|
||||||
|
private readonly userRepository: Repository<AdminUser>,
|
||||||
|
private readonly tokenService: AdminTokenService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(username?: string, password?: string) {
|
||||||
|
if (!username || !password) {
|
||||||
|
throwVbenError(
|
||||||
|
'Username and password are required',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
'BadRequestException',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.findUserByUsername(username);
|
||||||
|
if (!user || user.password !== password) {
|
||||||
|
throwVbenError(
|
||||||
|
'Username or password is incorrect.',
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: this.tokenService.signAccessToken(user),
|
||||||
|
refreshToken: this.tokenService.signRefreshToken(user),
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(refreshToken?: string) {
|
||||||
|
if (!refreshToken) {
|
||||||
|
throwVbenError('Forbidden Exception', HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = this.tokenService.verifyRefreshToken(refreshToken);
|
||||||
|
if (!payload) throwVbenError('Forbidden Exception', HttpStatus.FORBIDDEN);
|
||||||
|
|
||||||
|
const user = await this.findUserByUsername(payload.username);
|
||||||
|
if (!user) throwVbenError('Forbidden Exception', HttpStatus.FORBIDDEN);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: this.tokenService.signAccessToken(user),
|
||||||
|
refreshToken: this.tokenService.signRefreshToken(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async currentUser(authHeader?: string, req?: Request) {
|
||||||
|
const tokens = [
|
||||||
|
this.readBearerToken(authHeader),
|
||||||
|
this.readCookie(req, ACCESS_TOKEN_COOKIE),
|
||||||
|
].filter((token): token is string => !!token);
|
||||||
|
const payload = tokens
|
||||||
|
.map((token) => this.tokenService.verifyAccessToken(token))
|
||||||
|
.find(Boolean);
|
||||||
|
if (!payload) {
|
||||||
|
throwVbenError('Unauthorized Exception', HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
relations: ['roles', 'roles.menus'],
|
||||||
|
where: {
|
||||||
|
id: payload.sub,
|
||||||
|
isDeleted: false,
|
||||||
|
status: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) throwVbenError('Unauthorized Exception', HttpStatus.UNAUTHORIZED);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRefreshTokenFromRequest(req: Request) {
|
||||||
|
return this.readCookie(req, REFRESH_TOKEN_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccessTokenCookie(res: Response, token: string) {
|
||||||
|
res.cookie(ACCESS_TOKEN_COOKIE, token, {
|
||||||
|
...this.getTokenCookieOptions(),
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRefreshTokenCookie(res: Response, token: string) {
|
||||||
|
res.cookie(REFRESH_TOKEN_COOKIE, token, {
|
||||||
|
...this.getTokenCookieOptions(),
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRefreshTokenCookie(res: Response) {
|
||||||
|
this.clearTokenCookie(res, REFRESH_TOKEN_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAccessTokenCookie(res: Response) {
|
||||||
|
this.clearTokenCookie(res, ACCESS_TOKEN_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findUserByUsername(username: string) {
|
||||||
|
return this.userRepository.findOne({
|
||||||
|
relations: ['roles', 'roles.menus'],
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
status: 1,
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readBearerToken(authHeader?: string) {
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) return null;
|
||||||
|
return authHeader.split(' ')[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private readCookie(req: Request | undefined, cookieName: string) {
|
||||||
|
const cookie = req?.headers.cookie || '';
|
||||||
|
const cookies = cookie
|
||||||
|
.split(';')
|
||||||
|
.reduce<Record<string, string>>((acc, item) => {
|
||||||
|
const [key, ...value] = item.trim().split('=');
|
||||||
|
if (key) acc[key] = decodeURIComponent(value.join('='));
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
return cookies[cookieName];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTokenCookieOptions() {
|
||||||
|
const secure = process.env.ADMIN_COOKIE_SECURE === 'true';
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
path: '/',
|
||||||
|
sameSite: secure ? ('none' as const) : ('lax' as const),
|
||||||
|
secure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTokenCookie(res: Response, cookieName: string) {
|
||||||
|
const options = this.getTokenCookieOptions();
|
||||||
|
res.clearCookie(cookieName, options);
|
||||||
|
// 兼容旧版本未显式指定 path 时由浏览器按接口路径生成的 cookie。
|
||||||
|
res.clearCookie(cookieName, { ...options, path: '/api/auth' });
|
||||||
|
res.clearCookie(cookieName, { ...options, path: '/auth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/admin/auth/admin-token.service.ts
Normal file
90
src/admin/auth/admin-token.service.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
|
export type AdminTokenPayload = {
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
sub: string;
|
||||||
|
type: 'access' | 'refresh';
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminTokenService {
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
signAccessToken(user: { id: string; username: string }) {
|
||||||
|
return this.sign(user, 'access', 7 * 24 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
signRefreshToken(user: { id: string; username: string }) {
|
||||||
|
return this.sign(user, 'refresh', 30 * 24 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyAccessToken(token: string): AdminTokenPayload | null {
|
||||||
|
return this.verify(token, 'access');
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyRefreshToken(token: string): AdminTokenPayload | null {
|
||||||
|
return this.verify(token, 'refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
private sign(
|
||||||
|
user: { id: string; username: string },
|
||||||
|
type: AdminTokenPayload['type'],
|
||||||
|
ttlSeconds: number,
|
||||||
|
) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload: AdminTokenPayload = {
|
||||||
|
exp: now + ttlSeconds,
|
||||||
|
iat: now,
|
||||||
|
sub: user.id,
|
||||||
|
type,
|
||||||
|
username: user.username,
|
||||||
|
};
|
||||||
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
|
||||||
|
'base64url',
|
||||||
|
);
|
||||||
|
const signature = this.signPayload(encodedPayload);
|
||||||
|
return `${encodedPayload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private verify(
|
||||||
|
token: string,
|
||||||
|
type: AdminTokenPayload['type'],
|
||||||
|
): AdminTokenPayload | null {
|
||||||
|
const [encodedPayload, signature] = token.split('.');
|
||||||
|
if (!encodedPayload || !signature) return null;
|
||||||
|
|
||||||
|
const expected = this.signPayload(encodedPayload);
|
||||||
|
if (!this.safeEqual(signature, expected)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(encodedPayload, 'base64url').toString('utf8'),
|
||||||
|
) as AdminTokenPayload;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.type !== type || payload.exp <= now) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private signPayload(payload: string) {
|
||||||
|
const secret =
|
||||||
|
this.configService.get<string>('ADMIN_TOKEN_SECRET') ||
|
||||||
|
'kt-template-online-admin-token-secret';
|
||||||
|
return createHmac('sha256', secret).update(payload).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeEqual(left: string, right: string) {
|
||||||
|
const leftBuffer = Buffer.from(left);
|
||||||
|
const rightBuffer = Buffer.from(right);
|
||||||
|
return (
|
||||||
|
leftBuffer.length === rightBuffer.length &&
|
||||||
|
timingSafeEqual(leftBuffer, rightBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/admin/auth/jwt-auth.guard.ts
Normal file
38
src/admin/auth/jwt-auth.guard.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
|
import { AdminAuthService } from './admin-auth.service';
|
||||||
|
import { IS_PUBLIC_KEY } from '@/common';
|
||||||
|
|
||||||
|
type AdminRequest = Request & {
|
||||||
|
adminUser?: AdminUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly authService: AdminAuthService,
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext) {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AdminRequest>();
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
request.adminUser = await this.authService.currentUser(
|
||||||
|
Array.isArray(authorization) ? authorization[0] : authorization,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
|
||||||
Res,
|
|
||||||
Query,
|
|
||||||
Body,
|
|
||||||
HttpStatus,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiExtraModels,
|
ApiExtraModels,
|
||||||
@ -24,11 +25,12 @@ import {
|
|||||||
ApiSuccessResponse,
|
ApiSuccessResponse,
|
||||||
ToolsService,
|
ToolsService,
|
||||||
} from '@/common';
|
} from '@/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { ComponentService } from './component.service';
|
import { ComponentService } from './component.service';
|
||||||
import { Component } from './component.entity';
|
import { Component } from './component.entity';
|
||||||
|
|
||||||
const componentExample = {
|
const componentExample = {
|
||||||
id: '1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9',
|
id: '2041739550026043392',
|
||||||
name: '基础折线图',
|
name: '基础折线图',
|
||||||
type: 1,
|
type: 1,
|
||||||
componentType: 1,
|
componentType: 1,
|
||||||
@ -60,6 +62,7 @@ class CompPageDto
|
|||||||
@Controller('component')
|
@Controller('component')
|
||||||
@ApiTags('component')
|
@ApiTags('component')
|
||||||
@ApiExtraModels(PaginatedDto)
|
@ApiExtraModels(PaginatedDto)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class ComponentController {
|
export class ComponentController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
@ -99,7 +102,7 @@ export class ComponentController {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: '新增组件ID',
|
description: '新增组件ID',
|
||||||
},
|
},
|
||||||
example: '1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9',
|
example: '2041739550026043392',
|
||||||
})
|
})
|
||||||
async save(@Res() res, @Body() component: Component) {
|
async save(@Res() res, @Body() component: Component) {
|
||||||
const save = await this.componentService.save(component);
|
const save = await this.componentService.save(component);
|
||||||
@ -1,18 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
|
BeforeInsert,
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryColumn,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { DecodeDictKey, decodeDictKeys } from '@/common';
|
import { DecodeDictKey, decodeDictKeys, ensureSnowflakeId } from '@/common';
|
||||||
|
|
||||||
@Entity()
|
@Entity('admin_component')
|
||||||
export class Component {
|
export class Component {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryColumn({
|
||||||
|
type: 'bigint',
|
||||||
|
})
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@ -81,4 +84,9 @@ export class Component {
|
|||||||
// 查询结果初始化完成后再翻译,避免构造/赋值阶段覆盖派生字段。
|
// 查询结果初始化完成后再翻译,避免构造/赋值阶段覆盖派生字段。
|
||||||
decodeDictKeys(this);
|
decodeDictKeys(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
createId() {
|
||||||
|
ensureSnowflakeId(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -4,7 +4,7 @@ import { Repository } from 'typeorm';
|
|||||||
import { Component } from './component.entity';
|
import { Component } from './component.entity';
|
||||||
import { ToolsService } from '@/common';
|
import { ToolsService } from '@/common';
|
||||||
import { isNumber, omit, pick } from 'lodash';
|
import { isNumber, omit, pick } from 'lodash';
|
||||||
import { DictService } from '@/dict/dict.service';
|
import { DictService } from '@/admin/dict/dict.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ComponentService {
|
export class ComponentService {
|
||||||
@ -105,7 +105,7 @@ export class ComponentService {
|
|||||||
return link.affected > 0;
|
return link.affected > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async find(id: number): Promise<Component> {
|
async find(id: string): Promise<Component> {
|
||||||
await this.dictService.refreshDecodeCache();
|
await this.dictService.refreshDecodeCache();
|
||||||
|
|
||||||
const component = await this.userRepository
|
const component = await this.userRepository
|
||||||
45
src/admin/dept/admin-dept.controller.ts
Normal file
45
src/admin/dept/admin-dept.controller.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { vbenSuccess } from '@/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { AdminDept } from './admin-dept.entity';
|
||||||
|
import { AdminDeptService } from './admin-dept.service';
|
||||||
|
|
||||||
|
@ApiTags('admin-dept')
|
||||||
|
@Controller('system/dept')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AdminDeptController {
|
||||||
|
constructor(private readonly deptService: AdminDeptService) {}
|
||||||
|
|
||||||
|
@Get('list')
|
||||||
|
async list() {
|
||||||
|
return vbenSuccess(await this.deptService.getDeptList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Body() body: Partial<AdminDept>) {
|
||||||
|
return vbenSuccess(await this.deptService.createDept(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Partial<AdminDept>,
|
||||||
|
) {
|
||||||
|
return vbenSuccess(await this.deptService.updateDept(id, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(@Param('id') id: string) {
|
||||||
|
return vbenSuccess(await this.deptService.deleteDept(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/admin/dept/admin-dept.entity.ts
Normal file
57
src/admin/dept/admin-dept.entity.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
BeforeInsert,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ensureSnowflakeId } from '@/common';
|
||||||
|
|
||||||
|
@Entity('admin_dept')
|
||||||
|
export class AdminDept {
|
||||||
|
@PrimaryColumn({
|
||||||
|
type: 'bigint',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: 0,
|
||||||
|
type: 'bigint',
|
||||||
|
})
|
||||||
|
pid: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: '',
|
||||||
|
})
|
||||||
|
remark: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: false,
|
||||||
|
name: 'is_deleted',
|
||||||
|
})
|
||||||
|
isDeleted: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({
|
||||||
|
name: 'create_time',
|
||||||
|
})
|
||||||
|
createTime: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'update_time',
|
||||||
|
})
|
||||||
|
updateTime: Date;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
createId() {
|
||||||
|
ensureSnowflakeId(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/admin/dept/admin-dept.service.ts
Normal file
81
src/admin/dept/admin-dept.service.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { throwVbenError, toTree } from '@/common';
|
||||||
|
import { AdminDept } from './admin-dept.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminDeptService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AdminDept)
|
||||||
|
private readonly deptRepository: Repository<AdminDept>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getDeptList() {
|
||||||
|
const depts = await this.deptRepository.find({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
createTime: 'ASC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.buildDeptTree(depts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDept(data: Partial<AdminDept>) {
|
||||||
|
const entity = this.deptRepository.create({
|
||||||
|
name: data.name,
|
||||||
|
pid: data.pid || '0',
|
||||||
|
remark: data.remark || '',
|
||||||
|
status: data.status ?? 1,
|
||||||
|
});
|
||||||
|
await this.deptRepository.save(entity);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDept(id: string, data: Partial<AdminDept>) {
|
||||||
|
await this.deptRepository.update(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
name: data.name,
|
||||||
|
pid: data.pid || '0',
|
||||||
|
remark: data.remark || '',
|
||||||
|
status: data.status ?? 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDept(id: string) {
|
||||||
|
const hasChildren = await this.deptRepository.exist({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
pid: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (hasChildren) {
|
||||||
|
throwVbenError('请先删除子部门', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.deptRepository.update(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
isDeleted: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDeptTree(depts: AdminDept[]) {
|
||||||
|
const nodes = depts.map((dept) => ({
|
||||||
|
createTime: dept.createTime,
|
||||||
|
id: dept.id,
|
||||||
|
name: dept.name,
|
||||||
|
pid: dept.pid || '0',
|
||||||
|
remark: dept.remark,
|
||||||
|
status: dept.status,
|
||||||
|
}));
|
||||||
|
return toTree(nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,28 +1,32 @@
|
|||||||
import {
|
import {
|
||||||
|
BeforeInsert,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { ensureSnowflakeId } from '@/common';
|
||||||
|
|
||||||
@Entity('dict')
|
@Entity('admin_dict')
|
||||||
export class DictEntity {
|
export class AdminDict {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryColumn({
|
||||||
|
type: 'bigint',
|
||||||
|
})
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'CHART',
|
example: 'COMPONENT_TYPE',
|
||||||
})
|
})
|
||||||
@Column({
|
@Column({
|
||||||
name: 'dict_key',
|
name: 'dict_code',
|
||||||
})
|
})
|
||||||
dictKey: string;
|
dictCode: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '折线图',
|
example: '图表',
|
||||||
})
|
})
|
||||||
@Column()
|
@Column()
|
||||||
label: string;
|
label: string;
|
||||||
@ -37,10 +41,10 @@ export class DictEntity {
|
|||||||
example: 'CHART',
|
example: 'CHART',
|
||||||
})
|
})
|
||||||
@Column({
|
@Column({
|
||||||
name: 'children_key',
|
name: 'children_code',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
childrenKey: string;
|
childrenCode: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 1,
|
example: 1,
|
||||||
@ -51,9 +55,15 @@ export class DictEntity {
|
|||||||
sort: number;
|
sort: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
default: 0,
|
default: 1,
|
||||||
})
|
})
|
||||||
is_deleted: boolean;
|
status: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: false,
|
||||||
|
name: 'is_deleted',
|
||||||
|
})
|
||||||
|
isDeleted: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({
|
@CreateDateColumn({
|
||||||
name: 'create_time',
|
name: 'create_time',
|
||||||
@ -64,4 +74,9 @@ export class DictEntity {
|
|||||||
name: 'update_time',
|
name: 'update_time',
|
||||||
})
|
})
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
createId() {
|
||||||
|
ensureSnowflakeId(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,12 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class DictDto {
|
export class DictDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'COMPONENT_TYPE',
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
dictCode?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '图表',
|
example: '图表',
|
||||||
})
|
})
|
||||||
@ -3,10 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { DictController } from './dict.controller';
|
import { DictController } from './dict.controller';
|
||||||
import { DictService } from './dict.service';
|
import { DictService } from './dict.service';
|
||||||
import { ToolsService } from '@/common';
|
import { ToolsService } from '@/common';
|
||||||
import { DictEntity } from './dict.entity';
|
import { AdminDict } from './admin-dict.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([DictEntity])],
|
imports: [TypeOrmModule.forFeature([AdminDict])],
|
||||||
controllers: [DictController],
|
controllers: [DictController],
|
||||||
providers: [DictService, ToolsService],
|
providers: [DictService, ToolsService],
|
||||||
exports: [DictService],
|
exports: [DictService],
|
||||||
@ -2,15 +2,15 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { setDictDecodeCache } from '@/common';
|
import { setDictDecodeCache } from '@/common';
|
||||||
import { DictEntity } from './dict.entity';
|
import { AdminDict } from './admin-dict.entity';
|
||||||
|
|
||||||
const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE';
|
const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DictService implements OnApplicationBootstrap {
|
export class DictService implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(DictEntity)
|
@InjectRepository(AdminDict)
|
||||||
private readonly dictRepository: Repository<DictEntity>,
|
private readonly dictRepository: Repository<AdminDict>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
@ -20,8 +20,9 @@ export class DictService implements OnApplicationBootstrap {
|
|||||||
async getDictByKey(dictKey: string): Promise<Dict[]> {
|
async getDictByKey(dictKey: string): Promise<Dict[]> {
|
||||||
const list = await this.dictRepository.find({
|
const list = await this.dictRepository.find({
|
||||||
where: {
|
where: {
|
||||||
dictKey,
|
dictCode: dictKey,
|
||||||
is_deleted: false,
|
isDeleted: false,
|
||||||
|
status: 1,
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
@ -36,25 +37,27 @@ export class DictService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getComponentDictByType(type: number): Promise<Dict[]> {
|
async getComponentDictByType(type: number): Promise<Dict[]> {
|
||||||
// 一级类型的 childrenKey 决定二级字典来源,避免在代码里维护 1 -> CHART 这类关系。
|
// 一级类型的 childrenCode 决定二级字典来源,避免在代码里维护 1 -> CHART 这类关系。
|
||||||
const componentType = await this.dictRepository.findOne({
|
const componentType = await this.dictRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
dictKey: COMPONENT_TYPE_DICT_KEY,
|
dictCode: COMPONENT_TYPE_DICT_KEY,
|
||||||
|
isDeleted: false,
|
||||||
|
status: 1,
|
||||||
value: String(type),
|
value: String(type),
|
||||||
is_deleted: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!componentType?.childrenKey) return [];
|
if (!componentType?.childrenCode) return [];
|
||||||
|
|
||||||
return this.getDictByKey(componentType.childrenKey);
|
return this.getDictByKey(componentType.childrenCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshDecodeCache() {
|
async refreshDecodeCache() {
|
||||||
// AfterLoad 字典翻译必须同步完成,所以这里先把数据库字典刷新到进程缓存。
|
// AfterLoad 字典翻译必须同步完成,所以这里先把数据库字典刷新到进程缓存。
|
||||||
const list = await this.dictRepository.find({
|
const list = await this.dictRepository.find({
|
||||||
where: {
|
where: {
|
||||||
is_deleted: false,
|
isDeleted: false,
|
||||||
|
status: 1,
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
@ -62,6 +65,12 @@ export class DictService implements OnApplicationBootstrap {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setDictDecodeCache(list);
|
setDictDecodeCache(
|
||||||
|
list.map(({ dictCode, label, value }) => ({
|
||||||
|
dictKey: dictCode,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
158
src/admin/example/admin-example.controller.ts
Normal file
158
src/admin/example/admin-example.controller.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { Public, vbenPage, vbenSuccess } from '@/common';
|
||||||
|
import { MinioClientService } from '@/minio/minio.service';
|
||||||
|
import type { MinioUploadFile } from '@/minio/minio.service';
|
||||||
|
|
||||||
|
type DemoTableRow = {
|
||||||
|
available: boolean;
|
||||||
|
category: string;
|
||||||
|
color: string;
|
||||||
|
currency: string;
|
||||||
|
description: string;
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
imageUrl2: string;
|
||||||
|
inProduction: boolean;
|
||||||
|
open: boolean;
|
||||||
|
price: string;
|
||||||
|
productName: string;
|
||||||
|
quantity: number;
|
||||||
|
rating: number;
|
||||||
|
releaseDate: string;
|
||||||
|
status: string;
|
||||||
|
tags: string[];
|
||||||
|
weight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEMO_ROWS: DemoTableRow[] = Array.from({ length: 100 }, (_, index) => {
|
||||||
|
const sequence = index + 1;
|
||||||
|
const categories = ['Dashboard', 'Form', 'Table', 'Chart', 'Workflow'];
|
||||||
|
const colors = ['Blue', 'Green', 'Purple', 'Orange', 'Slate'];
|
||||||
|
const statuses = ['success', 'warning', 'error'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: sequence % 3 !== 0,
|
||||||
|
category: categories[index % categories.length],
|
||||||
|
color: colors[index % colors.length],
|
||||||
|
currency: 'CNY',
|
||||||
|
description: `真实 API 示例数据 ${sequence}`,
|
||||||
|
id: `demo-${String(sequence).padStart(3, '0')}`,
|
||||||
|
imageUrl: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
|
||||||
|
imageUrl2:
|
||||||
|
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
|
||||||
|
inProduction: sequence % 2 === 0,
|
||||||
|
open: sequence % 4 === 0,
|
||||||
|
price: `${(sequence * 3.6 + 19).toFixed(2)}`,
|
||||||
|
productName: `KT Admin 模板能力 ${sequence}`,
|
||||||
|
quantity: 10 + sequence,
|
||||||
|
rating: Number((3 + (sequence % 20) / 10).toFixed(1)),
|
||||||
|
releaseDate: new Date(2026, index % 12, (index % 28) + 1).toISOString(),
|
||||||
|
status: statuses[index % statuses.length],
|
||||||
|
tags: ['kt', 'admin', categories[index % categories.length].toLowerCase()],
|
||||||
|
weight: Number((1 + sequence / 10).toFixed(2)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
@ApiTags('admin-example')
|
||||||
|
@Controller()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AdminExampleController {
|
||||||
|
constructor(private readonly minioClientService: MinioClientService) {}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async upload(@UploadedFile() file: MinioUploadFile) {
|
||||||
|
const result = await this.minioClientService.uploadObject({ file });
|
||||||
|
|
||||||
|
return vbenSuccess({
|
||||||
|
url: result.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('table/list')
|
||||||
|
async tableList(
|
||||||
|
@Query() query: Record<string, any>,
|
||||||
|
) {
|
||||||
|
const page = Math.max(Number(query.page || 1), 1);
|
||||||
|
const pageSize = Math.max(Number(query.pageSize || 10), 1);
|
||||||
|
const sorted = this.sortRows([...DEMO_ROWS], query.sortBy, query.sortOrder);
|
||||||
|
const items = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
|
return vbenPage(items, sorted.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@Public()
|
||||||
|
status(@Query('status') status: string, @Res() res: Response) {
|
||||||
|
const code = Number(status) || 200;
|
||||||
|
res.status(code).send({
|
||||||
|
code: -1,
|
||||||
|
data: null,
|
||||||
|
error: `${code}`,
|
||||||
|
message: `${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('demo/bigint')
|
||||||
|
async bigint(@Res() res: Response) {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.send(`{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 123456789012345678901234567890123456789012345678901234567890,
|
||||||
|
"name": "John Doe",
|
||||||
|
"age": 30,
|
||||||
|
"email": "john-doe@demo.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 987654321098765432109876543210987654321098765432109876543210,
|
||||||
|
"name": "Jane Smith",
|
||||||
|
"age": 25,
|
||||||
|
"email": "jane@demo.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('test')
|
||||||
|
@Public()
|
||||||
|
testGet() {
|
||||||
|
return 'Test get handler';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('test')
|
||||||
|
@Public()
|
||||||
|
testPost() {
|
||||||
|
return 'Test post handler';
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortRows(rows: DemoTableRow[], sortBy?: string, sortOrder?: string) {
|
||||||
|
if (!sortBy || !Object.hasOwn(rows[0], sortBy)) return rows;
|
||||||
|
|
||||||
|
return rows.sort((prev, next) => {
|
||||||
|
const prevValue = prev[sortBy];
|
||||||
|
const nextValue = next[sortBy];
|
||||||
|
const result = String(prevValue).localeCompare(String(nextValue), 'zh-CN', {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortOrder === 'desc' ? -result : result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/admin/menu/admin-menu.controller.ts
Normal file
68
src/admin/menu/admin-menu.controller.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentAdminUser, vbenSuccess } from '@/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
|
import { AdminMenu } from './admin-menu.entity';
|
||||||
|
import { AdminMenuService } from './admin-menu.service';
|
||||||
|
|
||||||
|
@ApiTags('admin-menu')
|
||||||
|
@Controller()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AdminMenuController {
|
||||||
|
constructor(private readonly menuService: AdminMenuService) {}
|
||||||
|
|
||||||
|
@Get('menu/all')
|
||||||
|
async all(@CurrentAdminUser() user: AdminUser) {
|
||||||
|
return vbenSuccess(await this.menuService.getRouteMenus(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('system/menu/list')
|
||||||
|
async list() {
|
||||||
|
return vbenSuccess(await this.menuService.getMenuList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('system/menu/name-exists')
|
||||||
|
async nameExists(
|
||||||
|
@Query('name') name: string,
|
||||||
|
@Query('id') id?: string,
|
||||||
|
) {
|
||||||
|
return vbenSuccess(await this.menuService.isMenuNameExists(name, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('system/menu/path-exists')
|
||||||
|
async pathExists(
|
||||||
|
@Query('path') path: string,
|
||||||
|
@Query('id') id?: string,
|
||||||
|
) {
|
||||||
|
return vbenSuccess(await this.menuService.isMenuPathExists(path, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('system/menu')
|
||||||
|
async create(@Body() body: Partial<AdminMenu>) {
|
||||||
|
return vbenSuccess(await this.menuService.createMenu(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('system/menu/:id')
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Partial<AdminMenu>,
|
||||||
|
) {
|
||||||
|
return vbenSuccess(await this.menuService.updateMenu(id, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('system/menu/:id')
|
||||||
|
async remove(@Param('id') id: string) {
|
||||||
|
return vbenSuccess(await this.menuService.deleteMenu(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/admin/menu/admin-menu.entity.ts
Normal file
106
src/admin/menu/admin-menu.entity.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
BeforeInsert,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToMany,
|
||||||
|
PrimaryColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ensureSnowflakeId } from '@/common';
|
||||||
|
import { AdminRole } from '../role/admin-role.entity';
|
||||||
|
|
||||||
|
export type AdminMenuType = 'button' | 'catalog' | 'embedded' | 'link' | 'menu';
|
||||||
|
|
||||||
|
export type AdminMenuMeta = Record<string, any>;
|
||||||
|
|
||||||
|
@Entity('admin_menu')
|
||||||
|
export class AdminMenu {
|
||||||
|
@PrimaryColumn({
|
||||||
|
type: 'bigint',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: 0,
|
||||||
|
type: 'bigint',
|
||||||
|
})
|
||||||
|
pid: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
length: 120,
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
length: 255,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
length: 255,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
component: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
length: 255,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
redirect: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'auth_code',
|
||||||
|
length: 120,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
authCode: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: 'menu',
|
||||||
|
length: 32,
|
||||||
|
})
|
||||||
|
type: AdminMenuType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
nullable: true,
|
||||||
|
type: 'simple-json',
|
||||||
|
})
|
||||||
|
meta: AdminMenuMeta;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
sort: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: false,
|
||||||
|
name: 'is_deleted',
|
||||||
|
})
|
||||||
|
isDeleted: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({
|
||||||
|
name: 'create_time',
|
||||||
|
})
|
||||||
|
createTime: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'update_time',
|
||||||
|
})
|
||||||
|
updateTime: Date;
|
||||||
|
|
||||||
|
@ManyToMany(() => AdminRole, (role) => role.menus)
|
||||||
|
roles: AdminRole[];
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
createId() {
|
||||||
|
ensureSnowflakeId(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
229
src/admin/menu/admin-menu.service.ts
Normal file
229
src/admin/menu/admin-menu.service.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { In, Repository } from 'typeorm';
|
||||||
|
import { toTree } from '@/common';
|
||||||
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
|
import { AdminMenu, AdminMenuMeta } from './admin-menu.entity';
|
||||||
|
|
||||||
|
type MenuInput = Partial<AdminMenu> & {
|
||||||
|
activePath?: string;
|
||||||
|
linkSrc?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminMenuService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AdminMenu)
|
||||||
|
private readonly menuRepository: Repository<AdminMenu>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getAccessCodes(user: AdminUser) {
|
||||||
|
const menus = await this.getAllowedMenus(user);
|
||||||
|
return menus
|
||||||
|
.map((menu) => menu.authCode)
|
||||||
|
.filter((authCode) => !!authCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRouteMenus(user: AdminUser) {
|
||||||
|
const menus = await this.getAllowedMenus(user);
|
||||||
|
return this.buildMenuTree(menus.filter((menu) => menu.type !== 'button'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMenuList() {
|
||||||
|
const menus = await this.menuRepository.find({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.buildMenuTree(menus);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isMenuNameExists(name: string, id?: string) {
|
||||||
|
const menu = await this.menuRepository.findOne({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return !!menu && (!id || menu.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isMenuPathExists(path: string, id?: string) {
|
||||||
|
if (path === '/') return !id;
|
||||||
|
|
||||||
|
const menu = await this.menuRepository.findOne({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return !!menu && (!id || menu.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMenu(data: MenuInput) {
|
||||||
|
const entity = this.menuRepository.create({
|
||||||
|
...this.normalizeMenuInput(data, true),
|
||||||
|
});
|
||||||
|
await this.menuRepository.save(entity);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMenu(id: string, data: MenuInput) {
|
||||||
|
await this.menuRepository.update(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
...this.normalizeMenuInput(data, false),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMenu(id: string) {
|
||||||
|
const ids = await this.collectChildMenuIds(id);
|
||||||
|
await this.menuRepository.update(
|
||||||
|
{
|
||||||
|
id: In(ids),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isDeleted: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAllowedMenus(user: AdminUser) {
|
||||||
|
const activeRoles = (user.roles || []).filter(
|
||||||
|
(role) => !role.isDeleted && role.status === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeRoles.some((role) => role.roleCode === 'super')) {
|
||||||
|
return this.menuRepository.find({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
status: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuMap = new Map<string, AdminMenu>();
|
||||||
|
activeRoles.forEach((role) => {
|
||||||
|
(role.menus || [])
|
||||||
|
.filter((menu) => !menu.isDeleted && menu.status === 1)
|
||||||
|
.forEach((menu) => menuMap.set(menu.id, menu));
|
||||||
|
});
|
||||||
|
return [...menuMap.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMenuInput(
|
||||||
|
data: MenuInput,
|
||||||
|
includeEmptyMeta: boolean,
|
||||||
|
): Partial<AdminMenu> {
|
||||||
|
const meta = this.normalizeMetaInput(data);
|
||||||
|
const menu: Partial<AdminMenu> = {
|
||||||
|
authCode: data.authCode || null,
|
||||||
|
component: data.component || null,
|
||||||
|
name: data.name,
|
||||||
|
path: data.path || null,
|
||||||
|
pid: data.pid || '0',
|
||||||
|
redirect: data.redirect || null,
|
||||||
|
status: data.status ?? 1,
|
||||||
|
type: data.type || 'menu',
|
||||||
|
};
|
||||||
|
if (includeEmptyMeta || Object.keys(meta).length > 0) {
|
||||||
|
menu.meta = meta;
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMetaInput(data: MenuInput): AdminMenuMeta {
|
||||||
|
const meta = this.normalizeMetaValue(data.meta);
|
||||||
|
|
||||||
|
// 兼容表单库返回字面量 `meta.title` 的场景,避免更新菜单时把 meta 覆盖为空对象。
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
if (!key.startsWith('meta.')) return;
|
||||||
|
const metaKey = key.slice('meta.'.length);
|
||||||
|
if (metaKey) meta[metaKey] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.activePath) meta.activePath = data.activePath;
|
||||||
|
if (data.linkSrc && data.type === 'embedded') meta.iframeSrc = data.linkSrc;
|
||||||
|
if (data.linkSrc && data.type === 'link') meta.link = data.linkSrc;
|
||||||
|
|
||||||
|
Object.keys(meta).forEach((key) => {
|
||||||
|
if (meta[key] === null || meta[key] === undefined || meta[key] === '') {
|
||||||
|
delete meta[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMetaValue(
|
||||||
|
meta: AdminMenuMeta | null | string | undefined,
|
||||||
|
): AdminMenuMeta {
|
||||||
|
if (!meta) return {};
|
||||||
|
if (typeof meta !== 'string') return { ...meta };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(meta);
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectChildMenuIds(id: string) {
|
||||||
|
const menus = await this.menuRepository.find({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ids = new Set<string>([id]);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
menus.forEach((menu) => {
|
||||||
|
if (ids.has(menu.pid) && !ids.has(menu.id)) {
|
||||||
|
ids.add(menu.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMenuTree(menus: AdminMenu[]) {
|
||||||
|
const nodes = menus
|
||||||
|
.map((menu) => this.serializeMenu(menu))
|
||||||
|
.sort((prev, next) => {
|
||||||
|
const prevOrder = prev.meta?.order ?? prev.sort ?? 0;
|
||||||
|
const nextOrder = next.meta?.order ?? next.sort ?? 0;
|
||||||
|
return prevOrder - nextOrder;
|
||||||
|
});
|
||||||
|
return toTree(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeMenu(menu: AdminMenu) {
|
||||||
|
const meta = this.normalizeMetaValue(menu.meta);
|
||||||
|
if (!meta.title) meta.title = menu.name;
|
||||||
|
const node = {
|
||||||
|
authCode: menu.authCode,
|
||||||
|
component: menu.component,
|
||||||
|
createTime: menu.createTime,
|
||||||
|
id: menu.id,
|
||||||
|
meta,
|
||||||
|
name: menu.name,
|
||||||
|
path: menu.path,
|
||||||
|
pid: menu.pid || '0',
|
||||||
|
redirect: menu.redirect,
|
||||||
|
status: menu.status,
|
||||||
|
type: menu.type,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
Object.keys(node).forEach((key) => {
|
||||||
|
if (node[key] === null || node[key] === undefined || node[key] === '') {
|
||||||
|
delete node[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/admin/role/admin-role.controller.ts
Normal file
46
src/admin/role/admin-role.controller.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { vbenPage, vbenSuccess } from '@/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { AdminRoleService } from './admin-role.service';
|
||||||
|
|
||||||
|
@ApiTags('admin-role')
|
||||||
|
@Controller('system/role')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AdminRoleController {
|
||||||
|
constructor(private readonly roleService: AdminRoleService) {}
|
||||||
|
|
||||||
|
@Get('list')
|
||||||
|
async list(@Query() query: Record<string, any>) {
|
||||||
|
const page = await this.roleService.getRoleList(query);
|
||||||
|
return vbenPage(page.items, page.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Body() body: Record<string, any>) {
|
||||||
|
return vbenSuccess(await this.roleService.createRole(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Record<string, any>,
|
||||||
|
) {
|
||||||
|
return vbenSuccess(await this.roleService.updateRole(id, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(@Param('id') id: string) {
|
||||||
|
return vbenSuccess(await this.roleService.deleteRole(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/admin/role/admin-role.entity.ts
Normal file
78
src/admin/role/admin-role.entity.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
BeforeInsert,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
PrimaryColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ensureSnowflakeId } from '@/common';
|
||||||
|
import { AdminMenu } from '../menu/admin-menu.entity';
|
||||||
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
|
|
||||||
|
@Entity('admin_role')
|
||||||
|
export class AdminRole {
|
||||||
|
@PrimaryColumn({
|
||||||
|
type: 'bigint',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'role_code',
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
roleCode: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: '',
|
||||||
|
})
|
||||||
|
remark: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: false,
|
||||||
|
name: 'is_deleted',
|
||||||
|
})
|
||||||
|
isDeleted: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({
|
||||||
|
name: 'create_time',
|
||||||
|
})
|
||||||
|
createTime: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'update_time',
|
||||||
|
})
|
||||||
|
updateTime: Date;
|
||||||
|
|
||||||
|
@ManyToMany(() => AdminMenu, (menu) => menu.roles)
|
||||||
|
@JoinTable({
|
||||||
|
inverseJoinColumn: {
|
||||||
|
name: 'menu_id',
|
||||||
|
referencedColumnName: 'id',
|
||||||
|
},
|
||||||
|
joinColumn: {
|
||||||
|
name: 'role_id',
|
||||||
|
referencedColumnName: 'id',
|
||||||
|
},
|
||||||
|
name: 'admin_role_menu',
|
||||||
|
})
|
||||||
|
menus: AdminMenu[];
|
||||||
|
|
||||||
|
@ManyToMany(() => AdminUser, (user) => user.roles)
|
||||||
|
users: AdminUser[];
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
createId() {
|
||||||
|
ensureSnowflakeId(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/admin/role/admin-role.service.ts
Normal file
138
src/admin/role/admin-role.service.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { throwVbenError } from '@/common';
|
||||||
|
import { AdminMenu } from '../menu/admin-menu.entity';
|
||||||
|
import { AdminRole } from './admin-role.entity';
|
||||||
|
|
||||||
|
type RoleInput = Partial<AdminRole> & {
|
||||||
|
permissions?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListQuery = Record<string, any>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminRoleService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AdminRole)
|
||||||
|
private readonly roleRepository: Repository<AdminRole>,
|
||||||
|
@InjectRepository(AdminMenu)
|
||||||
|
private readonly menuRepository: Repository<AdminMenu>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getRoleList(query: ListQuery) {
|
||||||
|
const page = Number(query.page || 1);
|
||||||
|
const pageSize = Number(query.pageSize || 20);
|
||||||
|
const builder = this.roleRepository
|
||||||
|
.createQueryBuilder('role')
|
||||||
|
.leftJoinAndSelect('role.menus', 'menu')
|
||||||
|
.where('role.isDeleted = :isDeleted', { isDeleted: false });
|
||||||
|
|
||||||
|
if (query.id) builder.andWhere('role.id LIKE :id', { id: `%${query.id}%` });
|
||||||
|
if (query.name) {
|
||||||
|
builder.andWhere('role.name LIKE :name', { name: `%${query.name}%` });
|
||||||
|
}
|
||||||
|
if (query.remark) {
|
||||||
|
builder.andWhere('role.remark LIKE :remark', {
|
||||||
|
remark: `%${query.remark}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (['0', '1'].includes(String(query.status))) {
|
||||||
|
builder.andWhere('role.status = :status', {
|
||||||
|
status: Number(query.status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.startTime) {
|
||||||
|
builder.andWhere('role.createTime >= :startTime', {
|
||||||
|
startTime: query.startTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.endTime) {
|
||||||
|
builder.andWhere('role.createTime <= :endTime', {
|
||||||
|
endTime: query.endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [roles, total] = await builder
|
||||||
|
.orderBy('role.createTime', 'ASC')
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: roles.map((role) => this.serializeRole(role)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRole(data: RoleInput) {
|
||||||
|
const role = this.roleRepository.create({
|
||||||
|
name: data.name,
|
||||||
|
remark: data.remark || '',
|
||||||
|
roleCode: this.createRoleCode(data.name),
|
||||||
|
status: data.status ?? 1,
|
||||||
|
});
|
||||||
|
role.menus = await this.findMenusByIds(data.permissions || []);
|
||||||
|
await this.roleRepository.save(role);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRole(id: string, data: RoleInput) {
|
||||||
|
const role = await this.roleRepository.findOne({
|
||||||
|
relations: ['menus'],
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!role) throwVbenError('角色不存在', HttpStatus.BAD_REQUEST);
|
||||||
|
|
||||||
|
if (data.name !== undefined) role.name = data.name;
|
||||||
|
if (data.remark !== undefined) role.remark = data.remark;
|
||||||
|
if (data.status !== undefined) role.status = data.status;
|
||||||
|
if (data.permissions) role.menus = await this.findMenusByIds(data.permissions);
|
||||||
|
|
||||||
|
await this.roleRepository.save(role);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRole(id: string) {
|
||||||
|
await this.roleRepository.update(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
isDeleted: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeRole(role: AdminRole) {
|
||||||
|
return {
|
||||||
|
createTime: role.createTime,
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
permissions: (role.menus || []).map((menu) => menu.id),
|
||||||
|
remark: role.remark,
|
||||||
|
status: role.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMenusByIds(ids: string[]) {
|
||||||
|
const normalizedIds = ids.map((id) => String(id)).filter(Boolean);
|
||||||
|
if (!normalizedIds.length) return [];
|
||||||
|
return this.menuRepository.find({
|
||||||
|
where: normalizedIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
isDeleted: false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRoleCode(name?: string) {
|
||||||
|
const slug = (name || 'role')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
return `${slug || 'role'}_${Date.now()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/admin/timezone/admin-timezone.controller.ts
Normal file
45
src/admin/timezone/admin-timezone.controller.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentAdminUser, vbenSuccess } from '@/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
|
import { AdminTimezoneService } from './admin-timezone.service';
|
||||||
|
|
||||||
|
const TIMEZONE_OPTIONS = [
|
||||||
|
{ label: 'America/New_York (GMT-5)', value: 'America/New_York' },
|
||||||
|
{ label: 'Europe/London (GMT+0)', value: 'Europe/London' },
|
||||||
|
{ label: 'Asia/Shanghai (GMT+8)', value: 'Asia/Shanghai' },
|
||||||
|
{ label: 'Asia/Tokyo (GMT+9)', value: 'Asia/Tokyo' },
|
||||||
|
{ label: 'Asia/Seoul (GMT+9)', value: 'Asia/Seoul' },
|
||||||
|
];
|
||||||
|
|
||||||
|
@ApiTags('admin-timezone')
|
||||||
|
@Controller('timezone')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AdminTimezoneController {
|
||||||
|
constructor(private readonly timezoneService: AdminTimezoneService) {}
|
||||||
|
|
||||||
|
@Get('getTimezoneOptions')
|
||||||
|
getOptions() {
|
||||||
|
return vbenSuccess(TIMEZONE_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('getTimezone')
|
||||||
|
async getTimezone(@CurrentAdminUser() user: AdminUser) {
|
||||||
|
return vbenSuccess(await this.timezoneService.getTimezone(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('setTimezone')
|
||||||
|
async setTimezone(
|
||||||
|
@CurrentAdminUser() user: AdminUser,
|
||||||
|
@Body() body: { timezone?: string },
|
||||||
|
) {
|
||||||
|
return vbenSuccess(
|
||||||
|
await this.timezoneService.setTimezone(
|
||||||
|
user,
|
||||||
|
body.timezone,
|
||||||
|
TIMEZONE_OPTIONS.map((option) => option.value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/admin/timezone/admin-timezone.service.ts
Normal file
28
src/admin/timezone/admin-timezone.service.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { throwVbenError } from '@/common';
|
||||||
|
import { AdminUser } from '../user/admin-user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminTimezoneService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AdminUser)
|
||||||
|
private readonly userRepository: Repository<AdminUser>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getTimezone(user: AdminUser) {
|
||||||
|
return user.timezone || 'Asia/Shanghai';
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTimezone(user: AdminUser, timezone: string, allowed: string[]) {
|
||||||
|
if (!timezone || !allowed.includes(timezone)) {
|
||||||
|
throwVbenError('Invalid timezone', HttpStatus.BAD_REQUEST, 'Bad Request');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userRepository.update(user.id, {
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/admin/user/admin-user.controller.ts
Normal file
18
src/admin/user/admin-user.controller.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentAdminUser, vbenSuccess } from '@/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { AdminUser } from './admin-user.entity';
|
||||||
|
import { AdminUserService } from './admin-user.service';
|
||||||
|
|
||||||
|
@ApiTags('admin-user')
|
||||||
|
@Controller('user')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AdminUserController {
|
||||||
|
constructor(private readonly userService: AdminUserService) {}
|
||||||
|
|
||||||
|
@Get('info')
|
||||||
|
async info(@CurrentAdminUser() user: AdminUser) {
|
||||||
|
return vbenSuccess(this.userService.serializeUser(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/admin/user/admin-user.entity.ts
Normal file
86
src/admin/user/admin-user.entity.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
BeforeInsert,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
PrimaryColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ensureSnowflakeId } from '@/common';
|
||||||
|
import { AdminRole } from '../role/admin-role.entity';
|
||||||
|
|
||||||
|
@Entity('admin_user')
|
||||||
|
export class AdminUser {
|
||||||
|
@PrimaryColumn({
|
||||||
|
type: 'bigint',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'real_name',
|
||||||
|
})
|
||||||
|
realName: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: '',
|
||||||
|
name: 'home_path',
|
||||||
|
})
|
||||||
|
homePath: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: 'Asia/Shanghai',
|
||||||
|
})
|
||||||
|
timezone: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: false,
|
||||||
|
name: 'is_deleted',
|
||||||
|
})
|
||||||
|
isDeleted: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({
|
||||||
|
name: 'create_time',
|
||||||
|
})
|
||||||
|
createTime: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'update_time',
|
||||||
|
})
|
||||||
|
updateTime: Date;
|
||||||
|
|
||||||
|
@ManyToMany(() => AdminRole, (role) => role.users, {
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
@JoinTable({
|
||||||
|
inverseJoinColumn: {
|
||||||
|
name: 'role_id',
|
||||||
|
referencedColumnName: 'id',
|
||||||
|
},
|
||||||
|
joinColumn: {
|
||||||
|
name: 'user_id',
|
||||||
|
referencedColumnName: 'id',
|
||||||
|
},
|
||||||
|
name: 'admin_user_role',
|
||||||
|
})
|
||||||
|
roles: AdminRole[];
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
createId() {
|
||||||
|
ensureSnowflakeId(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/admin/user/admin-user.service.ts
Normal file
17
src/admin/user/admin-user.service.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AdminUser } from './admin-user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminUserService {
|
||||||
|
serializeUser(user: AdminUser) {
|
||||||
|
return {
|
||||||
|
homePath: user.homePath,
|
||||||
|
id: user.id,
|
||||||
|
realName: user.realName,
|
||||||
|
roles: (user.roles || [])
|
||||||
|
.filter((role) => !role.isDeleted && role.status === 1)
|
||||||
|
.map((role) => role.roleCode),
|
||||||
|
username: user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,10 +5,9 @@ import { APP_INTERCEPTOR } from '@nestjs/core';
|
|||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MinioModule } from 'nestjs-minio-client';
|
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 { MinioClientModule } from './minio/minio.module';
|
||||||
import { SaveBodyInterceptor } from './common';
|
import { SaveBodyInterceptor } from './common';
|
||||||
|
import { AdminModule } from './admin/admin.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -26,8 +25,9 @@ import { SaveBodyInterceptor } from './common';
|
|||||||
username: configService.get('DB_USERNAME'),
|
username: configService.get('DB_USERNAME'),
|
||||||
password: configService.get('DB_PASSWORD'),
|
password: configService.get('DB_PASSWORD'),
|
||||||
database: configService.get('DB_DATABASE'),
|
database: configService.get('DB_DATABASE'),
|
||||||
synchronize: configService.get('DB_SYNC'),
|
synchronize: configService.get<string>('DB_SYNC') === 'true',
|
||||||
entities: [__dirname + '/**/*.entity.js'],
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
|
subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
@ -46,9 +46,8 @@ import { SaveBodyInterceptor } from './common';
|
|||||||
},
|
},
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
ComponentModule,
|
|
||||||
DictModule,
|
|
||||||
MinioClientModule,
|
MinioClientModule,
|
||||||
|
AdminModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
|
|||||||
37
src/common/admin-response.ts
Normal file
37
src/common/admin-response.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type VbenResponse<T = any> = {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
error: any;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const vbenSuccess = <T = any>(data: T): VbenResponse<T> => ({
|
||||||
|
code: 0,
|
||||||
|
data,
|
||||||
|
error: null,
|
||||||
|
message: 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const vbenPage = <T = any>(items: T[], total: number) =>
|
||||||
|
vbenSuccess({
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const throwVbenError = (
|
||||||
|
message: string,
|
||||||
|
status = HttpStatus.BAD_REQUEST,
|
||||||
|
error: any = message,
|
||||||
|
): never => {
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
code: -1,
|
||||||
|
data: null,
|
||||||
|
error,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
};
|
||||||
19
src/common/admin-tree.ts
Normal file
19
src/common/admin-tree.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export function toTree<T extends { id: string; pid?: string | null }>(
|
||||||
|
nodes: T[],
|
||||||
|
) {
|
||||||
|
const map = new Map<string, T & { children?: T[] }>();
|
||||||
|
nodes.forEach((node) => map.set(node.id, { ...node }));
|
||||||
|
|
||||||
|
const roots: Array<T & { children?: T[] }> = [];
|
||||||
|
map.forEach((node) => {
|
||||||
|
const parent = node.pid ? map.get(node.pid) : null;
|
||||||
|
if (parent) {
|
||||||
|
parent.children = parent.children || [];
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
8
src/common/decorators/current-admin-user.decorator.ts
Normal file
8
src/common/decorators/current-admin-user.decorator.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentAdminUser = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.adminUser;
|
||||||
|
},
|
||||||
|
);
|
||||||
5
src/common/decorators/public.decorator.ts
Normal file
5
src/common/decorators/public.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
@ -1,4 +1,9 @@
|
|||||||
|
export * from './admin-response';
|
||||||
|
export * from './admin-tree';
|
||||||
|
export * from './decorators/current-admin-user.decorator';
|
||||||
export * from './decorators/decode-dict.decorator';
|
export * from './decorators/decode-dict.decorator';
|
||||||
|
export * from './decorators/public.decorator';
|
||||||
export * from './interceptors/save-body.interceptor';
|
export * from './interceptors/save-body.interceptor';
|
||||||
|
export * from './snowflake-id';
|
||||||
export * from './services/tool.service';
|
export * from './services/tool.service';
|
||||||
export * from './swagger/swagger-response';
|
export * from './swagger/swagger-response';
|
||||||
|
|||||||
20
src/common/snowflake-id.subscriber.ts
Normal file
20
src/common/snowflake-id.subscriber.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
EntitySubscriberInterface,
|
||||||
|
EventSubscriber,
|
||||||
|
InsertEvent,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ensureSnowflakeId } from './snowflake-id';
|
||||||
|
|
||||||
|
@EventSubscriber()
|
||||||
|
export class SnowflakeIdSubscriber implements EntitySubscriberInterface {
|
||||||
|
beforeInsert(event: InsertEvent<any>) {
|
||||||
|
if (!event.entity) return;
|
||||||
|
|
||||||
|
const idColumn = event.metadata.primaryColumns.find(
|
||||||
|
(column) => column.propertyName === 'id',
|
||||||
|
);
|
||||||
|
if (idColumn?.type !== 'bigint') return;
|
||||||
|
|
||||||
|
ensureSnowflakeId(event.entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/common/snowflake-id.ts
Normal file
93
src/common/snowflake-id.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
const TWEPOCH = 1288834974657n;
|
||||||
|
const WORKER_ID_BITS = 5n;
|
||||||
|
const DATACENTER_ID_BITS = 5n;
|
||||||
|
const SEQUENCE_BITS = 12n;
|
||||||
|
|
||||||
|
const MAX_WORKER_ID = (1n << WORKER_ID_BITS) - 1n;
|
||||||
|
const MAX_DATACENTER_ID = (1n << DATACENTER_ID_BITS) - 1n;
|
||||||
|
const SEQUENCE_MASK = (1n << SEQUENCE_BITS) - 1n;
|
||||||
|
const WORKER_ID_SHIFT = SEQUENCE_BITS;
|
||||||
|
const DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
|
||||||
|
const TIMESTAMP_LEFT_SHIFT =
|
||||||
|
SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS;
|
||||||
|
|
||||||
|
class SnowflakeIdGenerator {
|
||||||
|
private readonly workerId = this.readNodeId(
|
||||||
|
'SNOWFLAKE_WORKER_ID',
|
||||||
|
MAX_WORKER_ID,
|
||||||
|
);
|
||||||
|
private readonly datacenterId = this.readNodeId(
|
||||||
|
'SNOWFLAKE_DATACENTER_ID',
|
||||||
|
MAX_DATACENTER_ID,
|
||||||
|
);
|
||||||
|
private lastTimestamp = -1n;
|
||||||
|
private sequence = 0n;
|
||||||
|
|
||||||
|
nextId() {
|
||||||
|
let timestamp = this.currentTime();
|
||||||
|
|
||||||
|
if (timestamp < this.lastTimestamp) {
|
||||||
|
timestamp = this.waitUntil(this.lastTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamp === this.lastTimestamp) {
|
||||||
|
this.sequence = (this.sequence + 1n) & SEQUENCE_MASK;
|
||||||
|
if (this.sequence === 0n) {
|
||||||
|
timestamp = this.waitUntil(this.lastTimestamp);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.sequence = 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastTimestamp = timestamp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
((timestamp - TWEPOCH) << TIMESTAMP_LEFT_SHIFT) |
|
||||||
|
(this.datacenterId << DATACENTER_ID_SHIFT) |
|
||||||
|
(this.workerId << WORKER_ID_SHIFT) |
|
||||||
|
this.sequence
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentTime() {
|
||||||
|
return BigInt(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitUntil(lastTimestamp: bigint) {
|
||||||
|
// Snowflake requires monotonic timestamps; waiting avoids duplicate IDs
|
||||||
|
// when the system clock briefly moves backwards or a millisecond is full.
|
||||||
|
let timestamp = this.currentTime();
|
||||||
|
while (timestamp <= lastTimestamp) {
|
||||||
|
timestamp = this.currentTime();
|
||||||
|
}
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readNodeId(envName: string, max: bigint) {
|
||||||
|
const value = Number(process.env[envName] || 1);
|
||||||
|
if (!Number.isInteger(value) || value < 0 || value > Number(max)) {
|
||||||
|
return 1n;
|
||||||
|
}
|
||||||
|
return BigInt(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snowflakeIdGenerator = new SnowflakeIdGenerator();
|
||||||
|
|
||||||
|
export const createSnowflakeId = () => snowflakeIdGenerator.nextId();
|
||||||
|
|
||||||
|
export type SnowflakeEntity = {
|
||||||
|
id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isEmptySnowflakeId = (id: SnowflakeEntity['id']) =>
|
||||||
|
id === undefined || id === null || id === '' || id === 0 || id === '0';
|
||||||
|
|
||||||
|
export const ensureSnowflakeId = <T extends SnowflakeEntity>(entity: T) => {
|
||||||
|
if (isEmptySnowflakeId(entity.id)) {
|
||||||
|
entity.id = createSnowflakeId();
|
||||||
|
} else {
|
||||||
|
entity.id = String(entity.id);
|
||||||
|
}
|
||||||
|
return entity.id;
|
||||||
|
};
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { ComponentController } from './component.controller';
|
|
||||||
import { ComponentService } from './component.service';
|
|
||||||
import { Component } from './component.entity';
|
|
||||||
import { ToolsService } from '@/common';
|
|
||||||
import { DictModule } from '@/dict/dict.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [TypeOrmModule.forFeature([Component]), DictModule],
|
|
||||||
controllers: [ComponentController],
|
|
||||||
providers: [ComponentService, ToolsService],
|
|
||||||
exports: [ComponentService],
|
|
||||||
})
|
|
||||||
export class ComponentModule {}
|
|
||||||
@ -5,11 +5,13 @@ import request = require('supertest');
|
|||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { AppController } from '../src/app.controller';
|
import { AppController } from '../src/app.controller';
|
||||||
import { AppService } from '../src/app.service';
|
import { AppService } from '../src/app.service';
|
||||||
|
import { AdminAuthService } from '../src/admin/auth/admin-auth.service';
|
||||||
|
import { JwtAuthGuard } from '../src/admin/auth/jwt-auth.guard';
|
||||||
|
import { ComponentController } from '../src/admin/component/component.controller';
|
||||||
|
import { ComponentService } from '../src/admin/component/component.service';
|
||||||
|
import { DictController } from '../src/admin/dict/dict.controller';
|
||||||
|
import { DictService } from '../src/admin/dict/dict.service';
|
||||||
import { SaveBodyInterceptor, ToolsService } from '../src/common';
|
import { SaveBodyInterceptor, ToolsService } from '../src/common';
|
||||||
import { ComponentController } from '../src/component/component.controller';
|
|
||||||
import { ComponentService } from '../src/component/component.service';
|
|
||||||
import { DictController } from '../src/dict/dict.controller';
|
|
||||||
import { DictService } from '../src/dict/dict.service';
|
|
||||||
import { MinioClientController } from '../src/minio/minio.controller';
|
import { MinioClientController } from '../src/minio/minio.controller';
|
||||||
import { MinioClientService } from '../src/minio/minio.service';
|
import { MinioClientService } from '../src/minio/minio.service';
|
||||||
import {
|
import {
|
||||||
@ -18,7 +20,7 @@ import {
|
|||||||
} from './helpers/controller-route.helper';
|
} from './helpers/controller-route.helper';
|
||||||
|
|
||||||
const component = {
|
const component = {
|
||||||
id: 'component-id',
|
id: '2041739550026043392',
|
||||||
name: '基础折线图',
|
name: '基础折线图',
|
||||||
type: 1,
|
type: 1,
|
||||||
componentType: 1,
|
componentType: 1,
|
||||||
@ -70,6 +72,10 @@ const componentServiceMock = {
|
|||||||
find: jest.fn(),
|
find: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const authServiceMock = {
|
||||||
|
currentUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const dictServiceMock = {
|
const dictServiceMock = {
|
||||||
getDictByKey: jest.fn(),
|
getDictByKey: jest.fn(),
|
||||||
getComponentDictByType: jest.fn(),
|
getComponentDictByType: jest.fn(),
|
||||||
@ -154,7 +160,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
|||||||
const response = await request(server)
|
const response = await request(server)
|
||||||
.post('/component/save')
|
.post('/component/save')
|
||||||
.send({
|
.send({
|
||||||
id: 'frontend-id',
|
id: '2041739550026043999',
|
||||||
name: component.name,
|
name: component.name,
|
||||||
type: component.type,
|
type: component.type,
|
||||||
componentType: component.componentType,
|
componentType: component.componentType,
|
||||||
@ -382,6 +388,42 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'GET /minio/resource-proxy': async (server) => {
|
||||||
|
const body = Buffer.from('proxy-content');
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
headers: {
|
||||||
|
get: jest.fn().mockReturnValue('text/css; charset=utf-8'),
|
||||||
|
},
|
||||||
|
arrayBuffer: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
|
||||||
|
),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(server)
|
||||||
|
.get('/minio/resource-proxy')
|
||||||
|
.query({ url: 'https://example.com/assets/style.css' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'https://example.com/assets/style.css',
|
||||||
|
expect.objectContaining({
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(response.headers['content-type']).toContain('text/css');
|
||||||
|
expect(response.text).toBe('proxy-content');
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
'GET /minio/download': async (server) => {
|
'GET /minio/download': async (server) => {
|
||||||
minioServiceMock.getObject.mockResolvedValue({
|
minioServiceMock.getObject.mockResolvedValue({
|
||||||
stream: Readable.from(['file-content']),
|
stream: Readable.from(['file-content']),
|
||||||
@ -450,6 +492,11 @@ describe('KT Template Online API (e2e)', () => {
|
|||||||
provide: ComponentService,
|
provide: ComponentService,
|
||||||
useValue: componentServiceMock,
|
useValue: componentServiceMock,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: AdminAuthService,
|
||||||
|
useValue: authServiceMock,
|
||||||
|
},
|
||||||
|
JwtAuthGuard,
|
||||||
{
|
{
|
||||||
provide: DictService,
|
provide: DictService,
|
||||||
useValue: dictServiceMock,
|
useValue: dictServiceMock,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user