Browse Source

feat: init project

gemercheung 7 months ago
commit
bbf72b02c7
100 changed files with 28784 additions and 0 deletions
  1. 1 0
      .gitignore
  2. 3 0
      packages/.gitignore
  3. 17 0
      packages/backend/.env
  4. 27 0
      packages/backend/.eslintrc.js
  5. 37 0
      packages/backend/.gitignore
  6. 7 0
      packages/backend/.prettierrc
  7. 21 0
      packages/backend/LICENSE
  8. 25 0
      packages/backend/README.md
  9. 163 0
      packages/backend/init.sql
  10. 11 0
      packages/backend/nest-cli.json
  11. 9730 0
      packages/backend/package-lock.json
  12. 66 0
      packages/backend/package.json
  13. 6765 0
      packages/backend/pnpm-lock.yaml
  14. 40 0
      packages/backend/src/app.module.ts
  15. 12 0
      packages/backend/src/common/decorators/return-type.decorator.ts
  16. 11 0
      packages/backend/src/common/decorators/roles.decorator.ts
  17. 25 0
      packages/backend/src/common/exceptions/custom.exception.ts
  18. 35 0
      packages/backend/src/common/exceptions/error-code.ts
  19. 29 0
      packages/backend/src/common/filters/all-exception.filter.ts
  20. 4 0
      packages/backend/src/common/guards/index.ts
  21. 15 0
      packages/backend/src/common/guards/jwt.guard.ts
  22. 15 0
      packages/backend/src/common/guards/local.guard.ts
  23. 29 0
      packages/backend/src/common/guards/permission.guard.ts
  24. 22 0
      packages/backend/src/common/guards/preview.guard.ts
  25. 29 0
      packages/backend/src/common/guards/role.guard.ts
  26. 40 0
      packages/backend/src/common/interceptors/transform.interceptor.ts
  27. 11 0
      packages/backend/src/constants/redis.contant.ts
  28. 42 0
      packages/backend/src/main.ts
  29. 28 0
      packages/backend/src/modules/article/article.controller.ts
  30. 63 0
      packages/backend/src/modules/article/article.entity.ts
  31. 11 0
      packages/backend/src/modules/article/article.module.ts
  32. 66 0
      packages/backend/src/modules/article/article.service.ts
  33. 65 0
      packages/backend/src/modules/article/dto.ts
  34. 90 0
      packages/backend/src/modules/auth/auth.controller.ts
  35. 34 0
      packages/backend/src/modules/auth/auth.module.ts
  36. 92 0
      packages/backend/src/modules/auth/auth.service.ts
  37. 72 0
      packages/backend/src/modules/auth/dto.ts
  38. 71 0
      packages/backend/src/modules/auth/jwt.strategy.ts
  39. 26 0
      packages/backend/src/modules/auth/local.strategy.ts
  40. 43 0
      packages/backend/src/modules/category/category.controller.ts
  41. 57 0
      packages/backend/src/modules/category/category.entity.ts
  42. 11 0
      packages/backend/src/modules/category/category.module.ts
  43. 53 0
      packages/backend/src/modules/category/category.service.ts
  44. 52 0
      packages/backend/src/modules/category/dto.ts
  45. 93 0
      packages/backend/src/modules/permission/dto.ts
  46. 78 0
      packages/backend/src/modules/permission/permission.controller.ts
  47. 77 0
      packages/backend/src/modules/permission/permission.entity.ts
  48. 20 0
      packages/backend/src/modules/permission/permission.module.ts
  49. 88 0
      packages/backend/src/modules/permission/permission.service.ts
  50. 106 0
      packages/backend/src/modules/role/dto.ts
  51. 110 0
      packages/backend/src/modules/role/role.controller.ts
  52. 37 0
      packages/backend/src/modules/role/role.entity.ts
  53. 22 0
      packages/backend/src/modules/role/role.module.ts
  54. 162 0
      packages/backend/src/modules/role/role.service.ts
  55. 136 0
      packages/backend/src/modules/user/dto.ts
  56. 36 0
      packages/backend/src/modules/user/profile.entity.ts
  57. 132 0
      packages/backend/src/modules/user/user.controller.ts
  58. 53 0
      packages/backend/src/modules/user/user.entity.ts
  59. 24 0
      packages/backend/src/modules/user/user.module.ts
  60. 179 0
      packages/backend/src/modules/user/user.service.ts
  61. 47 0
      packages/backend/src/shared/redis.service.ts
  62. 75 0
      packages/backend/src/shared/shared.module.ts
  63. 59 0
      packages/backend/src/shared/shared.service.ts
  64. 11 0
      packages/backend/src/types/index.ts
  65. 4 0
      packages/backend/tsconfig.build.json
  66. 25 0
      packages/backend/tsconfig.json
  67. 9 0
      packages/frontend/.editorconfig
  68. 1 0
      packages/frontend/.env
  69. 12 0
      packages/frontend/.env.development
  70. 10 0
      packages/frontend/.env.production
  71. 4 0
      packages/frontend/.gitignore
  72. 3 0
      packages/frontend/.npmrc
  73. 21 0
      packages/frontend/LICENSE
  74. 88 0
      packages/frontend/README.md
  75. 39 0
      packages/frontend/build/index.js
  76. 25 0
      packages/frontend/build/plugin-isme/icons.js
  77. 10 0
      packages/frontend/build/plugin-isme/index.js
  78. 25 0
      packages/frontend/build/plugin-isme/page-pathes.js
  79. 34 0
      packages/frontend/eslint.config.js
  80. 94 0
      packages/frontend/index.html
  81. 14 0
      packages/frontend/jsconfig.json
  82. 61 0
      packages/frontend/package.json
  83. 8583 0
      packages/frontend/pnpm-lock.yaml
  84. BIN
      packages/frontend/public/favicon.png
  85. 65 0
      packages/frontend/src/App.vue
  86. 24 0
      packages/frontend/src/api/index.js
  87. 10 0
      packages/frontend/src/assets/icons/dynamic-icons.js
  88. 1 0
      packages/frontend/src/assets/icons/feather/activity.svg
  89. 1 0
      packages/frontend/src/assets/icons/feather/airplay.svg
  90. 1 0
      packages/frontend/src/assets/icons/feather/alert-circle.svg
  91. 1 0
      packages/frontend/src/assets/icons/feather/alert-octagon.svg
  92. 1 0
      packages/frontend/src/assets/icons/feather/alert-triangle.svg
  93. 1 0
      packages/frontend/src/assets/icons/feather/align-center.svg
  94. 1 0
      packages/frontend/src/assets/icons/feather/align-justify.svg
  95. 1 0
      packages/frontend/src/assets/icons/feather/align-left.svg
  96. 1 0
      packages/frontend/src/assets/icons/feather/align-right.svg
  97. 1 0
      packages/frontend/src/assets/icons/feather/anchor.svg
  98. 1 0
      packages/frontend/src/assets/icons/feather/aperture.svg
  99. 1 0
      packages/frontend/src/assets/icons/feather/archive.svg
  100. 0 0
      packages/frontend/src/assets/icons/feather/arrow-down-circle.svg

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+node_modules

+ 3 - 0
packages/.gitignore

@@ -0,0 +1,3 @@
+node_modules
+dist
+.vscode

+ 17 - 0
packages/backend/.env

@@ -0,0 +1,17 @@
+# 服务端口
+APP_PORT=8085
+# DB
+DB_HOST=120.24.144.164
+DB_PORT=3306
+DB_USER=root
+DB_PWD='4Dage@4Dage#@168'
+DB_DATABASE=4dkankan_motion
+DB_SYNC=true  # 是否开启同步,生产环境请设置成 false
+
+# Redis
+REDIS_URL=redis://192.168.0.47:6379
+
+# JWT
+JWT_SECRET="d0!doc15415B0*4G0`"
+# 是否预览环境
+IS_PREVIEW=false  

+ 27 - 0
packages/backend/.eslintrc.js

@@ -0,0 +1,27 @@
+module.exports = {
+  parser: '@typescript-eslint/parser',
+  parserOptions: {
+    project: 'tsconfig.json',
+    tsconfigRootDir: __dirname,
+    sourceType: 'module',
+  },
+  plugins: ['@typescript-eslint/eslint-plugin'],
+  extends: [
+    'plugin:@typescript-eslint/recommended',
+    'plugin:prettier/recommended',
+  ],
+  root: true,
+  env: {
+    node: true,
+    jest: true,
+  },
+  ignorePatterns: ['.eslintrc.js'],
+  rules: {
+    'prettier/prettier': 'error',
+    '@typescript-eslint/interface-name-prefix': 'off',
+    '@typescript-eslint/explicit-function-return-type': 'off',
+    '@typescript-eslint/explicit-module-boundary-types': 'off',
+    '@typescript-eslint/no-explicit-any': 'off',
+    '@typescript-eslint/no-unused-vars': 'off',
+  },
+};

+ 37 - 0
packages/backend/.gitignore

@@ -0,0 +1,37 @@
+# compiled output
+/dist
+/ncc-dist
+/node_modules
+
+# Logs
+logs
+*.log
+npm-debug.log*
+pnpm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+*.local
+
+# OS
+.DS_Store
+
+# Tests
+/coverage
+/.nyc_output
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json

+ 7 - 0
packages/backend/.prettierrc

@@ -0,0 +1,7 @@
+{
+  "trailingComma": "all",
+  "singleQuote": true,
+  "printWidth": 100,
+  "semi": true,
+  "endOfLine": "auto"
+}

+ 21 - 0
packages/backend/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Ronnie Zhang(大脸怪)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 25 - 0
packages/backend/README.md

@@ -0,0 +1,25 @@
+# Isme Nest Server
+
+## 简介
+
+本项目是 [Vue Naive Admin 2.0](https://github.com/zclzone/vue-naive-admin) 的后端服务,使用 Nestjs + TypeOrm + MySql + Redis 搭建,实现了 JWT 认证、菜单管理、RBAC 权限控制核心等功能。
+
+## 预览
+
+[https://admin.isme.top](https://admin.isme.top)
+
+## 接口文档
+
+[isme-nest-serve | Apifox](https://apifox.com/apidoc/shared-ff4a4d32-c0d1-4caf-b0ee-6abc130f734a)
+
+## 安装及使用
+
+请查看 Vue Naive Admin 2.0 项目文档:[Vue Naive Admin Docs](https://docs.isme.top/web/#/624306705/188522224)
+
+## 版权说明(跟Vue Naive Admin 2 完全一样)
+
+本项目使用 `MIT协议`,默认授权给任何人,被授权人可免费地无限制的使用、复制、修改、合并、发布、发行、再许可、售卖本软件拷贝、并有权向被供应人授予同等的权利,但必须满足以下条件:
+
+- 复制、修改和发行本项目代码需包含原作者的版权及许可信息,包括但不限于文件头注释、协议等
+
+简单来说,作者只想保留版权,没有任何其他限制。

+ 163 - 0
packages/backend/init.sql

@@ -0,0 +1,163 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:30:13
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+ 
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for permission
+-- ----------------------------
+DROP TABLE IF EXISTS `permission`;
+CREATE TABLE `permission`  (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+  `code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+  `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+  `parentId` int(11) NULL DEFAULT NULL,
+  `path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  `redirect` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  `component` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  `layout` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  `keepAlive` tinyint(4) NULL DEFAULT NULL,
+  `method` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  `show` tinyint(4) NOT NULL DEFAULT 1 COMMENT '是否展示在页面菜单',
+  `enable` tinyint(4) NOT NULL DEFAULT 1,
+  `order` int(11) NULL DEFAULT NULL,
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE INDEX `IDX_30e166e8c6359970755c5727a2`(`code`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 21 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
+
+-- ----------------------------
+-- Records of permission
+-- ----------------------------
+INSERT INTO `permission` VALUES (1, '资源管理', 'Resource_Mgt', 'MENU', 2, '/pms/resource', NULL, 'i-fe:list', '/src/views/pms/resource/index.vue', NULL, NULL, NULL, NULL, 1, 1, 1);
+INSERT INTO `permission` VALUES (2, '系统管理', 'SysMgt', 'MENU', NULL, NULL, NULL, 'i-fe:grid', NULL, NULL, NULL, NULL, NULL, 1, 1, 2);
+INSERT INTO `permission` VALUES (3, '角色管理', 'RoleMgt', 'MENU', 2, '/pms/role', NULL, 'i-fe:user-check', '/src/views/pms/role/index.vue', NULL, NULL, NULL, NULL, 1, 1, 2);
+INSERT INTO `permission` VALUES (4, '用户管理', 'UserMgt', 'MENU', 2, '/pms/user', NULL, 'i-fe:user', '/src/views/pms/user/index.vue', NULL, 1, NULL, NULL, 1, 1, 3);
+INSERT INTO `permission` VALUES (5, '分配用户', 'RoleUser', 'MENU', 3, '/pms/role/user/:roleId', NULL, 'i-fe:user-plus', '/src/views/pms/role/role-user.vue', 'full', NULL, NULL, NULL, 0, 1, 1);
+INSERT INTO `permission` VALUES (6, '业务示例', 'Demo', 'MENU', NULL, NULL, NULL, 'i-fe:grid', NULL, NULL, NULL, NULL, NULL, 1, 1, 1);
+INSERT INTO `permission` VALUES (7, '图片上传', 'ImgUpload', 'MENU', 6, '/demo/upload', NULL, 'i-fe:image', '/src/views/demo/upload/index.vue', '', 1, NULL, NULL, 1, 1, 2);
+INSERT INTO `permission` VALUES (8, '个人资料', 'UserProfile', 'MENU', NULL, '/profile', NULL, 'i-fe:user', '/src/views/profile/index.vue', NULL, NULL, NULL, NULL, 0, 1, 99);
+INSERT INTO `permission` VALUES (9, '基础功能', 'Base', 'MENU', NULL, '', NULL, 'i-fe:grid', NULL, '', NULL, NULL, NULL, 1, 1, 0);
+INSERT INTO `permission` VALUES (10, '基础组件', 'BaseComponents', 'MENU', 9, '/base/components', NULL, 'i-me:awesome', '/src/views/base/index.vue', NULL, NULL, NULL, NULL, 1, 1, 1);
+INSERT INTO `permission` VALUES (11, 'Unocss', 'Unocss', 'MENU', 9, '/base/unocss', NULL, 'i-me:awesome', '/src/views/base/unocss.vue', NULL, NULL, NULL, NULL, 1, 1, 2);
+INSERT INTO `permission` VALUES (12, 'KeepAlive', 'KeepAlive', 'MENU', 9, '/base/keep-alive', NULL, 'i-me:awesome', '/src/views/base/keep-alive.vue', NULL, 1, NULL, NULL, 1, 1, 3);
+INSERT INTO `permission` VALUES (13, '创建新用户', 'AddUser', 'BUTTON', 4, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 1, 1);
+INSERT INTO `permission` VALUES (14, '图标 Icon', 'Icon', 'MENU', 9, '/base/icon', NULL, 'i-fe:feather', '/src/views/base/unocss-icon.vue', '', NULL, NULL, NULL, 1, 1, 0);
+INSERT INTO `permission` VALUES (15, 'MeModal', 'TestModal', 'MENU', 9, '/testModal', NULL, 'i-me:dialog', '/src/views/base/test-modal.vue', NULL, NULL, NULL, NULL, 1, 1, 5);
+
+-- ----------------------------
+-- Table structure for profile
+-- ----------------------------
+DROP TABLE IF EXISTS `profile`;
+CREATE TABLE `profile`  (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `gender` int(11) NULL DEFAULT NULL,
+  `avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif?imageView2/1/w/80/h/80',
+  `address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  `userId` int(11) NOT NULL,
+  `nickName` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE INDEX `IDX_a24972ebd73b106250713dcddd`(`userId`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
+
+-- ----------------------------
+-- Records of profile
+-- ----------------------------
+INSERT INTO `profile` VALUES (1, NULL, 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif?imageView2/1/w/80/h/80', NULL, NULL, 1, 'Admin');
+
+-- ----------------------------
+-- Table structure for role
+-- ----------------------------
+DROP TABLE IF EXISTS `role`;
+CREATE TABLE `role`  (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+  `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+  `enable` tinyint(4) NOT NULL DEFAULT 1,
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE INDEX `IDX_ee999bb389d7ac0fd967172c41`(`code`) USING BTREE,
+  UNIQUE INDEX `IDX_ae4578dcaed5adff96595e6166`(`name`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
+
+-- ----------------------------
+-- Records of role
+-- ----------------------------
+INSERT INTO `role` VALUES (1, 'SUPER_ADMIN', '超级管理员', 1);
+INSERT INTO `role` VALUES (2, 'ROLE_QA', '质检员', 1);
+
+-- ----------------------------
+-- Table structure for role_permissions_permission
+-- ----------------------------
+DROP TABLE IF EXISTS `role_permissions_permission`;
+CREATE TABLE `role_permissions_permission`  (
+  `roleId` int(11) NOT NULL,
+  `permissionId` int(11) NOT NULL,
+  PRIMARY KEY (`roleId`, `permissionId`) USING BTREE,
+  INDEX `IDX_b36cb2e04bc353ca4ede00d87b`(`roleId`) USING BTREE,
+  INDEX `IDX_bfbc9e263d4cea6d7a8c9eb3ad`(`permissionId`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
+
+-- ----------------------------
+-- Records of role_permissions_permission
+-- ----------------------------
+INSERT INTO `role_permissions_permission` VALUES (2, 1);
+INSERT INTO `role_permissions_permission` VALUES (2, 2);
+INSERT INTO `role_permissions_permission` VALUES (2, 3);
+INSERT INTO `role_permissions_permission` VALUES (2, 4);
+INSERT INTO `role_permissions_permission` VALUES (2, 5);
+INSERT INTO `role_permissions_permission` VALUES (2, 9);
+INSERT INTO `role_permissions_permission` VALUES (2, 10);
+INSERT INTO `role_permissions_permission` VALUES (2, 11);
+INSERT INTO `role_permissions_permission` VALUES (2, 12);
+INSERT INTO `role_permissions_permission` VALUES (2, 14);
+INSERT INTO `role_permissions_permission` VALUES (2, 15);
+
+-- ----------------------------
+-- Table structure for user
+-- ----------------------------
+DROP TABLE IF EXISTS `user`;
+CREATE TABLE `user`  (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+  `enable` tinyint(4) NOT NULL DEFAULT 1,
+  `createTime` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+  `updateTime` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE INDEX `IDX_78a916df40e02a9deb1c4b75ed`(`username`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
+
+-- ----------------------------
+-- Records of user
+-- ----------------------------
+INSERT INTO `user` VALUES (1, 'admin', '$2a$10$FsAafxTTVVGXfIkJqvaiV.1vPfq4V9HW298McPldJgO829PR52a56', 1, '2023-11-18 16:18:59.150632', '2023-11-18 16:18:59.150632');
+
+-- ----------------------------
+-- Table structure for user_roles_role
+-- ----------------------------
+DROP TABLE IF EXISTS `user_roles_role`;
+CREATE TABLE `user_roles_role`  (
+  `userId` int(11) NOT NULL,
+  `roleId` int(11) NOT NULL,
+  PRIMARY KEY (`userId`, `roleId`) USING BTREE,
+  INDEX `IDX_5f9286e6c25594c6b88c108db7`(`userId`) USING BTREE,
+  INDEX `IDX_4be2f7adf862634f5f803d246b`(`roleId`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
+
+-- ----------------------------
+-- Records of user_roles_role
+-- ----------------------------
+INSERT INTO `user_roles_role` VALUES (1, 1);
+INSERT INTO `user_roles_role` VALUES (1, 2);
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 11 - 0
packages/backend/nest-cli.json

@@ -0,0 +1,11 @@
+{
+  "$schema": "https://json.schemastore.org/nest-cli",
+  "collection": "@nestjs/schematics",
+  "sourceRoot": "src",
+  "compilerOptions": {
+    "deleteOutDir": true,
+    "plugins": [
+      "@nestjs/swagger"
+    ]
+  }
+}

File diff suppressed because it is too large
+ 9730 - 0
packages/backend/package-lock.json


+ 66 - 0
packages/backend/package.json

@@ -0,0 +1,66 @@
+{
+  "name": "nest-jwt-typeorm",
+  "version": "0.0.1",
+  "description": "",
+  "author": "",
+  "private": true,
+  "license": "UNLICENSED",
+  "scripts": {
+    "build": "nest build && ncc build ./dist/main.js -o ncc-dist",
+    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
+    "start": "nest start",
+    "start:dev": "nest start --watch",
+    "start:debug": "nest start --debug --watch",
+    "start:prod": "node dist/main",
+    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
+  },
+  "dependencies": {
+    "@nestjs/common": "^10.0.0",
+    "@nestjs/config": "^3.1.1",
+    "@nestjs/core": "^10.0.0",
+    "@nestjs/jwt": "^10.1.1",
+    "@nestjs/mapped-types": "^2.0.2",
+    "@nestjs/passport": "^10.0.2",
+    "@nestjs/platform-express": "^10.0.0",
+    "@nestjs/swagger": "^8.1.0",
+    "@nestjs/typeorm": "^10.0.0",
+    "bcryptjs": "^2.4.3",
+    "class-transformer": "^0.5.1",
+    "class-validator": "^0.14.0",
+    "express-session": "^1.17.3",
+    "mysql2": "^3.6.3",
+    "passport-jwt": "^4.0.1",
+    "passport-local": "^1.0.0",
+    "path-to-regexp": "^6.2.1",
+    "redis": "^4.6.11",
+    "reflect-metadata": "^0.1.13",
+    "rxjs": "^7.8.1",
+    "svg-captcha": "^1.4.0",
+    "typeorm": "^0.3.17"
+  },
+  "devDependencies": {
+    "@nestjs/cli": "^10.0.0",
+    "@nestjs/schematics": "^10.0.0",
+    "@nestjs/testing": "^10.0.0",
+    "@types/bcryptjs": "^2.4.5",
+    "@types/express": "^4.17.17",
+    "@types/jest": "^29.5.2",
+    "@types/node": "^20.3.1",
+    "@types/supertest": "^2.0.12",
+    "@typescript-eslint/eslint-plugin": "^6.0.0",
+    "@typescript-eslint/parser": "^6.0.0",
+    "@vercel/ncc": "^0.38.1",
+    "eslint": "^8.42.0",
+    "eslint-config-prettier": "^9.0.0",
+    "eslint-plugin-prettier": "^5.0.0",
+    "jest": "^29.5.0",
+    "prettier": "^3.0.0",
+    "source-map-support": "^0.5.21",
+    "supertest": "^6.3.3",
+    "ts-jest": "^29.1.0",
+    "ts-loader": "^9.4.3",
+    "ts-node": "^10.9.1",
+    "tsconfig-paths": "^4.2.0",
+    "typescript": "^5.1.3"
+  }
+}

File diff suppressed because it is too large
+ 6765 - 0
packages/backend/pnpm-lock.yaml


+ 40 - 0
packages/backend/src/app.module.ts

@@ -0,0 +1,40 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:30:08
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Module } from '@nestjs/common';
+import { SharedModule } from './shared/shared.module';
+import { ConfigModule } from '@nestjs/config';
+import { UserModule } from './modules/user/user.module';
+import { PermissionModule } from './modules/permission/permission.module';
+import { RoleModule } from './modules/role/role.module';
+import { AuthModule } from './modules/auth/auth.module';
+import { ArticleModule } from './modules/article/article.module';
+import { CategoryModule } from './modules/category/category.module';
+
+@Module({
+  imports: [
+    /* 配置文件模块 */
+    ConfigModule.forRoot({
+      isGlobal: true,
+      envFilePath: ['.env.local', '.env'],
+    }),
+
+    UserModule,
+    PermissionModule,
+    RoleModule,
+    AuthModule,
+
+    SharedModule,
+
+    ArticleModule,
+
+    CategoryModule,
+  ],
+  controllers: [],
+})
+export class AppModule {}

+ 12 - 0
packages/backend/src/common/decorators/return-type.decorator.ts

@@ -0,0 +1,12 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:24:11
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { SetMetadata } from '@nestjs/common';
+import { ReturnType as Type } from '@/types';
+
+export const ReturnType = (returnType: Type) => SetMetadata('returnType', returnType);

+ 11 - 0
packages/backend/src/common/decorators/roles.decorator.ts

@@ -0,0 +1,11 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:24:17
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { SetMetadata } from '@nestjs/common';
+
+export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

+ 25 - 0
packages/backend/src/common/exceptions/custom.exception.ts

@@ -0,0 +1,25 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:24:23
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { HttpException, HttpStatus } from '@nestjs/common';
+import { ERR } from './error-code';
+import { ErrInfo } from './error-code';
+
+/**
+ * 自定义异常类
+ */
+export class CustomException extends HttpException {
+  protected code: number;
+  constructor(err: ErrInfo, message?: string, status?: HttpStatus) {
+    message = message ?? err.message ?? String(err.code);
+    super(message, status ?? HttpStatus.BAD_REQUEST);
+    this.code = err.code;
+  }
+}
+
+export { ERR as ErrorCode };

+ 35 - 0
packages/backend/src/common/exceptions/error-code.ts

@@ -0,0 +1,35 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:24:36
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+type ValueOf<T> = T[keyof T];
+
+export const ERR = {
+  // 登录相关
+  ERR_10000: { code: 10000, message: '参数校验异常' },
+  ERR_10001: { code: 10001, message: '用户已存在' },
+  ERR_10002: { code: 10002, message: '用户名或密码错误' },
+  ERR_10003: { code: 10003, message: '验证码错误' },
+  ERR_10004: { code: 10004, message: '密码验证失败' },
+  // token相关
+  ERR_11001: { code: 11001, message: '登录无效或无权限访问' },
+  ERR_11002: { code: 11002, message: '登录已过期' },
+  ERR_11003: { code: 11003, message: '请联系管理员申请权限' },
+  ERR_11004: { code: 11004, message: '越权操作' },
+  ERR_11005: { code: 11005, message: '当前用户无此角色' },
+  ERR_11006: { code: 11006, message: '非法操作' },
+  ERR_11007: { code: 11007, message: '用户已禁用' },
+  ERR_11008: { code: 11008, message: '角色已禁用' },
+  // OSS相关
+  ERR_20001: { code: 20001, message: '当前创建的文件或目录已存在' },
+  ERR_20002: { code: 20002, message: '无需操作' },
+  ERR_20003: { code: 20003, message: '已超出支持的最大处理数量' },
+  // 环境相关
+  ERR_30001: { code: 30001, message: '预览环境不支持此操作' },
+} as const;
+
+export type ErrInfo = ValueOf<typeof ERR>;

+ 29 - 0
packages/backend/src/common/filters/all-exception.filter.ts

@@ -0,0 +1,29 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:24:42
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
+import { Request, Response } from 'express';
+
+@Catch()
+export class AllExceptionFilter implements ExceptionFilter {
+  catch(exception: any, host: ArgumentsHost) {
+    const ctx = host.switchToHttp();
+    const response = ctx.getResponse<Response>();
+    const request = ctx.getRequest<Request>();
+    const exceptionResponse = exception.getResponse?.();
+
+    const status =
+      exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
+    response.status(status).json({
+      code: exception.code ?? status,
+      error: exception.name,
+      message: exceptionResponse?.message || exception.message,
+      originUrl: request.originalUrl,
+    });
+  }
+}

+ 4 - 0
packages/backend/src/common/guards/index.ts

@@ -0,0 +1,4 @@
+export { JwtGuard } from './jwt.guard';
+export { LocalGuard } from './local.guard';
+export { RoleGuard } from './role.guard';
+export { PreviewGuard } from './preview.guard';

+ 15 - 0
packages/backend/src/common/guards/jwt.guard.ts

@@ -0,0 +1,15 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:24:51
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { AuthGuard } from '@nestjs/passport';
+
+export class JwtGuard extends AuthGuard('jwt') {
+  constructor() {
+    super();
+  }
+}

+ 15 - 0
packages/backend/src/common/guards/local.guard.ts

@@ -0,0 +1,15 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:24:58
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { AuthGuard } from '@nestjs/passport';
+
+export class LocalGuard extends AuthGuard('local') {
+  constructor() {
+    super();
+  }
+}

+ 29 - 0
packages/backend/src/common/guards/permission.guard.ts

@@ -0,0 +1,29 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:25:04
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { CustomException, ErrorCode } from '@/common/exceptions/custom.exception';
+
+@Injectable()
+export class PermissionGuard implements CanActivate {
+  constructor(private reflector: Reflector) {}
+
+  canActivate(context: ExecutionContext): boolean {
+    const request = context.switchToHttp().getRequest();
+    const { user } = request;
+    // 当前角色不在可操作角色范围内
+    if (!user.currentRoleCode) throw new CustomException(ErrorCode.ERR_11005);
+
+    const roles = this.reflector.get<string[]>('roles', context.getHandler());
+    if (!roles?.length) return true;
+    const hasRole = roles.includes(user.currentRoleCode);
+    if (!hasRole) throw new CustomException(ErrorCode.ERR_11003);
+    return true;
+  }
+}

+ 22 - 0
packages/backend/src/common/guards/preview.guard.ts

@@ -0,0 +1,22 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:25:11
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { CanActivate, Injectable } from '@nestjs/common';
+import { CustomException, ErrorCode } from '@/common/exceptions/custom.exception';
+import { ConfigService } from '@nestjs/config';
+
+@Injectable()
+export class PreviewGuard implements CanActivate {
+  constructor(protected configService: ConfigService) {}
+
+  canActivate(): boolean {
+    if (this.configService.get('IS_PREVIEW') === 'true')
+      throw new CustomException(ErrorCode.ERR_30001);
+    return true;
+  }
+}

+ 29 - 0
packages/backend/src/common/guards/role.guard.ts

@@ -0,0 +1,29 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:25:17
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { CustomException, ErrorCode } from '@/common/exceptions/custom.exception';
+
+@Injectable()
+export class RoleGuard implements CanActivate {
+  constructor(private reflector: Reflector) {}
+
+  canActivate(context: ExecutionContext): boolean {
+    const request = context.switchToHttp().getRequest();
+    const { user } = request;
+    const currentRoleCode = user.currentRoleCode;
+    const roles = this.reflector.get<string[]>('roles', context.getHandler());
+    // 当前用户没有角色
+    if (!currentRoleCode) throw new CustomException(ErrorCode.ERR_11005);
+    if (!roles?.length) return true;
+    // 当前角色不在可操作角色范围内
+    if (!roles.includes(currentRoleCode)) throw new CustomException(ErrorCode.ERR_11003);
+    return true;
+  }
+}

+ 40 - 0
packages/backend/src/common/interceptors/transform.interceptor.ts

@@ -0,0 +1,40 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:25:23
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { Request } from 'express';
+import { Observable, map } from 'rxjs';
+import { ReturnType } from '@/types';
+
+@Injectable()
+export class TransformInterceptor implements NestInterceptor {
+  constructor(private reflector: Reflector) {}
+  intercept(
+    context: ExecutionContext,
+    next: CallHandler<any>,
+  ): Observable<any> | Promise<Observable<any>> {
+    const returnType = this.reflector.get<ReturnType>('returnType', context.getHandler());
+    const req = context.getArgByIndex(1).req as Request;
+    return next.handle().pipe(
+      map((data) => {
+        switch (returnType) {
+          case 'primitive':
+            return data;
+          default:
+            return {
+              code: 0,
+              message: 'OK',
+              data,
+              originUrl: req.originalUrl,
+            };
+        }
+      }),
+    );
+  }
+}

+ 11 - 0
packages/backend/src/constants/redis.contant.ts

@@ -0,0 +1,11 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:25:29
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+export const USER_ACCESS_TOKEN_KEY = 'user_access_token';
+
+export const ACCESS_TOKEN_EXPIRATION_TIME = 3600 * 24 * 3;

+ 42 - 0
packages/backend/src/main.ts

@@ -0,0 +1,42 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:30:13
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { NestFactory } from '@nestjs/core';
+import { AppModule } from './app.module';
+import * as session from 'express-session';
+import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
+
+async function bootstrap() {
+  const app = await NestFactory.create(AppModule);
+  app.use(
+    session({
+      secret: 'isme',
+      name: 'isme.session',
+      rolling: true,
+      cookie: { maxAge: null },
+      resave: false,
+      saveUninitialized: true,
+    }),
+  );
+
+  const config = new DocumentBuilder()
+    .setTitle('API Docs')
+    .setDescription('The API description')
+    .setVersion('1.0')
+    // .addTag('api')
+    .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'JWT')
+    .build();
+
+  const documentFactory = () => SwaggerModule.createDocument(app, config);
+  SwaggerModule.setup('api', app, documentFactory);
+
+  await app.listen(process.env.APP_PORT || 8085);
+
+  console.log(`🚀 启动成功: http://localhost:${process.env.APP_PORT}`);
+}
+bootstrap();

+ 28 - 0
packages/backend/src/modules/article/article.controller.ts

@@ -0,0 +1,28 @@
+import { JwtGuard } from '@/common/guards';
+import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { ArticleService } from './article.service';
+import { CreateArticleDto, GetArticleDto, QueryArticleDto } from './dto';
+
+@Controller('article')
+@ApiTags('article')
+@ApiBearerAuth('JWT')
+@UseGuards(JwtGuard)
+export class ArticleController {
+  constructor(private readonly articleService: ArticleService) {}
+
+  @Post()
+  create(@Body() createArticleDto: CreateArticleDto) {
+    return this.articleService.create(createArticleDto);
+  }
+
+  @Get()
+  getAll(@Param() getArticleDto: GetArticleDto) {
+    return this.articleService.findAll(getArticleDto);
+  }
+
+  @Get('page')
+  findPagination(@Query() queryDto: QueryArticleDto) {
+    return this.articleService.findPagination(queryDto);
+  }
+}

+ 63 - 0
packages/backend/src/modules/article/article.entity.ts

@@ -0,0 +1,63 @@
+import {
+  Column,
+  CreateDateColumn,
+  Entity,
+  JoinColumn,
+  JoinTable,
+  ManyToMany,
+  OneToOne,
+  PrimaryGeneratedColumn,
+  UpdateDateColumn,
+} from 'typeorm';
+import { Category } from '../category/category.entity';
+import { User } from '../user/user.entity';
+
+@Entity()
+export class Article {
+  @PrimaryGeneratedColumn()
+  id: number;
+
+  @Column({ unique: true, length: 200 })
+  title: string;
+
+  @Column({ default: true })
+  enable: boolean;
+
+  @Column({ default: false })
+  isShow: boolean;
+
+  @Column({ type: 'longtext' })
+  content: string;
+
+  @Column({ default: '' })
+  remark: string;
+
+  @OneToOne(() => Category, {
+    cascade: true,
+  })
+  @JoinColumn()
+  category: Category;
+
+  @OneToOne(() => User, {
+    cascade: true,
+  })
+  @JoinColumn()
+  user: User;
+
+  @CreateDateColumn()
+  createTime: Date;
+
+  @UpdateDateColumn()
+  updateTime: Date;
+
+  // @ManyToMany(() => User, (user) => user.roles, {
+  //   createForeignKeyConstraints: false,
+  // })
+  // users: User[];
+
+  // @ManyToMany(() => Permission, (permission) => permission.roles, {
+  //   createForeignKeyConstraints: false,
+  // })
+  // @JoinTable()
+  // permissions: Permission[];
+}

+ 11 - 0
packages/backend/src/modules/article/article.module.ts

@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common';
+import { ArticleService } from './article.service';
+import { ArticleController } from './article.controller';
+import { Article } from './article.entity';
+import { TypeOrmModule } from '@nestjs/typeorm';
+@Module({
+  imports: [TypeOrmModule.forFeature([Article])],
+  providers: [ArticleService],
+  controllers: [ArticleController],
+})
+export class ArticleModule {}

+ 66 - 0
packages/backend/src/modules/article/article.service.ts

@@ -0,0 +1,66 @@
+import { Injectable } from '@nestjs/common';
+import { Article } from './article.entity';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Like, Repository } from 'typeorm';
+import { CreateArticleDto, GetArticleDto, QueryArticleDto } from './dto';
+@Injectable()
+export class ArticleService {
+  constructor(
+    @InjectRepository(Article)
+    private articleRepo: Repository<Article>,
+  ) {}
+  async create(createArticleDto: CreateArticleDto) {
+    const article = this.articleRepo.create(createArticleDto);
+    return this.articleRepo.save(article);
+  }
+
+  async findAll(query: GetArticleDto) {
+    const pageSize = query.pageSize || 10;
+    const pageNo = query.pageNo || 1;
+    const [categories, total] = await this.articleRepo.findAndCount({
+      where: {
+        title: Like(`%${query.title || ''}%`),
+        enable: query.enable || undefined,
+      },
+      order: {
+        createTime: 'ASC',
+      },
+      take: pageSize,
+      skip: (pageNo - 1) * pageSize,
+    });
+    const pageData = categories.map((item) => {
+      const newItem = {
+        ...item,
+      };
+      return newItem;
+    });
+
+    return { pageData, total };
+  }
+
+  async findPagination(query: QueryArticleDto) {
+    const pageSize = query.pageSize || 10;
+    const pageNo = query.pageNo || 1;
+    const [data, total] = await this.articleRepo.findAndCount({
+      where: {
+        title: Like(`%${query.title || ''}%`),
+        enable: query.enable || undefined,
+      },
+      relations: { user: true, category: true },
+      order: {
+        title: 'DESC',
+      },
+      take: pageSize,
+      skip: (pageNo - 1) * pageSize,
+    });
+    const pageData = data.map((item) => {
+      return { ...item };
+    });
+    return { pageData, total };
+  }
+
+  async remove(id: number) {
+    await this.articleRepo.delete(id);
+    return true;
+  }
+}

+ 65 - 0
packages/backend/src/modules/article/dto.ts

@@ -0,0 +1,65 @@
+import { ApiProperty, ApiBody, PartialType } from '@nestjs/swagger';
+import { Exclude } from 'class-transformer';
+import {
+  Allow,
+  IsArray,
+  IsBoolean,
+  IsNotEmpty,
+  IsNumber,
+  IsOptional,
+  IsString,
+  Length,
+} from 'class-validator';
+
+export class CreateArticleDto {
+  @ApiProperty()
+  @IsString()
+  @IsNotEmpty({ message: '标题不能为空' })
+  @Length(1, 200, {
+    message: `用户名长度必须大于$constraint1到$constraint2之间,当前传递的值是$value`,
+  })
+  title: string;
+
+  @ApiProperty({ required: true })
+  @IsString()
+  content: string;
+
+  @ApiProperty({ required: true })
+  @IsNumber()
+  userId: number;
+
+  @ApiProperty({ required: true })
+  @IsNumber()
+  categoryId: number;
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  isShow?: boolean;
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  enable?: boolean;
+}
+
+export class GetArticleDto {
+  @ApiProperty({ required: false })
+  @Allow()
+  pageSize?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  pageNo?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  title?: string;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  enable?: boolean;
+}
+
+export class QueryArticleDto extends GetArticleDto {}
+
+export class UpdateArticleDto extends PartialType(CreateArticleDto) {}

+ 90 - 0
packages/backend/src/modules/auth/auth.controller.ts

@@ -0,0 +1,90 @@
+import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
+import { AuthService } from './auth.service';
+import { JwtGuard, LocalGuard, PreviewGuard } from '@/common/guards';
+import { ChangePasswordDto, RegisterUserDto, LoginUserDto } from './dto';
+import { UserService } from '@/modules/user/user.service';
+import * as svgCaptcha from 'svg-captcha';
+import { CustomException, ErrorCode } from '@/common/exceptions/custom.exception';
+import { ConfigService } from '@nestjs/config';
+import { ApiBearerAuth } from '@nestjs/swagger';
+
+@Controller('auth')
+export class AuthController {
+  constructor(
+    private readonly authService: AuthService,
+    private userService: UserService,
+    private configService: ConfigService,
+  ) {}
+
+  @UseGuards(LocalGuard)
+  @Post('login')
+  async login(@Req() req: any, @Body() body: LoginUserDto) {
+    // 预览环境下可快速登录,不用验证码
+    if (this.configService.get('IS_PREVIEW') === 'true' && body.isQuick) {
+      return this.authService.login(req.user, req.session?.code);
+    }
+    // 判断验证码是否正确
+    if (req.session?.code?.toLocaleLowerCase() !== body.captcha?.toLocaleLowerCase()) {
+      throw new CustomException(ErrorCode.ERR_10003);
+    }
+
+    return this.authService.login(req.user, req.session?.code);
+  }
+
+  @Post('register')
+  @UseGuards(PreviewGuard)
+  async register(@Body() user: RegisterUserDto) {
+    return this.userService.create(user);
+  }
+
+  @Get('refresh/token')
+  @ApiBearerAuth('JWT')
+  @UseGuards(JwtGuard)
+  async refreshToken(@Req() req: any) {
+    return this.authService.generateToken(req.user);
+  }
+
+  @Post('current-role/switch/:roleCode')
+  @ApiBearerAuth('JWT')
+  @UseGuards(JwtGuard)
+  async switchCurrentRole(@Req() req: any, @Param('roleCode') roleCode: string) {
+    return this.authService.switchCurrentRole(req.user, roleCode);
+  }
+
+  @Post('logout')
+  @ApiBearerAuth('JWT')
+  @UseGuards(JwtGuard)
+  async logout(@Req() req: any) {
+    return this.authService.logout(req.user);
+  }
+
+  @Get('captcha')
+  async createCaptcha(@Req() req, @Res() res) {
+    const captcha = svgCaptcha.create({
+      size: 4,
+      fontSize: 40,
+      width: 80,
+      height: 40,
+      background: '#fff',
+      color: true,
+    });
+    req.session.code = captcha.text || '';
+    res.type('image/svg+xml');
+    res.send(captcha.data);
+  }
+
+  @Post('password')
+  @ApiBearerAuth('JWT')
+  @UseGuards(JwtGuard, PreviewGuard)
+  async changePassword(@Req() req: any, @Body() body: ChangePasswordDto) {
+    const ret = await this.authService.validateUser(req.user.username, body.oldPassword);
+    if (!ret) {
+      throw new CustomException(ErrorCode.ERR_10004);
+    }
+    // 修改密码
+    await this.userService.resetPassword(req.user.id, body.newPassword);
+    // 修改密码后退出登录
+    await this.authService.logout(req.user);
+    return true;
+  }
+}

+ 34 - 0
packages/backend/src/modules/auth/auth.module.ts

@@ -0,0 +1,34 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:25:41
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Module } from '@nestjs/common';
+import { AuthService } from './auth.service';
+import { AuthController } from './auth.controller';
+import { PassportModule } from '@nestjs/passport';
+import { JwtModule } from '@nestjs/jwt';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+import { LocalStrategy } from './local.strategy';
+import { JwtStrategy } from './jwt.strategy';
+
+@Module({
+  imports: [
+    PassportModule,
+    JwtModule.registerAsync({
+      imports: [ConfigModule],
+      inject: [ConfigService],
+      useFactory: async (configService: ConfigService) => {
+        return {
+          secret: process.env.JWT_SECRET || configService.get('JWT_SECRET'),
+        };
+      },
+    }),
+  ],
+  controllers: [AuthController],
+  providers: [AuthService, LocalStrategy, JwtStrategy],
+})
+export class AuthModule {}

+ 92 - 0
packages/backend/src/modules/auth/auth.service.ts

@@ -0,0 +1,92 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:25:48
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Injectable } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { compareSync } from 'bcryptjs';
+import { UserService } from '@/modules/user/user.service';
+import { CustomException, ErrorCode } from '@/common/exceptions/custom.exception';
+import { RedisService } from '@/shared/redis.service';
+import { ACCESS_TOKEN_EXPIRATION_TIME, USER_ACCESS_TOKEN_KEY } from '@/constants/redis.contant';
+import { ConfigService } from '@nestjs/config';
+
+@Injectable()
+export class AuthService {
+  constructor(
+    private userService: UserService,
+    private jwtService: JwtService,
+    private redisService: RedisService,
+    private configService: ConfigService,
+  ) {}
+
+  async validateUser(username: string, password: string) {
+    const user = await this.userService.findByUsername(username);
+    if (user && compareSync(password, user.password)) {
+      const { password, ...result } = user;
+      return result;
+    }
+    return null;
+  }
+
+  async login(user: any, captcha?: string) {
+    // 判断用户是否有enable属性为true的角色
+    if (!user.roles?.some((item) => item.enable)) {
+      throw new CustomException(ErrorCode.ERR_11003);
+    }
+    const roleCodes = user.roles?.map((item) => item.code);
+    const currentRole = user.roles[0];
+    const payload = {
+      userId: user.id,
+      username: user.username,
+      roleCodes,
+      currentRoleCode: currentRole.code,
+    };
+    if (this.configService.get('IS_PREVIEW') === 'true') payload['captcha'] = captcha;
+    return this.generateToken(payload);
+  }
+
+  generateToken(payload: any) {
+    const accessToken = this.jwtService.sign(payload);
+    this.redisService.set(
+      this.getAccessTokenKey(payload),
+      accessToken,
+      ACCESS_TOKEN_EXPIRATION_TIME,
+    );
+    return {
+      accessToken,
+    };
+  }
+
+  async switchCurrentRole(payload: any, roleCode: string) {
+    const user = await this.userService.findByUsername(payload.username);
+    if (!user.roles?.some((item) => item.enable)) {
+      throw new CustomException(ErrorCode.ERR_11003);
+    }
+    const roleCodes = user.roles.map((item) => item.code);
+    const currentRole = user.roles.find((item) => item.code === roleCode);
+    if (!currentRole) {
+      throw new CustomException(ErrorCode.ERR_11005, '您目前暂无此角色,请联系管理员申请权限');
+    }
+    payload = { ...payload, roleCodes, currentRoleCode: currentRole.code };
+    return this.generateToken(payload);
+  }
+
+  async logout(user: any) {
+    if (user.userId) {
+      await Promise.all([this.redisService.del(this.getAccessTokenKey(user))]);
+      return true;
+    }
+    return false;
+  }
+
+  getAccessTokenKey(payload: any) {
+    return `${USER_ACCESS_TOKEN_KEY}:${payload.userId}${
+      payload.captcha ? ':' + payload.captcha : ''
+    }`;
+  }
+}

+ 72 - 0
packages/backend/src/modules/auth/dto.ts

@@ -0,0 +1,72 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:25:55
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { ApiProperty } from '@nestjs/swagger';
+import { IsBoolean, IsNotEmpty, IsOptional, IsString, Length } from 'class-validator';
+
+export class LoginDto {
+  @ApiProperty({ required: true })
+  @IsString()
+  @IsNotEmpty({ message: '用户名不能为空' })
+  username: string;
+
+  @ApiProperty({ required: true })
+  @IsString()
+  @IsNotEmpty({ message: '密码不能为空' })
+  password: string;
+}
+
+export class RegisterUserDto {
+  @ApiProperty({ required: true })
+  @IsString()
+  @Length(6, 20, {
+    message: `用户名长度必须是$constraint1到$constraint2之间,当前传递的值是$value`,
+  })
+  username: string;
+
+  @ApiProperty({ required: true })
+  @IsString()
+  @Length(6, 20, { message: `密码长度必须是$constraint1到$constraint2之间` })
+  password: string;
+}
+
+export class LoginUserDto {
+  @ApiProperty({ required: true })
+  @IsString()
+  @Length(5, 20, {
+    message: `用户名长度必须是$constraint1到$constraint2之间,当前传递的值是$value`,
+  })
+  username: string;
+
+  @ApiProperty({ required: true })
+  @IsString()
+  @Length(6, 20, { message: `密码长度必须是$constraint1到$constraint2之间` })
+  password: string;
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  isQuick?: boolean;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsOptional()
+  captcha?: string;
+}
+
+export class ChangePasswordDto {
+  @ApiProperty({ required: true })
+  @IsString()
+  @IsNotEmpty({ message: '旧密码不能为空' })
+  oldPassword: string;
+
+  @ApiProperty({ required: true })
+  @IsString()
+  @IsNotEmpty({ message: '新密码不能为空' })
+  newPassword: string;
+}

+ 71 - 0
packages/backend/src/modules/auth/jwt.strategy.ts

@@ -0,0 +1,71 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:26:02
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { CustomException, ErrorCode } from '@/common/exceptions/custom.exception';
+import { ACCESS_TOKEN_EXPIRATION_TIME, USER_ACCESS_TOKEN_KEY } from '@/constants/redis.contant';
+import { RedisService } from '@/shared/redis.service';
+import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { PassportStrategy } from '@nestjs/passport';
+import { ExtractJwt, Strategy } from 'passport-jwt';
+import { UserService } from '../user/user.service';
+import { AuthService } from './auth.service';
+
+@Injectable()
+export class JwtStrategy extends PassportStrategy(Strategy) {
+  constructor(
+    protected configService: ConfigService,
+    private redisService: RedisService,
+    private userService: UserService,
+    private authService: AuthService,
+  ) {
+    super({
+      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+      secretOrKey: process.env.JWT_SECRET || configService.get('JWT_SECRET'),
+      ignoreExpiration: false,
+      passReqToCallback: true,
+    });
+  }
+
+  async validate(req, payload: any) {
+    const user = await this.userService.findByUsername(payload.username);
+    if (!user.enable) {
+      throw new CustomException(ErrorCode.ERR_11007);
+    }
+    const currentRole = user.roles.find((item) => item.code === payload.currentRoleCode);
+    if (!currentRole.enable) {
+      throw new CustomException(ErrorCode.ERR_11008);
+    }
+
+    // 从请求头中提取JWT
+    const reqToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
+    // 从Redis中获取用户访问令牌
+    const accessToken = await this.redisService.get(this.authService.getAccessTokenKey(payload));
+
+    // 如果请求令牌不等于访问令牌
+    if (reqToken !== accessToken) {
+      this.redisService.del(this.authService.getAccessTokenKey(payload));
+      throw new HttpException(ErrorCode.ERR_11002, HttpStatus.UNAUTHORIZED);
+    }
+
+    // 延长token过期时间
+    this.redisService.set(
+      this.authService.getAccessTokenKey(payload),
+      accessToken,
+      ACCESS_TOKEN_EXPIRATION_TIME,
+    );
+
+    return {
+      userId: payload.userId,
+      username: payload.username,
+      roleCodes: payload.roleCodes || [],
+      currentRoleCode: payload.currentRoleCode,
+      captcha: payload.captcha,
+    };
+  }
+}

+ 26 - 0
packages/backend/src/modules/auth/local.strategy.ts

@@ -0,0 +1,26 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:26:09
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Injectable } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+import { Strategy } from 'passport-local';
+import { AuthService } from './auth.service';
+import { CustomException, ErrorCode } from '@/common/exceptions/custom.exception';
+
+@Injectable()
+export class LocalStrategy extends PassportStrategy(Strategy) {
+  constructor(private readonly authSerevice: AuthService) {
+    super();
+  }
+
+  async validate(username: string, password: string): Promise<any> {
+    const user = await this.authSerevice.validateUser(username, password);
+    if (!user) throw new CustomException(ErrorCode.ERR_10002);
+    return user;
+  }
+}

+ 43 - 0
packages/backend/src/modules/category/category.controller.ts

@@ -0,0 +1,43 @@
+import { JwtGuard } from '@/common/guards';
+import {
+  Body,
+  Controller,
+  Delete,
+  Get,
+  Param,
+  Patch,
+  Post,
+  Query,
+  UseGuards,
+} from '@nestjs/common';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { CategoryService } from './category.service';
+import { CreateCategoryDto, GetCategoryDto, UpdateCategoryDto } from './dto';
+
+@Controller('category')
+@ApiTags('category')
+@ApiBearerAuth('JWT')
+@UseGuards(JwtGuard)
+export class CategoryController {
+  constructor(private readonly categoryService: CategoryService) {}
+
+  @Post()
+  create(@Body() createCategoryDto: CreateCategoryDto) {
+    return this.categoryService.create(createCategoryDto);
+  }
+
+  @Get()
+  getAllCategories(@Query() getCategoryDto: GetCategoryDto) {
+    return this.categoryService.findAll(getCategoryDto);
+  }
+
+  @Delete(':id')
+  remove(@Param('id') id: string) {
+    return this.categoryService.remove(+id);
+  }
+
+  @Patch(':id')
+  update(@Param('id') id: string, @Body() updateCategoryDto: UpdateCategoryDto) {
+    return this.categoryService.update(+id, updateCategoryDto);
+  }
+}

+ 57 - 0
packages/backend/src/modules/category/category.entity.ts

@@ -0,0 +1,57 @@
+import {
+  Column,
+  CreateDateColumn,
+  Entity,
+  JoinTable,
+  ManyToMany,
+  ManyToOne,
+  OneToMany,
+  OneToOne,
+  PrimaryGeneratedColumn,
+  UpdateDateColumn,
+} from 'typeorm';
+
+@Entity()
+export class Category {
+  @PrimaryGeneratedColumn()
+  id: number;
+
+  @Column({ nullable: true })
+  parentId: number;
+
+  @Column({ unique: false, length: 200 })
+  title: string;
+
+  @Column({ default: true })
+  enable: boolean;
+
+  @Column({ default: '' })
+  remark: string;
+
+  @ManyToOne(() => Category, (category) => category.children, {
+    createForeignKeyConstraints: false,
+  })
+  parent: Category;
+
+  @OneToMany(() => Category, (category) => category.parent, {
+    createForeignKeyConstraints: false,
+  })
+  children: Category[];
+
+  @CreateDateColumn()
+  createTime: Date;
+
+  @UpdateDateColumn()
+  updateTime: Date;
+
+  // @ManyToMany(() => User, (user) => user.roles, {
+  //   createForeignKeyConstraints: false,
+  // })
+  // users: User[];
+
+  // @ManyToMany(() => Permission, (permission) => permission.roles, {
+  //   createForeignKeyConstraints: false,
+  // })
+  // @JoinTable()
+  // permissions: Permission[];
+}

+ 11 - 0
packages/backend/src/modules/category/category.module.ts

@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common';
+import { CategoryController } from './category.controller';
+import { CategoryService } from './category.service';
+import { Category } from './category.entity';
+import { TypeOrmModule } from '@nestjs/typeorm';
+@Module({
+  imports: [TypeOrmModule.forFeature([Category])],
+  controllers: [CategoryController],
+  providers: [CategoryService],
+})
+export class CategoryModule {}

+ 53 - 0
packages/backend/src/modules/category/category.service.ts

@@ -0,0 +1,53 @@
+import { BadRequestException, Injectable } from '@nestjs/common';
+import { Category } from './category.entity';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Like, Repository } from 'typeorm';
+import { CreateCategoryDto, GetCategoryDto, UpdateCategoryDto } from './dto';
+@Injectable()
+export class CategoryService {
+  constructor(
+    @InjectRepository(Category)
+    private categoryRepo: Repository<Category>,
+  ) {}
+
+  async create(createCategoryDto: CreateCategoryDto) {
+    const category = this.categoryRepo.create(createCategoryDto);
+    return this.categoryRepo.save(category);
+  }
+
+  async findAll(query: GetCategoryDto) {
+    const pageSize = query.pageSize || 10;
+    const pageNo = query.pageNo || 1;
+    const [categories, total] = await this.categoryRepo.findAndCount({
+      where: {
+        title: Like(`%${query.title || ''}%`),
+        enable: query.enable || undefined,
+      },
+      order: {
+        createTime: 'ASC',
+      },
+      take: pageSize,
+      skip: (pageNo - 1) * pageSize,
+    });
+    const pageData = categories.map((item) => {
+      const newItem = {
+        ...item,
+      };
+      return newItem;
+    });
+
+    return { pageData, total };
+  }
+
+  async remove(id: number) {
+    await this.categoryRepo.delete(id);
+    return true;
+  }
+
+  async update(id: number, updateCategoryDto: UpdateCategoryDto) {
+    const category = await this.categoryRepo.findOne({ where: { id } });
+    if (!category) throw new BadRequestException('权限不存在或者已删除');
+    await this.categoryRepo.save(updateCategoryDto);
+    return true;
+  }
+}

+ 52 - 0
packages/backend/src/modules/category/dto.ts

@@ -0,0 +1,52 @@
+import { ApiProperty, ApiBody, PartialType } from '@nestjs/swagger';
+import { Exclude } from 'class-transformer';
+import {
+  Allow,
+  IsArray,
+  IsBoolean,
+  IsNotEmpty,
+  IsNumber,
+  IsOptional,
+  IsString,
+  Length,
+} from 'class-validator';
+
+export class CreateCategoryDto {
+  @ApiProperty()
+  @IsString()
+  @IsNotEmpty({ message: '标题不能为空' })
+  @Length(1, 200, {
+    message: `用户名长度必须大于$constraint1到$constraint2之间,当前传递的值是$value`,
+  })
+  title: string;
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  enable?: boolean;
+
+  @ApiProperty({ required: false })
+  @IsNumber()
+  @IsOptional()
+  parentId?: number;
+}
+
+export class GetCategoryDto {
+  @ApiProperty({ required: false })
+  @Allow()
+  pageSize?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  pageNo?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  title?: string;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  enable?: boolean;
+}
+
+export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}

+ 93 - 0
packages/backend/src/modules/permission/dto.ts

@@ -0,0 +1,93 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:26:16
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { PartialType } from '@nestjs/mapped-types';
+import { Exclude } from 'class-transformer';
+import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
+import { MethodType, PermissionType } from '@/types';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class CreatePermissionDto {
+  @ApiProperty()
+  @IsString()
+  name: string;
+
+  @ApiProperty()
+  @IsString()
+  code: string;
+
+  @ApiProperty()
+  @IsString()
+  type: PermissionType;
+
+  @ApiProperty({ required: false })
+  @IsNumber()
+  @IsOptional()
+  parentId?: number;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsOptional()
+  path?: string;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsOptional()
+  redirect?: string;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsOptional()
+  icon?: string;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsOptional()
+  component?: string;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsOptional()
+  layout?: string;
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  keepAlive?: boolean;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsOptional()
+  method?: MethodType;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsOptional()
+  description?: string;
+
+  @ApiProperty({ required: false })
+  @IsNumber()
+  @IsOptional()
+  order?: number;
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  show?: boolean;
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  enable?: boolean;
+}
+
+export class UpdatePermissionDto extends PartialType(CreatePermissionDto) {
+  @ApiProperty()
+  @Exclude()
+  type: PermissionType;
+}

+ 78 - 0
packages/backend/src/modules/permission/permission.controller.ts

@@ -0,0 +1,78 @@
+import {
+  Controller,
+  Get,
+  Post,
+  Body,
+  Patch,
+  Param,
+  Delete,
+  UseGuards,
+  Query,
+} from '@nestjs/common';
+import { PermissionService } from './permission.service';
+import { CreatePermissionDto, UpdatePermissionDto } from './dto';
+import { JwtGuard, PreviewGuard } from '@/common/guards';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+
+@UseGuards(JwtGuard)
+@Controller('permission')
+@ApiTags('permission')
+@ApiBearerAuth('JWT')
+export class PermissionController {
+  constructor(private readonly permissionService: PermissionService) {}
+
+  @Post()
+  @UseGuards(PreviewGuard)
+  create(@Body() createPermissionDto: CreatePermissionDto) {
+    return this.permissionService.create(createPermissionDto);
+  }
+
+  @Post('batch')
+  @UseGuards(PreviewGuard)
+  batchCreate(@Body() createPermissionDtos: CreatePermissionDto[]) {
+    return this.permissionService.batchCreate(createPermissionDtos);
+  }
+
+  @Get()
+  findAll() {
+    return this.permissionService.findAll();
+  }
+
+  @Get('tree')
+  findAllTree() {
+    return this.permissionService.findAllTree();
+  }
+
+  @Get('menu/tree')
+  findMenuTree() {
+    return this.permissionService.findMenuTree();
+  }
+
+  @Get(':id')
+  findOne(@Param('id') id: string) {
+    return this.permissionService.findOne(+id);
+  }
+
+  @Patch(':id')
+  @UseGuards(PreviewGuard)
+  update(@Param('id') id: string, @Body() updatePermissionDto: UpdatePermissionDto) {
+    return this.permissionService.update(+id, updatePermissionDto);
+  }
+
+  @Delete(':id')
+  @UseGuards(PreviewGuard)
+  remove(@Param('id') id: string) {
+    return this.permissionService.remove(+id);
+  }
+
+  @Get('button/:parentId')
+  findButton(@Param('parentId') parentId: string) {
+    return this.permissionService.findButton(+parentId);
+  }
+
+  /* 校验 path 存不存在menu资源内  */
+  @Get('menu/validate')
+  validateMenuPath(@Query('path') path: string) {
+    return this.permissionService.validateMenuPath(path);
+  }
+}

+ 77 - 0
packages/backend/src/modules/permission/permission.entity.ts

@@ -0,0 +1,77 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:26:30
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Column, Entity, ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
+import { Role } from '@/modules/role/role.entity';
+import { MethodType, PermissionType } from '@/types';
+
+@Entity()
+export class Permission {
+  @PrimaryGeneratedColumn()
+  id: number;
+
+  @Column()
+  name: string;
+
+  @Column({ unique: true, length: 50 })
+  code: string;
+
+  @Column()
+  type: PermissionType;
+
+  @ManyToOne(() => Permission, (permission) => permission.children, {
+    createForeignKeyConstraints: false,
+  })
+  parent: Permission;
+
+  @OneToMany(() => Permission, (permission) => permission.parent, {
+    createForeignKeyConstraints: false,
+  })
+  children: Permission[];
+
+  @Column({ nullable: true })
+  parentId: number;
+
+  @Column({ nullable: true })
+  path: string;
+
+  @Column({ nullable: true })
+  redirect: string;
+
+  @Column({ nullable: true })
+  icon: string;
+
+  @Column({ nullable: true })
+  component: string;
+
+  @Column({ nullable: true })
+  layout: string;
+
+  @Column({ nullable: true })
+  keepAlive: boolean;
+
+  @Column({ nullable: true })
+  method: MethodType;
+
+  @Column({ nullable: true })
+  description: string;
+
+  @Column({ default: true, comment: '是否展示在页面菜单' })
+  show: boolean;
+
+  @Column({ default: true })
+  enable: boolean;
+
+  @Column({ nullable: true })
+  order: number;
+
+  @ManyToMany(() => Role, (role) => role.permissions, {
+    createForeignKeyConstraints: false,
+  })
+  roles: Role[];
+}

+ 20 - 0
packages/backend/src/modules/permission/permission.module.ts

@@ -0,0 +1,20 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:26:36
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Module } from '@nestjs/common';
+import { PermissionService } from './permission.service';
+import { PermissionController } from './permission.controller';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Permission } from './permission.entity';
+
+@Module({
+  imports: [TypeOrmModule.forFeature([Permission])],
+  controllers: [PermissionController],
+  providers: [PermissionService],
+})
+export class PermissionModule {}

+ 88 - 0
packages/backend/src/modules/permission/permission.service.ts

@@ -0,0 +1,88 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:26:42
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { BadRequestException, Injectable } from '@nestjs/common';
+import { CreatePermissionDto, UpdatePermissionDto } from './dto';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Permission } from './permission.entity';
+import { In, Repository } from 'typeorm';
+import { SharedService } from '@/shared/shared.service';
+import { pathToRegexp } from 'path-to-regexp';
+
+@Injectable()
+export class PermissionService {
+  constructor(
+    private readonly sharedService: SharedService,
+    @InjectRepository(Permission)
+    private permissionRepo: Repository<Permission>,
+  ) {}
+  create(createPermissionDto: CreatePermissionDto) {
+    const createPermission = this.permissionRepo.create(createPermissionDto);
+    return this.permissionRepo.save(createPermission);
+  }
+
+  batchCreate(createPermissionDtos: CreatePermissionDto[]) {
+    const permissions = this.permissionRepo.create(createPermissionDtos);
+    return this.permissionRepo.save(permissions);
+  }
+
+  findAll() {
+    return this.permissionRepo.find({ where: { type: 'MENU' } });
+  }
+
+  async findAllTree() {
+    const permissions = await this.permissionRepo.find({ order: { order: 'ASC' } });
+    return this.sharedService.handleTree(permissions);
+  }
+
+  async findMenuTree() {
+    const permissions = await this.permissionRepo.find({
+      where: { type: 'MENU' },
+      order: { order: 'ASC' },
+    });
+    return this.sharedService.handleTree(permissions);
+  }
+
+  findOne(id: number) {
+    return this.permissionRepo.findOne({ where: { id } });
+  }
+
+  async update(id: number, updatePermissionDto: UpdatePermissionDto) {
+    const permission = await this.permissionRepo.findOne({ where: { id } });
+    if (!permission) throw new BadRequestException('权限不存在或者已删除');
+    const newPermission = this.permissionRepo.merge(permission, updatePermissionDto);
+    await this.permissionRepo.save(newPermission);
+    return true;
+  }
+
+  // TODO 递归删除所有子孙权限
+  async remove(id: number) {
+    const permission = await this.permissionRepo.findOne({
+      where: { id },
+      relations: { roles: true },
+    });
+    if (!permission) throw new BadRequestException('权限不存在或者已删除');
+    if (permission.roles?.length)
+      throw new BadRequestException('当前权限存在已授权的角色,不允许删除!');
+    await this.permissionRepo.remove(permission);
+    return true;
+  }
+
+  findButton(parentId: number) {
+    return this.permissionRepo.find({
+      where: { parentId, type: In(['BUTTON']) },
+    });
+  }
+
+  async validateMenuPath(path: string) {
+    const allMenu = await this.permissionRepo.find({
+      where: { type: 'MENU' },
+    });
+    return allMenu.some((menu) => menu.path && pathToRegexp(menu.path).test(path))
+  }
+}

+ 106 - 0
packages/backend/src/modules/role/dto.ts

@@ -0,0 +1,106 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:26:53
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { ApiProperty } from '@nestjs/swagger';
+import { Exclude } from 'class-transformer';
+import { Allow, IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional } from 'class-validator';
+
+export class CreateRoleDto {
+  // @ApiProperty({ required: false })
+  @ApiProperty()
+  @IsNotEmpty({ message: '角色编码不能为空' })
+  code: string;
+
+  @ApiProperty()
+  @IsNotEmpty({ message: '角色名不能为空' })
+  name: string;
+
+  @ApiProperty({ required: false })
+  @IsOptional()
+  @IsArray()
+  permissionIds: number[];
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  enable?: boolean;
+}
+
+export class GetRolesDto {
+  @ApiProperty({ required: false })
+  @IsOptional()
+  enable?: boolean;
+}
+
+export class UpdateRoleDto {
+  @ApiProperty()
+  @Exclude()
+  code: string;
+
+  @ApiProperty({ required: false })
+  @IsOptional()
+  name?: string;
+
+  @ApiProperty({ required: false })
+  @IsOptional()
+  @IsArray()
+  permissionIds?: number[];
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  enable?: boolean;
+}
+
+export class AddRolePermissionsDto {
+  @ApiProperty()
+  @IsNumber()
+  id: number;
+
+  @ApiProperty()
+  @IsArray()
+  permissionIds: number[];
+}
+
+export class AddRoleUsersDto {
+  @ApiProperty()
+  @IsArray()
+  userIds: number[];
+}
+
+export class AddRoleButtonsDto {
+  @ApiProperty()
+  @IsNumber()
+  id: number;
+
+  @ApiProperty()
+  @IsNumber()
+  menuId: number;
+
+  @ApiProperty()
+  @IsArray()
+  buttons: string[];
+}
+
+export class QueryRoleDto {
+  @ApiProperty({ required: false })
+  @Allow()
+  pageSize?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  pageNo?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  name?: string;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  enable?: boolean;
+}

+ 110 - 0
packages/backend/src/modules/role/role.controller.ts

@@ -0,0 +1,110 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:27:04
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import {
+  Controller,
+  Get,
+  Post,
+  Body,
+  Patch,
+  Param,
+  Delete,
+  UseGuards,
+  Query,
+  Request,
+} from '@nestjs/common';
+import { RoleService } from './role.service';
+import {
+  AddRolePermissionsDto,
+  AddRoleUsersDto,
+  CreateRoleDto,
+  GetRolesDto,
+  QueryRoleDto,
+  UpdateRoleDto,
+} from './dto';
+import { JwtGuard, PreviewGuard, RoleGuard } from '@/common/guards';
+import { Roles } from '@/common/decorators/roles.decorator';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+
+@Controller('role')
+@ApiTags('role')
+@ApiBearerAuth('JWT')
+@UseGuards(JwtGuard, RoleGuard)
+export class RoleController {
+  constructor(private readonly roleService: RoleService) {}
+
+  @Post()
+  @UseGuards(PreviewGuard)
+  @Roles('SUPER_ADMIN')
+  create(@Body() createRoleDto: CreateRoleDto) {
+    return this.roleService.create(createRoleDto);
+  }
+
+  @Get()
+  findAll(@Query() query: GetRolesDto) {
+    return this.roleService.findAll(query);
+  }
+
+  @Get('page')
+  findPagination(@Query() queryDto: QueryRoleDto) {
+    return this.roleService.findPagination(queryDto);
+  }
+
+  @Get('permissions')
+  findRolePermissions(@Query('id') id: number) {
+    return this.roleService.findRolePermissions(+id);
+  }
+
+  @Get(':id')
+  @Roles('SUPER_ADMIN')
+  findOne(@Param('id') id: string) {
+    return this.roleService.findOne(+id);
+  }
+
+  @Patch(':id')
+  @UseGuards(PreviewGuard)
+  @Roles('SUPER_ADMIN', 'SYS_ADMIN', 'ROLE_PMS')
+  update(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleDto) {
+    return this.roleService.update(+id, updateRoleDto);
+  }
+
+  @Delete(':id')
+  @UseGuards(PreviewGuard)
+  @Roles('SUPER_ADMIN')
+  remove(@Param('id') id: number) {
+    return this.roleService.remove(+id);
+  }
+
+  @Post('permissions/add')
+  @UseGuards(PreviewGuard)
+  @Roles('SUPER_ADMIN')
+  addRolePermissions(@Body() dto: AddRolePermissionsDto) {
+    return this.roleService.addRolePermissions(dto);
+  }
+
+  @Get('permissions/tree')
+  findRolePermissionsTree(@Request() req: any) {
+    return this.roleService.findRolePermissionsTree(req.user.currentRoleCode);
+  }
+
+  // 给角色分配用户
+  @Patch('users/add/:roleId')
+  @UseGuards(PreviewGuard)
+  @Roles('SUPER_ADMIN')
+  addRoleUsers(@Param('roleId') roleId: string, @Body() dto: AddRoleUsersDto) {
+    return this.roleService.addRoleUsers(+roleId, dto);
+  }
+
+  // 给角色取消分配用户
+  @Patch('users/remove/:roleId')
+  @UseGuards(PreviewGuard)
+  @Roles('SUPER_ADMIN')
+  removeRoleUsers(@Param('roleId') roleId: string, @Body() dto: AddRoleUsersDto) {
+    return this.roleService.removeRoleUsers(+roleId, dto);
+  }
+}

+ 37 - 0
packages/backend/src/modules/role/role.entity.ts

@@ -0,0 +1,37 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:27:11
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';
+import { User } from '@/modules/user/user.entity';
+import { Permission } from '@/modules/permission/permission.entity';
+
+@Entity()
+export class Role {
+  @PrimaryGeneratedColumn()
+  id: number;
+
+  @Column({ unique: true, length: 50 })
+  code: string;
+
+  @Column({ unique: true, length: 50 })
+  name: string;
+
+  @Column({ default: true })
+  enable: boolean;
+
+  @ManyToMany(() => User, (user) => user.roles, {
+    createForeignKeyConstraints: false,
+  })
+  users: User[];
+
+  @ManyToMany(() => Permission, (permission) => permission.roles, {
+    createForeignKeyConstraints: false,
+  })
+  @JoinTable()
+  permissions: Permission[];
+}

+ 22 - 0
packages/backend/src/modules/role/role.module.ts

@@ -0,0 +1,22 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:28:12
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Module } from '@nestjs/common';
+import { RoleService } from './role.service';
+import { RoleController } from './role.controller';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Role } from './role.entity';
+import { Permission } from '@/modules/permission/permission.entity';
+import { User } from '@/modules/user/user.entity';
+
+@Module({
+  imports: [TypeOrmModule.forFeature([Role, Permission, User])],
+  controllers: [RoleController],
+  providers: [RoleService],
+})
+export class RoleModule {}

+ 162 - 0
packages/backend/src/modules/role/role.service.ts

@@ -0,0 +1,162 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:28:20
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { BadRequestException, Injectable } from '@nestjs/common';
+import {
+  AddRolePermissionsDto,
+  AddRoleUsersDto,
+  CreateRoleDto,
+  GetRolesDto,
+  QueryRoleDto,
+  UpdateRoleDto,
+} from './dto';
+import { InjectRepository } from '@nestjs/typeorm';
+import { In, Like, Repository } from 'typeorm';
+import { Role } from './role.entity';
+import { Permission } from '@/modules/permission/permission.entity';
+import { SharedService } from '@/shared/shared.service';
+import { User } from '@/modules/user/user.entity';
+
+@Injectable()
+export class RoleService {
+  constructor(
+    private readonly sharedService: SharedService,
+    @InjectRepository(Role) private roleRepo: Repository<Role>,
+    @InjectRepository(Permission)
+    private permissionRepo: Repository<Permission>,
+    @InjectRepository(User) private userRepo: Repository<User>,
+  ) {}
+  async create(createRoleDto: CreateRoleDto) {
+    const existRole = await this.roleRepo.findOne({
+      where: [{ name: createRoleDto.name }, { code: createRoleDto.code }],
+    });
+    if (existRole) throw new BadRequestException('角色已存在(角色名和角色编码不能重复)');
+    const role = this.roleRepo.create(createRoleDto);
+    if (createRoleDto.permissionIds) {
+      role.permissions = await this.permissionRepo.find({
+        where: { id: In(createRoleDto.permissionIds) },
+      });
+    }
+    return this.roleRepo.save(role);
+  }
+
+  async findAll(query: GetRolesDto) {
+    return this.roleRepo.find({ where: query });
+  }
+
+  async findPagination(query: QueryRoleDto) {
+    const pageSize = query.pageSize || 10;
+    const pageNo = query.pageNo || 1;
+    const [data, total] = await this.roleRepo.findAndCount({
+      where: {
+        name: Like(`%${query.name || ''}%`),
+        enable: query.enable || undefined,
+      },
+      relations: { permissions: true },
+      order: {
+        name: 'DESC',
+      },
+      take: pageSize,
+      skip: (pageNo - 1) * pageSize,
+    });
+    const pageData = data.map((item) => {
+      const permissionIds = item.permissions.map((p) => p.id);
+      delete item.permissions;
+      return { ...item, permissionIds };
+    });
+    return { pageData, total };
+  }
+
+  findOne(id: number) {
+    return this.roleRepo.findOne({ where: { id } });
+  }
+
+  async findRolePermissionsTree(code: string) {
+    const role = await this.roleRepo.findOne({ where: { code } });
+    if (!role) throw new BadRequestException('当前角色不存在或者已删除');
+    const permissions = await this.permissionRepo.find({
+      where: role.code === 'SUPER_ADMIN' ? undefined : { roles: [role] },
+    });
+    return this.sharedService.handleTree(permissions);
+  }
+
+  async findRolePermissions(id: number) {
+    const role = await this.findOne(id);
+    if (!role) throw new BadRequestException('当前角色不存在或者已删除');
+    return this.permissionRepo.find({ where: { roles: [role] } });
+  }
+
+  async update(id: number, updateRoleDto: UpdateRoleDto) {
+    const role = await this.findOne(id);
+    if (!role) throw new BadRequestException('角色不存在或者已删除');
+    if (role.code === 'SUPER_ADMIN') throw new BadRequestException('不允许修改超级管理员');
+    const newRole = this.roleRepo.merge(role, updateRoleDto);
+    if (updateRoleDto.permissionIds) {
+      newRole.permissions = await this.permissionRepo.find({
+        where: { id: In(updateRoleDto.permissionIds) },
+      });
+    }
+    await this.roleRepo.save(newRole);
+    return true;
+  }
+
+  async remove(id: number) {
+    const role = await this.roleRepo.findOne({
+      where: { id },
+      relations: { users: true },
+    });
+    if (!role) throw new BadRequestException('角色不存在或者已删除');
+    if (role.code === 'SUPER_ADMIN') throw new BadRequestException('不允许删除超级管理员');
+    if (role.users?.length) throw new BadRequestException('当前角色存在已授权的用户,不允许删除!');
+    await this.roleRepo.remove(role);
+    return true;
+  }
+
+  async addRolePermissions(dto: AddRolePermissionsDto) {
+    const { permissionIds, id } = dto;
+    const role = await this.roleRepo.findOne({
+      where: { id },
+      relations: { permissions: true },
+    });
+    if (!role) throw new BadRequestException('角色不存在或者已删除');
+    if (role.code === 'SUPER_ADMIN') throw new BadRequestException('无需给超级管理员授权');
+    const permissions = await this.permissionRepo.find({
+      where: permissionIds.map((item) => ({ id: item })),
+    });
+    role.permissions = role.permissions
+      .filter((item) => !permissionIds.includes(item.id))
+      .concat(permissions);
+    await this.roleRepo.save(role);
+    return true;
+  }
+
+  async addRoleUsers(id: number, dto: AddRoleUsersDto) {
+    const { userIds } = dto;
+    const role = await this.roleRepo.findOne({
+      where: { id },
+      relations: { users: true },
+    });
+    if (!role) throw new BadRequestException('角色不存在或者已删除');
+    const users = await this.userRepo.find({ where: { id: In(userIds) } });
+    role.users = role.users.filter((item) => !userIds.includes(item.id)).concat(users);
+    await this.roleRepo.save(role);
+    return true;
+  }
+
+  async removeRoleUsers(id: number, dto: AddRoleUsersDto) {
+    const { userIds } = dto;
+    const role = await this.roleRepo.findOne({
+      where: { id },
+      relations: { users: true },
+    });
+    if (!role) throw new BadRequestException('角色不存在或者已删除');
+    role.users = role.users.filter((item) => !userIds.includes(item.id));
+    await this.roleRepo.save(role);
+    return true;
+  }
+}

+ 136 - 0
packages/backend/src/modules/user/dto.ts

@@ -0,0 +1,136 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:28:27
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { ApiProperty, ApiBody } from '@nestjs/swagger';
+import { Exclude } from 'class-transformer';
+import {
+  Allow,
+  IsArray,
+  IsBoolean,
+  IsNotEmpty,
+  IsOptional,
+  IsString,
+  Length,
+} from 'class-validator';
+export class Profile {
+  @ApiProperty()
+  @Allow()
+  nickName: string;
+  @ApiProperty()
+  @Allow()
+  gender: number;
+  @ApiProperty()
+  @Allow()
+  avatar: string;
+  @ApiProperty()
+  @Allow()
+  address: string;
+  @ApiProperty()
+  @Allow()
+  email: string;
+}
+
+export class CreateUserDto {
+  @ApiProperty()
+  @IsString()
+  @IsNotEmpty({ message: '用户名不能为空' })
+  @Length(6, 20, {
+    message: `用户名长度必须大于$constraint1到$constraint2之间,当前传递的值是$value`,
+  })
+  username: string;
+
+  @ApiProperty()
+  @IsString()
+  @IsNotEmpty({ message: '密码不能为空' })
+  @Length(6, 20, { message: `密码长度必须大于$constraint1到$constraint2之间` })
+  password: string;
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  enable?: boolean;
+
+  @ApiProperty({ required: false })
+  @IsOptional()
+  profile?: Profile;
+
+  @ApiProperty({ required: false })
+  @IsOptional()
+  @IsArray()
+  roleIds?: number[];
+}
+
+export class UpdateUserDto {
+  @ApiProperty()
+  @Exclude()
+  password: string;
+
+  @ApiProperty({ required: false })
+  @Exclude()
+  profile?: Profile;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsNotEmpty({ message: '用户名不能为空' })
+  @Length(5, 20, {
+    message: `用户名长度必须大于$constraint1到$constraint2之间,当前传递的值是$value`,
+  })
+  @IsOptional()
+  username?: string;
+
+  @ApiProperty({ required: false })
+  @IsBoolean()
+  @IsOptional()
+  enable?: boolean;
+
+  @ApiProperty({ required: false })
+  @IsOptional()
+  @IsArray()
+  roleIds?: number[];
+}
+
+export class UpdateProfileDto extends Profile {}
+
+export class GetUserDto {
+  @ApiProperty({ required: false })
+  @Allow()
+  pageSize?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  pageNo?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  username?: string;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  gender?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  role?: number;
+
+  @ApiProperty({ required: false })
+  @Allow()
+  enable?: boolean;
+}
+
+export class AddUserRolesDto {
+  @ApiProperty()
+  @IsArray()
+  roleIds: number[];
+}
+export class UpdatePasswordDto {
+  @ApiProperty()
+  @IsString()
+  @IsNotEmpty({ message: '密码不能为空' })
+  @Length(6, 20, { message: `密码长度必须大于$constraint1到$constraint2之间` })
+  password: string;
+}

+ 36 - 0
packages/backend/src/modules/user/profile.entity.ts

@@ -0,0 +1,36 @@
+import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { User } from './user.entity';
+
+@Entity()
+export class Profile {
+  @PrimaryGeneratedColumn()
+  id: number;
+
+  @Column({ nullable: true, length: 10 })
+  nickName: string;
+
+  @Column({ nullable: true })
+  gender: number;
+
+  @Column({
+    default:
+      'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif?imageView2/1/w/80/h/80',
+  })
+  avatar: string;
+
+  @Column({ nullable: true })
+  address: string;
+
+  @Column({ nullable: true })
+  email: string;
+
+  @OneToOne(() => User, (user) => user.profile, {
+    createForeignKeyConstraints: false,
+    onDelete: 'CASCADE',
+  })
+  @JoinColumn()
+  user: User;
+
+  @Column({ unique: true })
+  userId: number;
+}

+ 132 - 0
packages/backend/src/modules/user/user.controller.ts

@@ -0,0 +1,132 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:28:41
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import {
+  Body,
+  Controller,
+  Get,
+  Post,
+  Param,
+  Query,
+  Delete,
+  Patch,
+  ParseIntPipe,
+  UseGuards,
+  Request,
+} from '@nestjs/common';
+import { UserService } from './user.service';
+import {
+  AddUserRolesDto,
+  CreateUserDto,
+  GetUserDto,
+  UpdatePasswordDto,
+  UpdateProfileDto,
+  UpdateUserDto,
+} from './dto';
+import { CustomException, ErrorCode } from '@/common/exceptions/custom.exception';
+import { JwtGuard, PreviewGuard, RoleGuard } from '@/common/guards';
+import { Roles } from '@/common/decorators/roles.decorator';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+
+@Controller('user')
+@ApiTags('users')
+@ApiBearerAuth('JWT')
+@UseGuards(JwtGuard, RoleGuard)
+export class UserController {
+  constructor(private readonly userService: UserService) {}
+
+  @Post()
+  @UseGuards(PreviewGuard)
+  @Roles('SUPER_ADMIN')
+  addUser(@Body() user: CreateUserDto) {
+    return this.userService.create(user);
+  }
+
+  @Get()
+  getAllUsers(@Query() queryDto: GetUserDto) {
+    return this.userService.findAll(queryDto);
+  }
+
+  @Delete(':id')
+  @UseGuards(PreviewGuard)
+  @Roles('SUPER_ADMIN')
+  deleteUser(@Param('id') id: number, @Request() req: any) {
+    const currentUser = req.user;
+
+    if (currentUser.userId === id)
+      throw new CustomException(ErrorCode.ERR_11006, '非法操作,不能删除自己!');
+    return this.userService.remove(id);
+  }
+
+  @Patch(':id')
+  @UseGuards(PreviewGuard)
+  @Roles('SUPER_ADMIN', 'SYS_ADMIN')
+  updateUser(@Param('id') id: number, @Body() user: UpdateUserDto) {
+    return this.userService.update(id, user);
+  }
+
+  /**
+   * @desc 修改用户资料
+   */
+  @Patch('/profile/:id')
+  @UseGuards(PreviewGuard)
+  updateProfile(
+    @Body() profile: UpdateProfileDto,
+    @Param('id', ParseIntPipe) id: number,
+    @Request() req: any,
+  ) {
+    const currentUser = req.user;
+    // 只能本人修改
+    if (currentUser.userId !== id)
+      throw new CustomException(ErrorCode.ERR_11004, '越权操作,用户资料只能本人修改!');
+    return this.userService.updateProfile(id, profile);
+  }
+
+  /**
+   * @desc 获取当前登录用户的详情信息
+   */
+  @Get('detail')
+  getUserInfo(@Request() req: any) {
+    const currentUser = req.user;
+    return this.userService.findUserDetail(currentUser.userId, currentUser.currentRoleCode);
+  }
+
+  @Get(':username')
+  @Roles('SUPER_ADMIN')
+  findByUsername(@Param('username') username: string) {
+    return this.userService.findByUsername(username);
+  }
+
+  // 查询用户的profile
+  @Get('profile/:userId')
+  getUserProfile(@Param('userId') userId: number, @Request() req: any) {
+    // 涉及隐私信息,只能本人或者超管查询
+    const currentUser = req.user;
+    // 只能本人或者超管查询
+    if (currentUser.userId === userId || currentUser.roles.includes('SUPER_ADMIN')) {
+      return this.userService.findUserProfile(userId);
+    }
+    throw new CustomException(ErrorCode.ERR_11003);
+  }
+
+  /** 给用户赋角色 */
+  @Post('roles/add/:userId')
+  @Roles('SUPER_ADMIN')
+  @UseGuards(PreviewGuard)
+  addRoles(@Param('userId') userId: number, @Body() dto: AddUserRolesDto) {
+    return this.userService.addRoles(userId, dto.roleIds);
+  }
+
+  /** 管理员重置密码 */
+  @Patch('password/reset/:userId')
+  @Roles('SUPER_ADMIN')
+  @UseGuards(PreviewGuard)
+  resetPassword(@Param('userId') userId: number, @Body() dto: UpdatePasswordDto) {
+    return this.userService.resetPassword(userId, dto.password);
+  }
+}

+ 53 - 0
packages/backend/src/modules/user/user.entity.ts

@@ -0,0 +1,53 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:28:50
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import {
+  Column,
+  CreateDateColumn,
+  Entity,
+  JoinTable,
+  ManyToMany,
+  OneToOne,
+  PrimaryGeneratedColumn,
+  UpdateDateColumn,
+} from 'typeorm';
+import { Profile } from './profile.entity';
+import { Role } from '@/modules/role/role.entity';
+
+@Entity()
+export class User {
+  @PrimaryGeneratedColumn()
+  id: number;
+
+  @Column({ unique: true, length: 50 })
+  username: string;
+
+  @Column({ select: false })
+  password: string;
+
+  @Column({ default: true })
+  enable: boolean;
+
+  @CreateDateColumn()
+  createTime: Date;
+
+  @UpdateDateColumn()
+  updateTime: Date;
+
+  @OneToOne(() => Profile, (profile) => profile.user, {
+    createForeignKeyConstraints: false,
+    cascade: true,
+  })
+  profile: Profile;
+
+  @ManyToMany(() => Role, (role) => role.users, {
+    createForeignKeyConstraints: false,
+  })
+  @JoinTable()
+  roles: Role[];
+}

+ 24 - 0
packages/backend/src/modules/user/user.module.ts

@@ -0,0 +1,24 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:29:00
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Global, Module } from '@nestjs/common';
+import { UserService } from './user.service';
+import { UserController } from './user.controller';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { User } from './user.entity';
+import { Profile } from './profile.entity';
+import { Role } from '@/modules/role/role.entity';
+
+@Global()
+@Module({
+  imports: [TypeOrmModule.forFeature([User, Profile, Role])],
+  controllers: [UserController],
+  providers: [UserService],
+  exports: [UserService],
+})
+export class UserModule {}

+ 179 - 0
packages/backend/src/modules/user/user.service.ts

@@ -0,0 +1,179 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:29:09
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { hashSync } from 'bcryptjs';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { In, Like, Repository } from 'typeorm';
+import { CreateUserDto, GetUserDto, UpdateProfileDto, UpdateUserDto } from './dto';
+import { User } from './user.entity';
+import { Profile } from './profile.entity';
+import { CustomException, ErrorCode } from '@/common/exceptions/custom.exception';
+import { Role } from '@/modules/role/role.entity';
+
+@Injectable()
+export class UserService {
+  constructor(
+    @InjectRepository(User)
+    private userRep: Repository<User>,
+    @InjectRepository(Profile)
+    private profileRep: Repository<Profile>,
+    @InjectRepository(Role) private roleRepo: Repository<Role>,
+  ) {}
+
+  async create(user: CreateUserDto) {
+    const { username } = user;
+    const existUser = await this.findByUsername(username);
+
+    if (existUser) {
+      throw new CustomException(ErrorCode.ERR_10001);
+    }
+
+    const newUser = this.userRep.create(user);
+    if (user.roleIds !== undefined) {
+      newUser.roles = await this.roleRepo.find({
+        where: { id: In(user.roleIds) },
+      });
+    }
+    if (!newUser.profile) {
+      newUser.profile = this.profileRep.create();
+    }
+    newUser.password = hashSync(newUser.password);
+    await this.userRep.save(newUser);
+    return true;
+  }
+
+  async findAll(query: GetUserDto) {
+    const pageSize = query.pageSize || 10;
+    const pageNo = query.pageNo || 1;
+    const [users, total] = await this.userRep.findAndCount({
+      select: {
+        profile: {
+          gender: true,
+          avatar: true,
+          email: true,
+          address: true,
+        },
+        roles: true,
+      },
+      relations: {
+        profile: true,
+        roles: true,
+      },
+      where: {
+        username: Like(`%${query.username || ''}%`),
+        enable: query.enable || undefined,
+        profile: {
+          gender: query.gender || undefined,
+        },
+      },
+      order: {
+        createTime: 'ASC',
+      },
+      take: pageSize,
+      skip: (pageNo - 1) * pageSize,
+    });
+    const pageData = users.map((item) => {
+      const newItem = {
+        ...item,
+        ...item.profile,
+      };
+      delete newItem.profile;
+      return newItem;
+    });
+
+    return { pageData, total };
+  }
+
+  async remove(id: number) {
+    // 不能删除根用户
+    if (id === 1) throw new CustomException(ErrorCode.ERR_11006, '不能删除根用户');
+    await this.userRep.delete(id);
+    await this.profileRep
+      .createQueryBuilder('profile')
+      .delete()
+      .where('profile.userId = :id', { id })
+      .execute();
+    return true;
+  }
+
+  async update(id: number, user: UpdateUserDto) {
+    const findUser = await this.findUserProfile(id);
+    if (user.roleIds !== undefined) {
+      findUser.roles = await this.roleRepo.find({
+        where: { id: In(user.roleIds) },
+      });
+    }
+    const newUser = this.userRep.merge(findUser, user);
+    await this.userRep.save(newUser);
+    return true;
+  }
+
+  async resetPassword(id: number, password: string) {
+    const user = await this.userRep.findOne({ where: { id } });
+    user.password = hashSync(password);
+    await this.userRep.save(user);
+    return true;
+  }
+
+  async updateProfile(id: number, profile: UpdateProfileDto) {
+    const user = await this.findUserProfile(id);
+    user.profile = this.profileRep.merge(user.profile, profile);
+    await this.userRep.save(user);
+    return true;
+  }
+
+  async findByUsername(username: string) {
+    return this.userRep.findOne({
+      where: { username },
+      select: ['id', 'username', 'password', 'enable'],
+      relations: {
+        profile: true,
+        roles: true,
+      },
+    });
+  }
+
+  findUserProfile(id: number) {
+    return this.userRep.findOne({
+      where: { id },
+      relations: {
+        profile: true,
+        roles: true,
+      },
+    });
+  }
+
+  async findUserDetail(id: number, roleCode: string) {
+    const user = await this.userRep.findOne({
+      where: { id },
+      relations: {
+        profile: true,
+        roles: true,
+      },
+    });
+    const currentRole = user.roles?.find((item) => item.code === roleCode && item.enable);
+    if (!currentRole) {
+      throw new CustomException(ErrorCode.ERR_11005, '您目前暂无此角色或已被禁用,请联系管理员');
+    }
+    return { ...user, currentRole };
+  }
+
+  async addRoles(userId: number, roleIds: number[]) {
+    const user = await this.userRep.findOne({
+      where: { id: userId },
+      relations: { roles: true },
+    });
+    const roles = await this.roleRepo.find({
+      where: roleIds.map((item) => ({ id: item })),
+    });
+    user.roles = user.roles.filter((item) => !roleIds.includes(item.id)).concat(roles);
+    await this.userRep.save(user);
+    return true;
+  }
+}

+ 47 - 0
packages/backend/src/shared/redis.service.ts

@@ -0,0 +1,47 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:29:16
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Inject, Injectable } from '@nestjs/common';
+import { RedisClientType } from 'redis';
+
+@Injectable()
+export class RedisService {
+  @Inject('REDIS_CLIENT')
+  private redisClient: RedisClientType;
+
+  async get(key: string) {
+    return await this.redisClient.get(key);
+  }
+
+  async set(key: string, value: string | number, ttl?: number) {
+    await this.redisClient.set(key, value);
+
+    if (ttl) {
+      await this.redisClient.expire(key, ttl);
+    }
+  }
+
+  async del(key: string) {
+    await this.redisClient.del(key);
+    return true;
+  }
+
+  async hashGet(key: string) {
+    return await this.redisClient.hGetAll(key);
+  }
+
+  async hashSet(key: string, obj: Record<string, any>, ttl?: number) {
+    for (const name in obj) {
+      await this.redisClient.hSet(key, name, obj[name]);
+    }
+
+    if (ttl) {
+      await this.redisClient.expire(key, ttl);
+    }
+  }
+}

+ 75 - 0
packages/backend/src/shared/shared.module.ts

@@ -0,0 +1,75 @@
+/**********************************
+ * @Description: 公共模块
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:29:25
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Global, Module, ValidationPipe } from '@nestjs/common';
+import { SharedService } from './shared.service';
+import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
+import { AllExceptionFilter } from '@/common/filters/all-exception.filter';
+import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
+import { ConfigService } from '@nestjs/config';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { RedisService } from './redis.service';
+import { createClient } from 'redis';
+
+@Global()
+@Module({
+  imports: [
+    TypeOrmModule.forRootAsync({
+      inject: [ConfigService],
+      useFactory: (configService: ConfigService) => {
+        return {
+          type: 'mysql',
+          autoLoadEntities: true,
+          host: process.env.DB_HOST || configService.get('DB_HOST'),
+          port: +process.env.DB_PORT || configService.get('DB_PORT'),
+          username: process.env.DB_USER || configService.get('DB_USER'),
+          password: process.env.DB_PWD || configService.get('DB_PWD'),
+          database: process.env.DB_DATABASE || configService.get('DB_DATABASE'),
+          synchronize: process.env.NODE_ENV === 'production' ? false : configService.get('DB_SYNC'),
+          timezone: '+08:00',
+        };
+      },
+    }),
+  ],
+  providers: [
+    SharedService,
+    RedisService,
+    {
+      inject: [ConfigService],
+      provide: 'REDIS_CLIENT',
+      async useFactory(configService: ConfigService) {
+        const client = createClient({
+          url: configService.get('REDIS_URL'),
+        });
+        await client.connect();
+        return client;
+      },
+    },
+    {
+      // 全局错误过滤器
+      provide: APP_FILTER,
+      useClass: AllExceptionFilter,
+    },
+    {
+      // 全局拦截器
+      provide: APP_INTERCEPTOR,
+      useClass: TransformInterceptor,
+    },
+    {
+      //全局参数校验管道
+      provide: APP_PIPE,
+      useValue: new ValidationPipe({
+        whitelist: true,
+        transform: true, // 自动类型转换
+      }),
+    },
+  ],
+  exports: [SharedService, RedisService],
+})
+export class SharedModule {}

+ 59 - 0
packages/backend/src/shared/shared.service.ts

@@ -0,0 +1,59 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:29:41
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { Injectable } from '@nestjs/common';
+
+@Injectable()
+export class SharedService {
+  /**
+   * 构造树型结构数据
+   */
+  public handleTree(data: any[], id?: string, parentId?: string, children?: string) {
+    const config = {
+      id: id || 'id',
+      parentId: parentId || 'parentId',
+      childrenList: children || 'children',
+    };
+
+    const childrenListMap = {};
+    const nodeIds = {};
+    const tree = [];
+
+    for (const d of data) {
+      const parentId = d[config.parentId];
+      if (childrenListMap[parentId] == null) {
+        childrenListMap[parentId] = [];
+      }
+      nodeIds[d[config.id]] = d;
+      childrenListMap[parentId].push(d);
+    }
+
+    for (const d of data) {
+      const parentId = d[config.parentId];
+      if (nodeIds[parentId] == null) {
+        tree.push(d);
+      }
+    }
+
+    for (const t of tree) {
+      adaptToChildrenList(t);
+    }
+
+    function adaptToChildrenList(o) {
+      if (childrenListMap[o[config.id]] !== null) {
+        o[config.childrenList] = childrenListMap[o[config.id]];
+      }
+      if (o[config.childrenList]) {
+        for (const c of o[config.childrenList]) {
+          adaptToChildrenList(c);
+        }
+      }
+    }
+    return tree;
+  }
+}

+ 11 - 0
packages/backend/src/types/index.ts

@@ -0,0 +1,11 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/07 20:29:53
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+export type PermissionType = 'MENU' | 'BUTTON' | 'API';
+export type MethodType = 'GET' | 'POST' | 'PATCH' | 'DELETE';
+export type ReturnType = 'primitive' | '';

+ 4 - 0
packages/backend/tsconfig.build.json

@@ -0,0 +1,4 @@
+{
+  "extends": "./tsconfig.json",
+  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
+}

+ 25 - 0
packages/backend/tsconfig.json

@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "module": "commonjs",
+    "declaration": true,
+    "removeComments": true,
+    "emitDecoratorMetadata": true,
+    "experimentalDecorators": true,
+    "allowSyntheticDefaultImports": true,
+    "target": "ES2021",
+    "sourceMap": true,
+    "outDir": "./dist",
+    "baseUrl": "./",
+    "incremental": true,
+    "skipLibCheck": true,
+    "strictNullChecks": false,
+    "noImplicitAny": false,
+    "strictBindCallApply": false,
+    "forceConsistentCasingInFileNames": false,
+    "noFallthroughCasesInSwitch": false,
+    "paths": {
+      "@/*": ["src/*"],
+      "~/*": ["./*"]
+    }
+  }
+}

+ 9 - 0
packages/frontend/.editorconfig

@@ -0,0 +1,9 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = unset
+insert_final_newline = true
+trim_trailing_whitespace = true

+ 1 - 0
packages/frontend/.env

@@ -0,0 +1 @@
+VITE_TITLE = 'TESTING'

+ 12 - 0
packages/frontend/.env.development

@@ -0,0 +1,12 @@
+# 是否使用Hash路由
+VITE_USE_HASH = 'true'
+
+# 资源公共路径,需要以 /开头和结尾
+VITE_PUBLIC_PATH = '/'
+
+# Axios 基础路径
+VITE_AXIOS_BASE_URL = '/api'  # 用于代理
+# VITE_AXIOS_BASE_URL = 'https://mock.apipark.cn/m1/3776410-0-default'  # apifox云端mock
+
+# 代理配置-target
+VITE_PROXY_TARGET = 'http://localhost:8085'

+ 10 - 0
packages/frontend/.env.production

@@ -0,0 +1,10 @@
+# 是否使用Hash路由
+VITE_USE_HASH = 'false'
+
+# 资源公共路径,需要以 /开头和结尾
+VITE_PUBLIC_PATH = '/'
+
+VITE_AXIOS_BASE_URL = '/api'  # 用于代理
+
+# 代理配置-target
+VITE_PROXY_TARGET = 'http://localhost:8085'

+ 4 - 0
packages/frontend/.gitignore

@@ -0,0 +1,4 @@
+*.local
+node_modules
+dist
+stats.html

+ 3 - 0
packages/frontend/.npmrc

@@ -0,0 +1,3 @@
+registry=https://registry.npmmirror.com
+shamefully-hoist=true
+strict-peer-dependencies=false

+ 21 - 0
packages/frontend/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Ronnie Zhang(大脸怪)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

File diff suppressed because it is too large
+ 88 - 0
packages/frontend/README.md


+ 39 - 0
packages/frontend/build/index.js

@@ -0,0 +1,39 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/04 22:48:02
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import path from 'node:path'
+import { globSync } from 'glob'
+import dynamicIcons from '../src/assets/icons/dynamic-icons.js'
+
+/**
+ * @usage 生成icons, 用于 unocss safelist,以支持页面动态渲染自定义图标
+ */
+export function getIcons() {
+  const feFiles = globSync('src/assets/icons/feather/*.svg', { nodir: true, strict: true })
+  const meFiles = globSync('src/assets/icons/isme/*.svg', { nodir: true, strict: true })
+  const feIcons = feFiles.map((filePath) => {
+    const fileName = path.basename(filePath) // 获取文件名,包括后缀
+    const fileNameWithoutExt = path.parse(fileName).name // 获取去除后缀的文件名
+    return `i-fe:${fileNameWithoutExt}`
+  })
+  const meIcons = meFiles.map((filePath) => {
+    const fileName = path.basename(filePath) // 获取文件名,包括后缀
+    const fileNameWithoutExt = path.parse(fileName).name // 获取去除后缀的文件名
+    return `i-me:${fileNameWithoutExt}`
+  })
+
+  return [...dynamicIcons, ...feIcons, ...meIcons]
+}
+
+/**
+ * @usage 生成.vue文件路径列表,用于添加菜单时可下拉选择对应的.vue文件路径,防止手动输入报错
+ */
+export function getPagePathes() {
+  const files = globSync('src/views/**/*.vue')
+  return files.map(item => `/${path.normalize(item).replace(/\\/g, '/')}`)
+}

+ 25 - 0
packages/frontend/build/plugin-isme/icons.js

@@ -0,0 +1,25 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/04 22:48:11
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { getIcons } from '..'
+
+const PLUGIN_ICONS_ID = 'isme:icons'
+export function pluginIcons() {
+  return {
+    name: 'isme:icons',
+    resolveId(id) {
+      if (id === PLUGIN_ICONS_ID)
+        return `\0${PLUGIN_ICONS_ID}`
+    },
+    load(id) {
+      if (id === `\0${PLUGIN_ICONS_ID}`) {
+        return `export default ${JSON.stringify(getIcons())}`
+      }
+    },
+  }
+}

+ 10 - 0
packages/frontend/build/plugin-isme/index.js

@@ -0,0 +1,10 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/04 22:48:17
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+export { pluginIcons } from './icons'
+export { pluginPagePathes } from './page-pathes'

+ 25 - 0
packages/frontend/build/plugin-isme/page-pathes.js

@@ -0,0 +1,25 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/05 21:37:43
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { getPagePathes } from '..'
+
+const PLUGIN_PAGE_PATHES_ID = 'isme:page-pathes'
+export function pluginPagePathes() {
+  return {
+    name: 'isme:page-pathes',
+    resolveId(id) {
+      if (id === PLUGIN_PAGE_PATHES_ID)
+        return `\0${PLUGIN_PAGE_PATHES_ID}`
+    },
+    load(id) {
+      if (id === `\0${PLUGIN_PAGE_PATHES_ID}`) {
+        return `export default ${JSON.stringify(getPagePathes())}`
+      }
+    },
+  }
+}

+ 34 - 0
packages/frontend/eslint.config.js

@@ -0,0 +1,34 @@
+import antfu from '@antfu/eslint-config'
+
+export default antfu({
+  unocss: true,
+  formatters: true,
+  stylistic: true,
+  rules: {
+    'n/prefer-global/process': 'off',
+    'no-undef': 'error',
+    'no-fallthrough': 'off',
+    'vue/block-order': 'off',
+    '@typescript-eslint/no-this-alias': 'off',
+    'prefer-promise-reject-errors': 'off',
+  },
+  languageOptions: {
+    globals: {
+      h: 'readonly',
+      unref: 'readonly',
+      provide: 'readonly',
+      inject: 'readonly',
+      markRaw: 'readonly',
+      defineAsyncComponent: 'readonly',
+      nextTick: 'readonly',
+      useRoute: 'readonly',
+      useRouter: 'readonly',
+      Message: 'readonly',
+      $loadingBar: 'readonly',
+      $message: 'readonly',
+      $dialog: 'readonly',
+      $notification: 'readonly',
+      $modal: 'readonly',
+    },
+  },
+})

+ 94 - 0
packages/frontend/index.html

@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link rel="icon" href="/favicon.png" />
+    <title>%VITE_TITLE%</title>
+    <style>
+      .loading-container {
+        position: fixed;
+        top: 0;
+        left: 0;
+        width: 100%;
+        height: 100%;
+        display: flex;
+      }
+      .dark .loading-container {
+        background-color: #232324;
+        color: rgba(255, 255, 255, 0.9);
+      }
+
+      .loading-container .loading {
+        --speed-of-animation: 0.9s;
+        --gap: 12px;
+        --first-color: #4c86f9;
+        --second-color: #49a84c;
+        --third-color: #f6bb02;
+        --fourth-color: #26a69a;
+        --fifth-color: #2196f3;
+
+        margin: auto;
+        width: 160px;
+        height: 100px;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        gap: var(--gap);
+      }
+
+      .loading-container .loading span {
+        width: 6px;
+        height: 80px;
+        background: var(--first-color);
+        animation: scale var(--speed-of-animation) ease-in-out infinite;
+      }
+
+      .loading-container .loading span:nth-child(2) {
+        background: var(--second-color);
+        animation-delay: -0.8s;
+      }
+
+      .loading-container .loading span:nth-child(3) {
+        background: var(--third-color);
+        animation-delay: -0.7s;
+      }
+
+      .loading-container .loading span:nth-child(4) {
+        background: var(--fourth-color);
+        animation-delay: -0.6s;
+      }
+
+      .loading-container .loading span:nth-child(5) {
+        background: var(--fifth-color);
+        animation-delay: -0.5s;
+      }
+
+      @keyframes scale {
+        0%,
+        40%,
+        100% {
+          transform: scaleY(0.25);
+        }
+
+        20% {
+          transform: scaleY(1);
+        }
+      }
+    </style>
+  </head>
+  <body class="dark:text-#e9e9e9 auto-bg">
+    <div id="app">
+      <div class="loading-container">
+        <div class="loading">
+          <span></span>
+          <span></span>
+          <span></span>
+          <span></span>
+          <span></span>
+        </div>
+      </div>
+    </div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 14 - 0
packages/frontend/jsconfig.json

@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "baseUrl": "./",
+    "moduleResolution": "node",
+    "paths": {
+      "@/*": ["src/*"],
+      "~/*": ["./*"]
+    },
+    "jsx": "preserve",
+    "allowJs": true
+  },
+  "exclude": ["node_modules", "dist"]
+}

+ 61 - 0
packages/frontend/package.json

@@ -0,0 +1,61 @@
+{
+  "name": "vue-naive-admin",
+  "type": "module",
+  "version": "2.0.0",
+  "private": true,
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview",
+    "lint:fix": "eslint --fix",
+    "postinstall": "npx simple-git-hooks",
+    "up": "taze major -I"
+  },
+  "dependencies": {
+    "@arco-design/color": "^0.4.0",
+    "@vueuse/core": "^12.0.0",
+    "axios": "^1.7.9",
+    "cherry-markdown": "^0.8.57",
+    "dayjs": "^1.11.13",
+    "echarts": "^5.5.1",
+    "lodash-es": "^4.17.21",
+    "naive-ui": "^2.40.3",
+    "pinia": "^2.3.0",
+    "pinia-plugin-persistedstate": "^4.1.3",
+    "vue": "^3.5.13",
+    "vue-echarts": "^7.0.3",
+    "vue-router": "^4.5.0",
+    "xlsx": "^0.18.5"
+  },
+  "devDependencies": {
+    "@antfu/eslint-config": "^3.12.0",
+    "@iconify/json": "^2.2.282",
+    "@unocss/eslint-config": "^0.65.1",
+    "@unocss/eslint-plugin": "^0.65.1",
+    "@unocss/preset-rem-to-px": "^0.65.1",
+    "@vitejs/plugin-vue": "^5.2.1",
+    "@vitejs/plugin-vue-jsx": "^4.1.1",
+    "eslint": "^9.17.0",
+    "eslint-plugin-format": "^0.1.3",
+    "esno": "^4.8.0",
+    "fs-extra": "^11.2.0",
+    "glob": "^11.0.0",
+    "lint-staged": "^15.2.11",
+    "rollup-plugin-visualizer": "^5.12.0",
+    "simple-git-hooks": "^2.11.1",
+    "taze": "^0.18.0",
+    "unocss": "^0.65.1",
+    "unplugin-auto-import": "^0.19.0",
+    "unplugin-vue-components": "0.28.0",
+    "vite": "^6.0.3",
+    "vite-plugin-router-warn": "^1.0.0",
+    "vite-plugin-vue-devtools": "^7.6.8",
+    "vue3-intro-step": "^1.0.5"
+  },
+  "simple-git-hooks": {
+    "pre-commit": "pnpm lint-staged"
+  },
+  "lint-staged": {
+    "*": "eslint --fix"
+  }
+}

File diff suppressed because it is too large
+ 8583 - 0
packages/frontend/pnpm-lock.yaml


BIN
packages/frontend/public/favicon.png


+ 65 - 0
packages/frontend/src/App.vue

@@ -0,0 +1,65 @@
+<!--------------------------------
+ - @Author: Ronnie Zhang
+ - @LastEditor: Ronnie Zhang
+ - @LastEditTime: 2023/12/16 18:49:42
+ - @Email: zclzone@outlook.com
+ - Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ --------------------------------->
+
+<template>
+  <n-config-provider
+    class="wh-full"
+    :locale="zhCN"
+    :date-locale="dateZhCN"
+    :theme="appStore.isDark ? darkTheme : undefined"
+    :theme-overrides="appStore.naiveThemeOverrides"
+  >
+    <router-view v-if="Layout" v-slot="{ Component, route: curRoute }">
+      <component :is="Layout">
+        <transition name="fade-slide" mode="out-in" appear>
+          <KeepAlive :include="keepAliveNames">
+            <component :is="Component" v-if="!tabStore.reloading" :key="curRoute.fullPath" />
+          </KeepAlive>
+        </transition>
+      </component>
+
+      <LayoutSetting v-if="layoutSettingVisible" class="fixed right-12 top-1/2 z-999" />
+    </router-view>
+  </n-config-provider>
+</template>
+
+<script setup>
+import { LayoutSetting } from '@/components'
+import { useAppStore, useTabStore } from '@/store'
+import { darkTheme, dateZhCN, zhCN } from 'naive-ui'
+import { layoutSettingVisible } from './settings'
+
+const layouts = new Map()
+function getLayout(name) {
+  // 利用map将加载过的layout缓存起来,防止重新加载layout导致页面闪烁
+  if (layouts.get(name))
+    return layouts.get(name)
+  const layout = markRaw(defineAsyncComponent(() => import(`@/layouts/${name}/index.vue`)))
+  layouts.set(name, layout)
+  return layout
+}
+
+const route = useRoute()
+const appStore = useAppStore()
+if (appStore.layout === 'default')
+  appStore.setLayout('')
+const Layout = computed(() => {
+  if (!route.matched?.length)
+    return null
+  return getLayout(route.meta?.layout || appStore.layout)
+})
+
+const tabStore = useTabStore()
+const keepAliveNames = computed(() => {
+  return tabStore.tabs.filter(item => item.keepAlive).map(item => item.name)
+})
+
+watchEffect(() => {
+  appStore.setThemeColor(appStore.primaryColor, appStore.isDark)
+})
+</script>

+ 24 - 0
packages/frontend/src/api/index.js

@@ -0,0 +1,24 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/04 22:50:38
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+import { request } from '@/utils'
+
+export default {
+  // 获取用户信息
+  getUser: () => request.get('/user/detail'),
+  // 刷新token
+  refreshToken: () => request.get('/auth/refresh/token'),
+  // 登出
+  logout: () => request.post('/auth/logout', {}, { needTip: false }),
+  // 切换当前角色
+  switchCurrentRole: role => request.post(`/auth/current-role/switch/${role}`),
+  // 获取角色权限
+  getRolePermissions: () => request.get('/role/permissions/tree'),
+  // 验证菜单路径
+  validateMenuPath: path => request.get(`/permission/menu/validate?path=${path}`),
+}

+ 10 - 0
packages/frontend/src/assets/icons/dynamic-icons.js

@@ -0,0 +1,10 @@
+/**********************************
+ * @Author: Ronnie Zhang
+ * @LastEditor: Ronnie Zhang
+ * @LastEditTime: 2023/12/04 22:50:49
+ * @Email: zclzone@outlook.com
+ * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
+ **********************************/
+
+// 需要动态渲染的iconify图标,以i-开头
+export default ['i-simple-icons:juejin']

+ 1 - 0
packages/frontend/src/assets/icons/feather/activity.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-activity"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>

+ 1 - 0
packages/frontend/src/assets/icons/feather/airplay.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-airplay"><path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"></path><polygon points="12 15 17 21 7 21 12 15"></polygon></svg>

+ 1 - 0
packages/frontend/src/assets/icons/feather/alert-circle.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>

+ 1 - 0
packages/frontend/src/assets/icons/feather/alert-octagon.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-octagon"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>

+ 1 - 0
packages/frontend/src/assets/icons/feather/alert-triangle.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>

+ 1 - 0
packages/frontend/src/assets/icons/feather/align-center.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-center"><line x1="18" y1="10" x2="6" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="18" y1="18" x2="6" y2="18"></line></svg>

+ 1 - 0
packages/frontend/src/assets/icons/feather/align-justify.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-justify"><line x1="21" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="3" y2="18"></line></svg>

+ 1 - 0
packages/frontend/src/assets/icons/feather/align-left.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-left"><line x1="17" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="17" y1="18" x2="3" y2="18"></line></svg>

+ 1 - 0
packages/frontend/src/assets/icons/feather/align-right.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-right"><line x1="21" y1="10" x2="7" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="7" y2="18"></line></svg>

+ 1 - 0
packages/frontend/src/assets/icons/feather/anchor.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-anchor"><circle cx="12" cy="5" r="3"></circle><line x1="12" y1="22" x2="12" y2="8"></line><path d="M5 12H2a10 10 0 0 0 20 0h-3"></path></svg>

File diff suppressed because it is too large
+ 1 - 0
packages/frontend/src/assets/icons/feather/aperture.svg


+ 1 - 0
packages/frontend/src/assets/icons/feather/archive.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-archive"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>

+ 0 - 0
packages/frontend/src/assets/icons/feather/arrow-down-circle.svg


Some files were not shown because too many files changed in this diff