Browse Source

feat:管理台vue3版本提交

颜琼丽 3 weeks ago
parent
commit
3f751ce1ca
100 changed files with 10609 additions and 0 deletions
  1. 15 0
      jd-logistics-ui-vue3/src/App.vue
  2. 62 0
      jd-logistics-ui-vue3/src/api/login.js
  3. 9 0
      jd-logistics-ui-vue3/src/api/menu.js
  4. 60 0
      jd-logistics-ui-vue3/src/api/system/config.js
  5. 52 0
      jd-logistics-ui-vue3/src/api/system/dept.js
  6. 52 0
      jd-logistics-ui-vue3/src/api/system/dict/data.js
  7. 60 0
      jd-logistics-ui-vue3/src/api/system/dict/type.js
  8. 33 0
      jd-logistics-ui-vue3/src/api/system/logininfor.js
  9. 60 0
      jd-logistics-ui-vue3/src/api/system/menu.js
  10. 44 0
      jd-logistics-ui-vue3/src/api/system/notice.js
  11. 26 0
      jd-logistics-ui-vue3/src/api/system/operlog.js
  12. 44 0
      jd-logistics-ui-vue3/src/api/system/post.js
  13. 119 0
      jd-logistics-ui-vue3/src/api/system/role.js
  14. 136 0
      jd-logistics-ui-vue3/src/api/system/user.js
  15. 76 0
      jd-logistics-ui-vue3/src/api/tool/gen.js
  16. 97 0
      jd-logistics-ui-vue3/src/components/Breadcrumb/index.vue
  17. 174 0
      jd-logistics-ui-vue3/src/components/Crontab/day.vue
  18. 133 0
      jd-logistics-ui-vue3/src/components/Crontab/hour.vue
  19. 313 0
      jd-logistics-ui-vue3/src/components/Crontab/index.vue
  20. 126 0
      jd-logistics-ui-vue3/src/components/Crontab/min.vue
  21. 141 0
      jd-logistics-ui-vue3/src/components/Crontab/month.vue
  22. 540 0
      jd-logistics-ui-vue3/src/components/Crontab/result.vue
  23. 128 0
      jd-logistics-ui-vue3/src/components/Crontab/second.vue
  24. 197 0
      jd-logistics-ui-vue3/src/components/Crontab/week.vue
  25. 143 0
      jd-logistics-ui-vue3/src/components/Crontab/year.vue
  26. 87 0
      jd-logistics-ui-vue3/src/components/DictTag/index.vue
  27. 276 0
      jd-logistics-ui-vue3/src/components/Editor/index.vue
  28. 255 0
      jd-logistics-ui-vue3/src/components/FileUpload/index.vue
  29. 42 0
      jd-logistics-ui-vue3/src/components/Hamburger/index.vue
  30. 252 0
      jd-logistics-ui-vue3/src/components/HeaderSearch/index.vue
  31. 111 0
      jd-logistics-ui-vue3/src/components/IconSelect/index.vue
  32. 8 0
      jd-logistics-ui-vue3/src/components/IconSelect/requireIcons.js
  33. 84 0
      jd-logistics-ui-vue3/src/components/ImagePreview/index.vue
  34. 253 0
      jd-logistics-ui-vue3/src/components/ImageUpload/index.vue
  35. 105 0
      jd-logistics-ui-vue3/src/components/Pagination/index.vue
  36. 3 0
      jd-logistics-ui-vue3/src/components/ParentView/index.vue
  37. 181 0
      jd-logistics-ui-vue3/src/components/RightToolbar/index.vue
  38. 13 0
      jd-logistics-ui-vue3/src/components/RuoYi/Doc/index.vue
  39. 13 0
      jd-logistics-ui-vue3/src/components/RuoYi/Git/index.vue
  40. 22 0
      jd-logistics-ui-vue3/src/components/Screenfull/index.vue
  41. 45 0
      jd-logistics-ui-vue3/src/components/SizeSelect/index.vue
  42. 53 0
      jd-logistics-ui-vue3/src/components/SvgIcon/index.vue
  43. 10 0
      jd-logistics-ui-vue3/src/components/SvgIcon/svgicon.js
  44. 215 0
      jd-logistics-ui-vue3/src/components/TopNav/index.vue
  45. 31 0
      jd-logistics-ui-vue3/src/components/iFrame/index.vue
  46. 65 0
      jd-logistics-ui-vue3/src/directive/common/copyText.js
  47. 9 0
      jd-logistics-ui-vue3/src/directive/index.js
  48. 27 0
      jd-logistics-ui-vue3/src/directive/permission/hasPermi.js
  49. 27 0
      jd-logistics-ui-vue3/src/directive/permission/hasRole.js
  50. 123 0
      jd-logistics-ui-vue3/src/layout/components/AppMain.vue
  51. 31 0
      jd-logistics-ui-vue3/src/layout/components/Copyright/index.vue
  52. 25 0
      jd-logistics-ui-vue3/src/layout/components/IframeToggle/index.vue
  53. 35 0
      jd-logistics-ui-vue3/src/layout/components/InnerLink/index.vue
  54. 290 0
      jd-logistics-ui-vue3/src/layout/components/Navbar.vue
  55. 315 0
      jd-logistics-ui-vue3/src/layout/components/Settings/index.vue
  56. 40 0
      jd-logistics-ui-vue3/src/layout/components/Sidebar/Link.vue
  57. 102 0
      jd-logistics-ui-vue3/src/layout/components/Sidebar/Logo.vue
  58. 100 0
      jd-logistics-ui-vue3/src/layout/components/Sidebar/SidebarItem.vue
  59. 104 0
      jd-logistics-ui-vue3/src/layout/components/Sidebar/index.vue
  60. 107 0
      jd-logistics-ui-vue3/src/layout/components/TagsView/ScrollPane.vue
  61. 371 0
      jd-logistics-ui-vue3/src/layout/components/TagsView/index.vue
  62. 99 0
      jd-logistics-ui-vue3/src/layout/components/TopBar/index.vue
  63. 4 0
      jd-logistics-ui-vue3/src/layout/components/index.js
  64. 117 0
      jd-logistics-ui-vue3/src/layout/index.vue
  65. 84 0
      jd-logistics-ui-vue3/src/main.js
  66. 69 0
      jd-logistics-ui-vue3/src/permission.js
  67. 60 0
      jd-logistics-ui-vue3/src/plugins/auth.js
  68. 79 0
      jd-logistics-ui-vue3/src/plugins/cache.js
  69. 45 0
      jd-logistics-ui-vue3/src/plugins/download.js
  70. 18 0
      jd-logistics-ui-vue3/src/plugins/index.js
  71. 82 0
      jd-logistics-ui-vue3/src/plugins/modal.js
  72. 71 0
      jd-logistics-ui-vue3/src/plugins/tab.js
  73. 174 0
      jd-logistics-ui-vue3/src/router/index.js
  74. 57 0
      jd-logistics-ui-vue3/src/settings.js
  75. 3 0
      jd-logistics-ui-vue3/src/store/index.js
  76. 46 0
      jd-logistics-ui-vue3/src/store/modules/app.js
  77. 57 0
      jd-logistics-ui-vue3/src/store/modules/dict.js
  78. 127 0
      jd-logistics-ui-vue3/src/store/modules/permission.js
  79. 51 0
      jd-logistics-ui-vue3/src/store/modules/settings.js
  80. 182 0
      jd-logistics-ui-vue3/src/store/modules/tagsView.js
  81. 89 0
      jd-logistics-ui-vue3/src/store/modules/user.js
  82. 29 0
      jd-logistics-ui-vue3/src/utils/auth.js
  83. 24 0
      jd-logistics-ui-vue3/src/utils/dict.js
  84. 14 0
      jd-logistics-ui-vue3/src/utils/dynamicTitle.js
  85. 6 0
      jd-logistics-ui-vue3/src/utils/errorCode.js
  86. 452 0
      jd-logistics-ui-vue3/src/utils/generator/config.js
  87. 18 0
      jd-logistics-ui-vue3/src/utils/generator/css.js
  88. 37 0
      jd-logistics-ui-vue3/src/utils/generator/drawingDefault.js
  89. 359 0
      jd-logistics-ui-vue3/src/utils/generator/html.js
  90. 1 0
      jd-logistics-ui-vue3/src/utils/generator/icon.json
  91. 370 0
      jd-logistics-ui-vue3/src/utils/generator/js.js
  92. 156 0
      jd-logistics-ui-vue3/src/utils/generator/render.js
  93. 390 0
      jd-logistics-ui-vue3/src/utils/index.js
  94. 30 0
      jd-logistics-ui-vue3/src/utils/jsencrypt.js
  95. 51 0
      jd-logistics-ui-vue3/src/utils/permission.js
  96. 153 0
      jd-logistics-ui-vue3/src/utils/request.js
  97. 228 0
      jd-logistics-ui-vue3/src/utils/ruoyi.js
  98. 58 0
      jd-logistics-ui-vue3/src/utils/scroll-to.js
  99. 49 0
      jd-logistics-ui-vue3/src/utils/theme.js
  100. 0 0
      jd-logistics-ui-vue3/src/utils/validate.js

+ 15 - 0
jd-logistics-ui-vue3/src/App.vue

@@ -0,0 +1,15 @@
+<template>
+  <router-view />
+</template>
+
+<script setup>
+import useSettingsStore from '@/store/modules/settings'
+import { handleThemeStyle } from '@/utils/theme'
+
+onMounted(() => {
+  nextTick(() => {
+    // 初始化主题样式
+    handleThemeStyle(useSettingsStore().theme)
+  })
+})
+</script>

+ 62 - 0
jd-logistics-ui-vue3/src/api/login.js

@@ -0,0 +1,62 @@
+import request from '@/utils/request'
+
+// 登录方法
+export function login(username, password, code, uuid) {
+  return request({
+    url: '/auth/login',
+    headers: {
+      isToken: false,
+      repeatSubmit: false
+    },
+    method: 'post',
+    data: { username, password, code, uuid }
+  })
+}
+
+// 注册方法
+export function register(data) {
+  return request({
+    url: '/auth/register',
+    headers: {
+      isToken: false
+    },
+    method: 'post',
+    data: data
+  })
+}
+
+// 刷新方法
+export function refreshToken() {
+  return request({
+    url: '/auth/refresh',
+    method: 'post'
+  })
+}
+
+// 获取用户详细信息
+export function getInfo() {
+  return request({
+    url: '/system/user/getInfo',
+    method: 'get'
+  })
+}
+
+// 退出方法
+export function logout() {
+  return request({
+    url: '/auth/logout',
+    method: 'delete'
+  })
+}
+
+// 获取验证码
+export function getCodeImg() {
+  return request({
+    url: '/code',
+    headers: {
+      isToken: false
+    },
+    method: 'get',
+    timeout: 20000
+  })
+}

+ 9 - 0
jd-logistics-ui-vue3/src/api/menu.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+// 获取路由
+export const getRouters = () => {
+  return request({
+    url: '/system/menu/getRouters',
+    method: 'get'
+  })
+}

+ 60 - 0
jd-logistics-ui-vue3/src/api/system/config.js

@@ -0,0 +1,60 @@
+import request from '@/utils/request'
+
+// 查询参数列表
+export function listConfig(query) {
+  return request({
+    url: '/system/config/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询参数详细
+export function getConfig(configId) {
+  return request({
+    url: '/system/config/' + configId,
+    method: 'get'
+  })
+}
+
+// 根据参数键名查询参数值
+export function getConfigKey(configKey) {
+  return request({
+    url: '/system/config/configKey/' + configKey,
+    method: 'get'
+  })
+}
+
+// 新增参数配置
+export function addConfig(data) {
+  return request({
+    url: '/system/config',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改参数配置
+export function updateConfig(data) {
+  return request({
+    url: '/system/config',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除参数配置
+export function delConfig(configId) {
+  return request({
+    url: '/system/config/' + configId,
+    method: 'delete'
+  })
+}
+
+// 刷新参数缓存
+export function refreshCache() {
+  return request({
+    url: '/system/config/refreshCache',
+    method: 'delete'
+  })
+}

+ 52 - 0
jd-logistics-ui-vue3/src/api/system/dept.js

@@ -0,0 +1,52 @@
+import request from '@/utils/request'
+
+// 查询部门列表
+export function listDept(query) {
+  return request({
+    url: '/system/dept/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询部门列表(排除节点)
+export function listDeptExcludeChild(deptId) {
+  return request({
+    url: '/system/dept/list/exclude/' + deptId,
+    method: 'get'
+  })
+}
+
+// 查询部门详细
+export function getDept(deptId) {
+  return request({
+    url: '/system/dept/' + deptId,
+    method: 'get'
+  })
+}
+
+// 新增部门
+export function addDept(data) {
+  return request({
+    url: '/system/dept',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改部门
+export function updateDept(data) {
+  return request({
+    url: '/system/dept',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除部门
+export function delDept(deptId) {
+  return request({
+    url: '/system/dept/' + deptId,
+    method: 'delete'
+  })
+}

+ 52 - 0
jd-logistics-ui-vue3/src/api/system/dict/data.js

@@ -0,0 +1,52 @@
+import request from '@/utils/request'
+
+// 查询字典数据列表
+export function listData(query) {
+  return request({
+    url: '/system/dict/data/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询字典数据详细
+export function getData(dictCode) {
+  return request({
+    url: '/system/dict/data/' + dictCode,
+    method: 'get'
+  })
+}
+
+// 根据字典类型查询字典数据信息
+export function getDicts(dictType) {
+  return request({
+    url: '/system/dict/data/type/' + dictType,
+    method: 'get'
+  })
+}
+
+// 新增字典数据
+export function addData(data) {
+  return request({
+    url: '/system/dict/data',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改字典数据
+export function updateData(data) {
+  return request({
+    url: '/system/dict/data',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除字典数据
+export function delData(dictCode) {
+  return request({
+    url: '/system/dict/data/' + dictCode,
+    method: 'delete'
+  })
+}

+ 60 - 0
jd-logistics-ui-vue3/src/api/system/dict/type.js

@@ -0,0 +1,60 @@
+import request from '@/utils/request'
+
+// 查询字典类型列表
+export function listType(query) {
+  return request({
+    url: '/system/dict/type/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询字典类型详细
+export function getType(dictId) {
+  return request({
+    url: '/system/dict/type/' + dictId,
+    method: 'get'
+  })
+}
+
+// 新增字典类型
+export function addType(data) {
+  return request({
+    url: '/system/dict/type',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改字典类型
+export function updateType(data) {
+  return request({
+    url: '/system/dict/type',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除字典类型
+export function delType(dictId) {
+  return request({
+    url: '/system/dict/type/' + dictId,
+    method: 'delete'
+  })
+}
+
+// 刷新字典缓存
+export function refreshCache() {
+  return request({
+    url: '/system/dict/type/refreshCache',
+    method: 'delete'
+  })
+}
+
+// 获取字典选择框列表
+export function optionselect() {
+  return request({
+    url: '/system/dict/type/optionselect',
+    method: 'get'
+  })
+}

+ 33 - 0
jd-logistics-ui-vue3/src/api/system/logininfor.js

@@ -0,0 +1,33 @@
+import request from '@/utils/request'
+
+// 查询登录日志列表
+export function list(query) {
+  return request({
+    url: '/system/logininfor/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 删除登录日志
+export function delLogininfor(infoId) {
+  return request({
+    url: '/system/logininfor/' + infoId,
+    method: 'delete'
+  })
+}
+
+// 解锁用户登录状态
+export function unlockLogininfor(userName) {
+  return request({
+    url: '/system/logininfor/unlock/' + userName,
+    method: 'get'
+  })
+}
+// 清空登录日志
+export function cleanLogininfor() {
+  return request({
+    url: '/system/logininfor/clean',
+    method: 'delete'
+  })
+}

+ 60 - 0
jd-logistics-ui-vue3/src/api/system/menu.js

@@ -0,0 +1,60 @@
+import request from '@/utils/request'
+
+// 查询菜单列表
+export function listMenu(query) {
+  return request({
+    url: '/system/menu/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询菜单详细
+export function getMenu(menuId) {
+  return request({
+    url: '/system/menu/' + menuId,
+    method: 'get'
+  })
+}
+
+// 查询菜单下拉树结构
+export function treeselect() {
+  return request({
+    url: '/system/menu/treeselect',
+    method: 'get'
+  })
+}
+
+// 根据角色ID查询菜单下拉树结构
+export function roleMenuTreeselect(roleId) {
+  return request({
+    url: '/system/menu/roleMenuTreeselect/' + roleId,
+    method: 'get'
+  })
+}
+
+// 新增菜单
+export function addMenu(data) {
+  return request({
+    url: '/system/menu',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改菜单
+export function updateMenu(data) {
+  return request({
+    url: '/system/menu',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除菜单
+export function delMenu(menuId) {
+  return request({
+    url: '/system/menu/' + menuId,
+    method: 'delete'
+  })
+}

+ 44 - 0
jd-logistics-ui-vue3/src/api/system/notice.js

@@ -0,0 +1,44 @@
+import request from '@/utils/request'
+
+// 查询公告列表
+export function listNotice(query) {
+  return request({
+    url: '/system/notice/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询公告详细
+export function getNotice(noticeId) {
+  return request({
+    url: '/system/notice/' + noticeId,
+    method: 'get'
+  })
+}
+
+// 新增公告
+export function addNotice(data) {
+  return request({
+    url: '/system/notice',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改公告
+export function updateNotice(data) {
+  return request({
+    url: '/system/notice',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除公告
+export function delNotice(noticeId) {
+  return request({
+    url: '/system/notice/' + noticeId,
+    method: 'delete'
+  })
+}

+ 26 - 0
jd-logistics-ui-vue3/src/api/system/operlog.js

@@ -0,0 +1,26 @@
+import request from '@/utils/request'
+
+// 查询操作日志列表
+export function list(query) {
+  return request({
+    url: '/system/operlog/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 删除操作日志
+export function delOperlog(operId) {
+  return request({
+    url: '/system/operlog/' + operId,
+    method: 'delete'
+  })
+}
+
+// 清空操作日志
+export function cleanOperlog() {
+  return request({
+    url: '/system/operlog/clean',
+    method: 'delete'
+  })
+}

+ 44 - 0
jd-logistics-ui-vue3/src/api/system/post.js

@@ -0,0 +1,44 @@
+import request from '@/utils/request'
+
+// 查询岗位列表
+export function listPost(query) {
+  return request({
+    url: '/system/post/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询岗位详细
+export function getPost(postId) {
+  return request({
+    url: '/system/post/' + postId,
+    method: 'get'
+  })
+}
+
+// 新增岗位
+export function addPost(data) {
+  return request({
+    url: '/system/post',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改岗位
+export function updatePost(data) {
+  return request({
+    url: '/system/post',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除岗位
+export function delPost(postId) {
+  return request({
+    url: '/system/post/' + postId,
+    method: 'delete'
+  })
+}

+ 119 - 0
jd-logistics-ui-vue3/src/api/system/role.js

@@ -0,0 +1,119 @@
+import request from '@/utils/request'
+
+// 查询角色列表
+export function listRole(query) {
+  return request({
+    url: '/system/role/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询角色详细
+export function getRole(roleId) {
+  return request({
+    url: '/system/role/' + roleId,
+    method: 'get'
+  })
+}
+
+// 新增角色
+export function addRole(data) {
+  return request({
+    url: '/system/role',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改角色
+export function updateRole(data) {
+  return request({
+    url: '/system/role',
+    method: 'put',
+    data: data
+  })
+}
+
+// 角色数据权限
+export function dataScope(data) {
+  return request({
+    url: '/system/role/dataScope',
+    method: 'put',
+    data: data
+  })
+}
+
+// 角色状态修改
+export function changeRoleStatus(roleId, status) {
+  const data = {
+    roleId,
+    status
+  }
+  return request({
+    url: '/system/role/changeStatus',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除角色
+export function delRole(roleId) {
+  return request({
+    url: '/system/role/' + roleId,
+    method: 'delete'
+  })
+}
+
+// 查询角色已授权用户列表
+export function allocatedUserList(query) {
+  return request({
+    url: '/system/role/authUser/allocatedList',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询角色未授权用户列表
+export function unallocatedUserList(query) {
+  return request({
+    url: '/system/role/authUser/unallocatedList',
+    method: 'get',
+    params: query
+  })
+}
+
+// 取消用户授权角色
+export function authUserCancel(data) {
+  return request({
+    url: '/system/role/authUser/cancel',
+    method: 'put',
+    data: data
+  })
+}
+
+// 批量取消用户授权角色
+export function authUserCancelAll(data) {
+  return request({
+    url: '/system/role/authUser/cancelAll',
+    method: 'put',
+    params: data
+  })
+}
+
+// 授权用户选择
+export function authUserSelectAll(data) {
+  return request({
+    url: '/system/role/authUser/selectAll',
+    method: 'put',
+    params: data
+  })
+}
+
+// 根据角色ID查询部门树结构
+export function deptTreeSelect(roleId) {
+  return request({
+    url: '/system/role/deptTree/' + roleId,
+    method: 'get'
+  })
+}

+ 136 - 0
jd-logistics-ui-vue3/src/api/system/user.js

@@ -0,0 +1,136 @@
+import request from '@/utils/request'
+import { parseStrEmpty } from "@/utils/ruoyi";
+
+// 查询用户列表
+export function listUser(query) {
+  return request({
+    url: '/system/user/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询用户详细
+export function getUser(userId) {
+  return request({
+    url: '/system/user/' + parseStrEmpty(userId),
+    method: 'get'
+  })
+}
+
+// 新增用户
+export function addUser(data) {
+  return request({
+    url: '/system/user',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改用户
+export function updateUser(data) {
+  return request({
+    url: '/system/user',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除用户
+export function delUser(userId) {
+  return request({
+    url: '/system/user/' + userId,
+    method: 'delete'
+  })
+}
+
+// 用户密码重置
+export function resetUserPwd(userId, password) {
+  const data = {
+    userId,
+    password
+  }
+  return request({
+    url: '/system/user/resetPwd',
+    method: 'put',
+    data: data
+  })
+}
+
+// 用户状态修改
+export function changeUserStatus(userId, status) {
+  const data = {
+    userId,
+    status
+  }
+  return request({
+    url: '/system/user/changeStatus',
+    method: 'put',
+    data: data
+  })
+}
+
+// 查询用户个人信息
+export function getUserProfile() {
+  return request({
+    url: '/system/user/profile',
+    method: 'get'
+  })
+}
+
+// 修改用户个人信息
+export function updateUserProfile(data) {
+  return request({
+    url: '/system/user/profile',
+    method: 'put',
+    data: data
+  })
+}
+
+// 用户密码重置
+export function updateUserPwd(oldPassword, newPassword) {
+  const data = {
+    oldPassword,
+    newPassword
+  }
+  return request({
+    url: '/system/user/profile/updatePwd',
+    method: 'put',
+    data: data
+  })
+}
+
+// 用户头像上传
+export function uploadAvatar(data) {
+  return request({
+    url: '/system/user/profile/avatar',
+    method: 'post',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    data: data
+  })
+}
+
+// 查询授权角色
+export function getAuthRole(userId) {
+  return request({
+    url: '/system/user/authRole/' + userId,
+    method: 'get'
+  })
+}
+
+// 保存授权角色
+export function updateAuthRole(data) {
+  return request({
+    url: '/system/user/authRole',
+    method: 'put',
+    params: data
+  })
+}
+
+// 查询部门下拉树结构
+export function deptTreeSelect() {
+  return request({
+    url: '/system/user/deptTree',
+    method: 'get'
+  })
+}

+ 76 - 0
jd-logistics-ui-vue3/src/api/tool/gen.js

@@ -0,0 +1,76 @@
+import request from '@/utils/request'
+
+// 查询生成表数据
+export function listTable(query) {
+  return request({
+    url: '/code/gen/list',
+    method: 'get',
+    params: query
+  })
+}
+// 查询db数据库列表
+export function listDbTable(query) {
+  return request({
+    url: '/code/gen/db/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询表详细信息
+export function getGenTable(tableId) {
+  return request({
+    url: '/code/gen/' + tableId,
+    method: 'get'
+  })
+}
+
+// 修改代码生成信息
+export function updateGenTable(data) {
+  return request({
+    url: '/code/gen',
+    method: 'put',
+    data: data
+  })
+}
+
+// 导入表
+export function importTable(data) {
+  return request({
+    url: '/code/gen/importTable',
+    method: 'post',
+    params: data
+  })
+}
+
+// 预览生成代码
+export function previewTable(tableId) {
+  return request({
+    url: '/code/gen/preview/' + tableId,
+    method: 'get'
+  })
+}
+
+// 删除表数据
+export function delTable(tableId) {
+  return request({
+    url: '/code/gen/' + tableId,
+    method: 'delete'
+  })
+}
+
+// 生成代码(自定义路径)
+export function genCode(tableName) {
+  return request({
+    url: '/code/gen/genCode/' + tableName,
+    method: 'get'
+  })
+}
+
+// 同步数据库
+export function synchDb(tableName) {
+  return request({
+    url: '/code/gen/synchDb/' + tableName,
+    method: 'get'
+  })
+}

+ 97 - 0
jd-logistics-ui-vue3/src/components/Breadcrumb/index.vue

@@ -0,0 +1,97 @@
+<template>
+  <el-breadcrumb class="app-breadcrumb" separator="/">
+    <transition-group name="breadcrumb">
+      <el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
+        <span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1" class="no-redirect">{{ item.meta.title }}</span>
+        <a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
+      </el-breadcrumb-item>
+    </transition-group>
+  </el-breadcrumb>
+</template>
+
+<script setup>
+import usePermissionStore from '@/store/modules/permission'
+
+const route = useRoute()
+const router = useRouter()
+const permissionStore = usePermissionStore()
+const levelList = ref([])
+
+function getBreadcrumb() {
+  // only show routes with meta.title
+  let matched = []
+  const pathNum = findPathNum(route.path)
+  // multi-level menu
+  if (pathNum > 2) {
+    const reg = /\/\w+/gi
+    const pathList = route.path.match(reg).map((item, index) => {
+      if (index !== 0) item = item.slice(1)
+      return item
+    })
+    getMatched(pathList, permissionStore.defaultRoutes, matched)
+  } else {
+    matched = route.matched.filter((item) => item.meta && item.meta.title)
+  }
+  // 判断是否为首页
+  if (!isDashboard(matched[0])) {
+    matched = [{ path: "/index", meta: { title: "首页" } }].concat(matched)
+  }
+  levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
+}
+function findPathNum(str, char = "/") {
+  let index = str.indexOf(char)
+  let num = 0
+  while (index !== -1) {
+    num++
+    index = str.indexOf(char, index + 1)
+  }
+  return num
+}
+function getMatched(pathList, routeList, matched) {
+  let data = routeList.find(item => item.path == pathList[0] || (item.name += '').toLowerCase() == pathList[0])
+  if (data) {
+    matched.push(data)
+    if (data.children && pathList.length) {
+      pathList.shift()
+      getMatched(pathList, data.children, matched)
+    }
+  }
+}
+function isDashboard(route) {
+  const name = route && route.name
+  if (!name) {
+    return false
+  }
+  return name.trim() === 'Index'
+}
+function handleLink(item) {
+  const { redirect, path } = item
+  if (redirect) {
+    router.push(redirect)
+    return
+  }
+  router.push(path)
+}
+
+watchEffect(() => {
+  // if you go to the redirect page, do not update the breadcrumbs
+  if (route.path.startsWith('/redirect/')) {
+    return
+  }
+  getBreadcrumb()
+})
+getBreadcrumb()
+</script>
+
+<style lang='scss' scoped>
+.app-breadcrumb.el-breadcrumb {
+  display: inline-block;
+  font-size: 14px;
+  line-height: 50px;
+
+  .no-redirect {
+    color: #97a8be;
+    cursor: text;
+  }
+}
+</style>

+ 174 - 0
jd-logistics-ui-vue3/src/components/Crontab/day.vue

@@ -0,0 +1,174 @@
+<template>
+    <el-form>
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="1">
+                日,允许的通配符[, - * ? / L W]
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="2">
+                不指定
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="3">
+                周期从
+                <el-input-number v-model='cycle01' :min="1" :max="30" /> -
+                <el-input-number v-model='cycle02' :min="cycle01 + 1" :max="31" /> 日
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="4">
+                从
+                <el-input-number v-model='average01' :min="1" :max="30" /> 号开始,每
+                <el-input-number v-model='average02' :min="1" :max="31 - average01" /> 日执行一次
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="5">
+                每月
+                <el-input-number v-model='workday' :min="1" :max="31" /> 号最近的那个工作日
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="6">
+                本月最后一天
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="7">
+                指定
+                <el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="10">
+                    <el-option v-for="item in 31" :key="item" :label="item" :value="item" />
+                </el-select>
+            </el-radio>
+        </el-form-item>
+    </el-form>
+</template>
+<script setup>
+const emit = defineEmits(['update'])
+const props = defineProps({
+    cron: {
+        type: Object,
+        default: {
+            second: "*",
+            min: "*",
+            hour: "*",
+            day: "*",
+            month: "*",
+            week: "?",
+            year: "",
+        }
+    },
+    check: {
+        type: Function,
+        default: () => {
+        }
+    }
+})
+const radioValue = ref(1)
+const cycle01 = ref(1)
+const cycle02 = ref(2)
+const average01 = ref(1)
+const average02 = ref(1)
+const workday = ref(1)
+const checkboxList = ref([])
+const checkCopy = ref([1])
+const cycleTotal = computed(() => {
+    cycle01.value = props.check(cycle01.value, 1, 30)
+    cycle02.value = props.check(cycle02.value, cycle01.value + 1, 31)
+    return cycle01.value + '-' + cycle02.value
+})
+const averageTotal = computed(() => {
+    average01.value = props.check(average01.value, 1, 30)
+    average02.value = props.check(average02.value, 1, 31 - average01.value)
+    return average01.value + '/' + average02.value
+})
+const workdayTotal = computed(() => {
+    workday.value = props.check(workday.value, 1, 31)
+    return workday.value + 'W'
+})
+const checkboxString = computed(() => {
+    return checkboxList.value.join(',')
+})
+watch(() => props.cron.day, value => changeRadioValue(value))
+watch([radioValue, cycleTotal, averageTotal, workdayTotal, checkboxString], () => onRadioChange())
+function changeRadioValue(value) {
+    if (value === "*") {
+        radioValue.value = 1
+    } else if (value === "?") {
+        radioValue.value = 2
+    } else if (value.indexOf("-") > -1) {
+        const indexArr = value.split('-')
+        cycle01.value = Number(indexArr[0])
+        cycle02.value = Number(indexArr[1])
+        radioValue.value = 3
+    } else if (value.indexOf("/") > -1) {
+        const indexArr = value.split('/')
+        average01.value = Number(indexArr[0])
+        average02.value = Number(indexArr[1])
+        radioValue.value = 4
+    } else if (value.indexOf("W") > -1) {
+        const indexArr = value.split("W")
+        workday.value = Number(indexArr[0])
+        radioValue.value = 5
+    } else if (value === "L") {
+        radioValue.value = 6
+    } else {
+        checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
+        radioValue.value = 7
+    }
+}
+// 单选按钮值变化时
+function onRadioChange() {
+    if (radioValue.value === 2 && props.cron.week === '?') {
+        emit('update', 'week', '*', 'day')
+    }
+    if (radioValue.value !== 2 && props.cron.week !== '?') {
+        emit('update', 'week', '?', 'day')
+    }
+    switch (radioValue.value) {
+        case 1:
+            emit('update', 'day', '*', 'day')
+            break
+        case 2:
+            emit('update', 'day', '?', 'day')
+            break
+        case 3:
+            emit('update', 'day', cycleTotal.value, 'day')
+            break
+        case 4:
+            emit('update', 'day', averageTotal.value, 'day')
+            break
+        case 5:
+            emit('update', 'day', workdayTotal.value, 'day')
+            break
+        case 6:
+            emit('update', 'day', 'L', 'day')
+            break
+        case 7:
+            if (checkboxList.value.length === 0) {
+                checkboxList.value.push(checkCopy.value[0])
+            } else {
+                checkCopy.value = checkboxList.value
+            }
+            emit('update', 'day', checkboxString.value, 'day')
+            break
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.el-input-number--small, .el-select, .el-select--small {
+    margin: 0 0.2rem;
+}
+.el-select, .el-select--small {
+    width: 18.8rem;
+}
+</style>

+ 133 - 0
jd-logistics-ui-vue3/src/components/Crontab/hour.vue

@@ -0,0 +1,133 @@
+<template>
+    <el-form>
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="1">
+                小时,允许的通配符[, - * /]
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="2">
+                周期从
+                <el-input-number v-model='cycle01' :min="0" :max="22" /> -
+                <el-input-number v-model='cycle02' :min="cycle01 + 1" :max="23" /> 时
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="3">
+                从
+                <el-input-number v-model='average01' :min="0" :max="22" /> 时开始,每
+                <el-input-number v-model='average02' :min="1" :max="23 - average01" /> 小时执行一次
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="4">
+                指定
+                <el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="10">
+                    <el-option v-for="item in 24" :key="item" :label="item - 1" :value="item - 1" />
+                </el-select>
+            </el-radio>
+        </el-form-item>
+    </el-form>
+</template>
+
+<script setup>
+const emit = defineEmits(['update'])
+const props = defineProps({
+    cron: {
+        type: Object,
+        default: {
+            second: "*",
+            min: "*",
+            hour: "*",
+            day: "*",
+            month: "*",
+            week: "?",
+            year: "",
+        }
+    },
+    check: {
+        type: Function,
+        default: () => {
+        }
+    }
+})
+const radioValue = ref(1)
+const cycle01 = ref(0)
+const cycle02 = ref(1)
+const average01 = ref(0)
+const average02 = ref(1)
+const checkboxList = ref([])
+const checkCopy = ref([0])
+const cycleTotal = computed(() => {
+    cycle01.value = props.check(cycle01.value, 0, 22)
+    cycle02.value = props.check(cycle02.value, cycle01.value + 1, 23)
+    return cycle01.value + '-' + cycle02.value
+})
+const averageTotal = computed(() => {
+    average01.value = props.check(average01.value, 0, 22)
+    average02.value = props.check(average02.value, 1, 23 - average01.value)
+    return average01.value + '/' + average02.value
+})
+const checkboxString = computed(() => {
+    return checkboxList.value.join(',')
+})
+watch(() => props.cron.hour, value => changeRadioValue(value))
+watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
+function changeRadioValue(value) {
+    if (props.cron.min === '*') {
+        emit('update', 'min', '0', 'hour')
+    }
+    if (props.cron.second === '*') {
+        emit('update', 'second', '0', 'hour')
+    }
+    if (value === '*') {
+        radioValue.value = 1
+    } else if (value.indexOf('-') > -1) {
+        const indexArr = value.split('-')
+        cycle01.value = Number(indexArr[0])
+        cycle02.value = Number(indexArr[1])
+        radioValue.value = 2
+    } else if (value.indexOf('/') > -1) {
+        const indexArr = value.split('/')
+        average01.value = Number(indexArr[0])
+        average02.value = Number(indexArr[1])
+        radioValue.value = 3
+    } else {
+        checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
+        radioValue.value = 4
+    }
+}
+function onRadioChange() {
+    switch (radioValue.value) {
+        case 1:
+            emit('update', 'hour', '*', 'hour')
+            break
+        case 2:
+            emit('update', 'hour', cycleTotal.value, 'hour')
+            break
+        case 3:
+            emit('update', 'hour', averageTotal.value, 'hour')
+            break
+        case 4:
+            if (checkboxList.value.length === 0) {
+                checkboxList.value.push(checkCopy.value[0])
+            } else {
+                checkCopy.value = checkboxList.value
+            }
+            emit('update', 'hour', checkboxString.value, 'hour')
+            break
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.el-input-number--small, .el-select, .el-select--small {
+    margin: 0 0.2rem;
+}
+.el-select, .el-select--small {
+    width: 18.8rem;
+}
+</style>

+ 313 - 0
jd-logistics-ui-vue3/src/components/Crontab/index.vue

@@ -0,0 +1,313 @@
+<template>
+    <div>
+        <el-tabs type="border-card">
+            <el-tab-pane label="秒" v-if="shouldHide('second')">
+                <CrontabSecond
+                    @update="updateCrontabValue"
+                    :check="checkNumber"
+                    :cron="crontabValueObj"
+                    ref="cronsecond"
+                />
+            </el-tab-pane>
+
+            <el-tab-pane label="分钟" v-if="shouldHide('min')">
+                <CrontabMin
+                    @update="updateCrontabValue"
+                    :check="checkNumber"
+                    :cron="crontabValueObj"
+                    ref="cronmin"
+                />
+            </el-tab-pane>
+
+            <el-tab-pane label="小时" v-if="shouldHide('hour')">
+                <CrontabHour
+                    @update="updateCrontabValue"
+                    :check="checkNumber"
+                    :cron="crontabValueObj"
+                    ref="cronhour"
+                />
+            </el-tab-pane>
+
+            <el-tab-pane label="日" v-if="shouldHide('day')">
+                <CrontabDay
+                    @update="updateCrontabValue"
+                    :check="checkNumber"
+                    :cron="crontabValueObj"
+                    ref="cronday"
+                />
+            </el-tab-pane>
+
+            <el-tab-pane label="月" v-if="shouldHide('month')">
+                <CrontabMonth
+                    @update="updateCrontabValue"
+                    :check="checkNumber"
+                    :cron="crontabValueObj"
+                    ref="cronmonth"
+                />
+            </el-tab-pane>
+
+            <el-tab-pane label="周" v-if="shouldHide('week')">
+                <CrontabWeek
+                    @update="updateCrontabValue"
+                    :check="checkNumber"
+                    :cron="crontabValueObj"
+                    ref="cronweek"
+                />
+            </el-tab-pane>
+
+            <el-tab-pane label="年" v-if="shouldHide('year')">
+                <CrontabYear
+                    @update="updateCrontabValue"
+                    :check="checkNumber"
+                    :cron="crontabValueObj"
+                    ref="cronyear"
+                />
+            </el-tab-pane>
+        </el-tabs>
+
+        <div class="popup-main">
+            <div class="popup-result">
+                <p class="title">时间表达式</p>
+                <table>
+                    <thead>
+                        <tr>
+                            <th v-for="item of tabTitles" :key="item">{{item}}</th>
+                            <th>Cron 表达式</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td>
+                                <span v-if="crontabValueObj.second.length < 10">{{crontabValueObj.second}}</span>
+                                <el-tooltip v-else :content="crontabValueObj.second" placement="top"><span>{{crontabValueObj.second}}</span></el-tooltip>
+                            </td>
+                            <td>
+                                <span v-if="crontabValueObj.min.length < 10">{{crontabValueObj.min}}</span>
+                                <el-tooltip v-else :content="crontabValueObj.min" placement="top"><span>{{crontabValueObj.min}}</span></el-tooltip>
+                            </td>
+                            <td>
+                                <span v-if="crontabValueObj.hour.length < 10">{{crontabValueObj.hour}}</span>
+                                <el-tooltip v-else :content="crontabValueObj.hour" placement="top"><span>{{crontabValueObj.hour}}</span></el-tooltip>
+                            </td>
+                            <td>
+                                <span v-if="crontabValueObj.day.length < 10">{{crontabValueObj.day}}</span>
+                                <el-tooltip v-else :content="crontabValueObj.day" placement="top"><span>{{crontabValueObj.day}}</span></el-tooltip>
+                            </td>
+                            <td>
+                                <span v-if="crontabValueObj.month.length < 10">{{crontabValueObj.month}}</span>
+                                <el-tooltip v-else :content="crontabValueObj.month" placement="top"><span>{{crontabValueObj.month}}</span></el-tooltip>
+                            </td>
+                            <td>
+                                <span v-if="crontabValueObj.week.length < 10">{{crontabValueObj.week}}</span>
+                                <el-tooltip v-else :content="crontabValueObj.week" placement="top"><span>{{crontabValueObj.week}}</span></el-tooltip>
+                            </td>
+                            <td>
+                                <span v-if="crontabValueObj.year.length < 10">{{crontabValueObj.year}}</span>
+                                <el-tooltip v-else :content="crontabValueObj.year" placement="top"><span>{{crontabValueObj.year}}</span></el-tooltip>
+                            </td>
+                            <td class="result">
+                                <span v-if="crontabValueString.length < 90">{{crontabValueString}}</span>
+                                <el-tooltip v-else :content="crontabValueString" placement="top"><span>{{crontabValueString}}</span></el-tooltip>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+            <CrontabResult :ex="crontabValueString"></CrontabResult>
+
+            <div class="pop_btn">
+                <el-button type="primary" @click="submitFill">确定</el-button>
+                <el-button type="warning" @click="clearCron">重置</el-button>
+                <el-button @click="hidePopup">取消</el-button>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import CrontabSecond from "./second.vue"
+import CrontabMin from "./min.vue"
+import CrontabHour from "./hour.vue"
+import CrontabDay from "./day.vue"
+import CrontabMonth from "./month.vue"
+import CrontabWeek from "./week.vue"
+import CrontabYear from "./year.vue"
+import CrontabResult from "./result.vue"
+const { proxy } = getCurrentInstance()
+const emit = defineEmits(['hide', 'fill'])
+const props = defineProps({
+    hideComponent: {
+        type: Array,
+        default: () => [],
+    },
+    expression: {
+        type: String,
+        default: ""
+    }
+})
+const tabTitles = ref(["秒", "分钟", "小时", "日", "月", "周", "年"])
+const tabActive = ref(0)
+const hideComponent = ref([])
+const expression = ref('')
+const crontabValueObj = ref({
+    second: "*",
+    min: "*",
+    hour: "*",
+    day: "*",
+    month: "*",
+    week: "?",
+    year: "",
+})
+const crontabValueString = computed(() => {
+    const obj = crontabValueObj.value
+    return obj.second
+        + " "
+        + obj.min
+        + " "
+        + obj.hour
+        + " "
+        + obj.day
+        + " "
+        + obj.month
+        + " "
+        + obj.week
+        + (obj.year === "" ? "" : " " + obj.year)
+})
+watch(expression, () => resolveExp())
+function shouldHide(key) {
+    return !(hideComponent.value && hideComponent.value.includes(key))
+}
+function resolveExp() {
+    // 反解析 表达式
+    if (expression.value) {
+        const arr = expression.value.split(/\s+/)
+        if (arr.length >= 6) {
+            //6 位以上是合法表达式
+            let obj = {
+                second: arr[0],
+                min: arr[1],
+                hour: arr[2],
+                day: arr[3],
+                month: arr[4],
+                week: arr[5],
+                year: arr[6] ? arr[6] : ""
+            }
+            crontabValueObj.value = {
+                ...obj,
+            }
+        }
+    } else {
+        // 没有传入的表达式 则还原
+        clearCron()
+    }
+}
+// tab切换值
+function tabCheck(index) {
+    tabActive.value = index
+}
+// 由子组件触发,更改表达式组成的字段值
+function updateCrontabValue(name, value, from) {
+    crontabValueObj.value[name] = value
+}
+// 表单选项的子组件校验数字格式(通过-props传递)
+function checkNumber(value, minLimit, maxLimit) {
+    // 检查必须为整数
+    value = Math.floor(value)
+    if (value < minLimit) {
+        value = minLimit
+    } else if (value > maxLimit) {
+        value = maxLimit
+    }
+    return value
+}
+// 隐藏弹窗
+function hidePopup() {
+    emit("hide")
+}
+// 填充表达式
+function submitFill() {
+    emit("fill", crontabValueString.value)
+    hidePopup()
+}
+function clearCron() {
+    // 还原选择项
+    crontabValueObj.value = {
+        second: "*",
+        min: "*",
+        hour: "*",
+        day: "*",
+        month: "*",
+        week: "?",
+        year: "",
+    }
+}
+onMounted(() => {
+    expression.value = props.expression
+    hideComponent.value = props.hideComponent
+})
+</script>
+
+<style lang="scss" scoped>
+.pop_btn {
+    text-align: center;
+    margin-top: 20px;
+}
+.popup-main {
+    position: relative;
+    margin: 10px auto;
+    border-radius: 5px;
+    font-size: 12px;
+    overflow: hidden;
+}
+.popup-title {
+    overflow: hidden;
+    line-height: 34px;
+    padding-top: 6px;
+    background: #f2f2f2;
+}
+.popup-result {
+    box-sizing: border-box;
+    line-height: 24px;
+    margin: 25px auto;
+    padding: 15px 10px 10px;
+    border: 1px solid #ccc;
+    position: relative;
+}
+.popup-result .title {
+    position: absolute;
+    top: -28px;
+    left: 50%;
+    width: 140px;
+    font-size: 14px;
+    margin-left: -70px;
+    text-align: center;
+    line-height: 30px;
+    background: #fff;
+}
+.popup-result table {
+    text-align: center;
+    width: 100%;
+    margin: 0 auto;
+}
+.popup-result table td:not(.result) {
+    width: 3.5rem;
+    min-width: 3.5rem;
+    max-width: 3.5rem;
+}
+.popup-result table span {
+    display: block;
+    width: 100%;
+    font-family: arial;
+    line-height: 30px;
+    height: 30px;
+    white-space: nowrap;
+    overflow: hidden;
+    border: 1px solid #e8e8e8;
+}
+.popup-result-scroll {
+    font-size: 12px;
+    line-height: 24px;
+    height: 10em;
+    overflow-y: auto;
+}
+</style>

+ 126 - 0
jd-logistics-ui-vue3/src/components/Crontab/min.vue

@@ -0,0 +1,126 @@
+<template>
+    <el-form>
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="1">
+                分钟,允许的通配符[, - * /]
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="2">
+                周期从
+                <el-input-number v-model='cycle01' :min="0" :max="58" /> -
+                <el-input-number v-model='cycle02' :min="cycle01 + 1" :max="59" /> 分钟
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="3">
+                从
+                <el-input-number v-model='average01' :min="0" :max="58" /> 分钟开始, 每
+                <el-input-number v-model='average02' :min="1" :max="59 - average01" /> 分钟执行一次
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="4">
+                指定
+                <el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="10">
+                    <el-option v-for="item in 60" :key="item" :label="item - 1" :value="item - 1" />
+                </el-select>
+            </el-radio>
+        </el-form-item>
+    </el-form>
+</template>
+<script setup>
+const emit = defineEmits(['update'])
+const props = defineProps({
+    cron: {
+        type: Object,
+        default: {
+            second: "*",
+            min: "*",
+            hour: "*",
+            day: "*",
+            month: "*",
+            week: "?",
+            year: "",
+        }
+    },
+    check: {
+        type: Function,
+        default: () => {
+        }
+    }
+})
+const radioValue = ref(1)
+const cycle01 = ref(0)
+const cycle02 = ref(1)
+const average01 = ref(0)
+const average02 = ref(1)
+const checkboxList = ref([])
+const checkCopy = ref([0])
+const cycleTotal = computed(() => {
+    cycle01.value = props.check(cycle01.value, 0, 58)
+    cycle02.value = props.check(cycle02.value, cycle01.value + 1, 59)
+    return cycle01.value + '-' + cycle02.value
+})
+const averageTotal = computed(() => {
+    average01.value = props.check(average01.value, 0, 58)
+    average02.value = props.check(average02.value, 1, 59 - average01.value)
+    return average01.value + '/' + average02.value
+})
+const checkboxString = computed(() => {
+    return checkboxList.value.join(',')
+})
+watch(() => props.cron.min, value => changeRadioValue(value))
+watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
+function changeRadioValue(value) {
+    if (value === '*') {
+        radioValue.value = 1
+    } else if (value.indexOf('-') > -1) {
+        const indexArr = value.split('-')
+        cycle01.value = Number(indexArr[0])
+        cycle02.value = Number(indexArr[1])
+        radioValue.value = 2
+    } else if (value.indexOf('/') > -1) {
+        const indexArr = value.split('/')
+        average01.value = Number(indexArr[0])
+        average02.value = Number(indexArr[1])
+        radioValue.value = 3
+    } else {
+        checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
+        radioValue.value = 4
+    }
+}
+function onRadioChange() {
+    switch (radioValue.value) {
+        case 1:
+            emit('update', 'min', '*', 'min')
+            break
+        case 2:
+            emit('update', 'min', cycleTotal.value, 'min')
+            break
+        case 3:
+            emit('update', 'min', averageTotal.value, 'min')
+            break
+        case 4:
+            if (checkboxList.value.length === 0) {
+                checkboxList.value.push(checkCopy.value[0])
+            } else {
+                checkCopy.value = checkboxList.value
+            }
+            emit('update', 'min', checkboxString.value, 'min')
+            break
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.el-input-number--small, .el-select, .el-select--small {
+    margin: 0 0.2rem;
+}
+.el-select, .el-select--small {
+    width: 19.8rem;
+}
+</style>

+ 141 - 0
jd-logistics-ui-vue3/src/components/Crontab/month.vue

@@ -0,0 +1,141 @@
+<template>
+    <el-form>
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="1">
+                月,允许的通配符[, - * /]
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="2">
+                周期从
+                <el-input-number v-model='cycle01' :min="1" :max="11" /> -
+                <el-input-number v-model='cycle02' :min="cycle01 + 1" :max="12" /> 月
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="3">
+                从
+                <el-input-number v-model='average01' :min="1" :max="11" /> 月开始,每
+                <el-input-number v-model='average02' :min="1" :max="12 - average01" /> 月月执行一次
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="4">
+                指定
+                <el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="8">
+                    <el-option v-for="item in monthList" :key="item.key" :label="item.value" :value="item.key" />
+                </el-select>
+            </el-radio>
+        </el-form-item>
+    </el-form>
+</template>
+
+<script setup>
+const emit = defineEmits(['update'])
+const props = defineProps({
+    cron: {
+        type: Object,
+        default: {
+            second: "*",
+            min: "*",
+            hour: "*",
+            day: "*",
+            month: "*",
+            week: "?",
+            year: "",
+        }
+    },
+    check: {
+        type: Function,
+        default: () => {
+        }
+    }
+})
+const radioValue = ref(1)
+const cycle01 = ref(1)
+const cycle02 = ref(2)
+const average01 = ref(1)
+const average02 = ref(1)
+const checkboxList = ref([])
+const checkCopy = ref([1])
+const monthList = ref([
+    {key: 1, value: '一月'},
+    {key: 2, value: '二月'},
+    {key: 3, value: '三月'},
+    {key: 4, value: '四月'},
+    {key: 5, value: '五月'},
+    {key: 6, value: '六月'},
+    {key: 7, value: '七月'},
+    {key: 8, value: '八月'},
+    {key: 9, value: '九月'},
+    {key: 10, value: '十月'},
+    {key: 11, value: '十一月'},
+    {key: 12, value: '十二月'}
+])
+const cycleTotal = computed(() => {
+    cycle01.value = props.check(cycle01.value, 1, 11)
+    cycle02.value = props.check(cycle02.value, cycle01.value + 1, 12)
+    return cycle01.value + '-' + cycle02.value
+})
+const averageTotal = computed(() => {
+    average01.value = props.check(average01.value, 1, 11)
+    average02.value = props.check(average02.value, 1, 12 - average01.value)
+    return average01.value + '/' + average02.value
+})
+const checkboxString = computed(() => {
+    return checkboxList.value.join(',')
+})
+watch(() => props.cron.month, value => changeRadioValue(value))
+watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
+function changeRadioValue(value) {
+    if (value === '*') {
+        radioValue.value = 1
+    } else if (value.indexOf('-') > -1) {
+        const indexArr = value.split('-')
+        cycle01.value = Number(indexArr[0])
+        cycle02.value = Number(indexArr[1])
+        radioValue.value = 2
+    } else if (value.indexOf('/') > -1) {
+        const indexArr = value.split('/')
+        average01.value = Number(indexArr[0])
+        average02.value = Number(indexArr[1])
+        radioValue.value = 3
+    } else {
+        checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
+        radioValue.value = 4
+    }
+}
+function onRadioChange() {
+    switch (radioValue.value) {
+        case 1:
+            emit('update', 'month', '*', 'month')
+            break
+        case 2:
+            emit('update', 'month', cycleTotal.value, 'month')
+            break
+        case 3:
+            emit('update', 'month', averageTotal.value, 'month')
+            break
+        case 4:
+            if (checkboxList.value.length === 0) {
+                checkboxList.value.push(checkCopy.value[0])
+            } else {
+                checkCopy.value = checkboxList.value
+            }
+            emit('update', 'month', checkboxString.value, 'month')
+            break
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.el-input-number--small, .el-select, .el-select--small {
+    margin: 0 0.2rem;
+}
+.el-select, .el-select--small {
+    width: 18.8rem;
+}
+</style>

+ 540 - 0
jd-logistics-ui-vue3/src/components/Crontab/result.vue

@@ -0,0 +1,540 @@
+<template>
+	<div class="popup-result">
+		<p class="title">最近5次运行时间</p>
+		<ul class="popup-result-scroll">
+			<template v-if='isShow'>
+				<li v-for='item in resultList' :key="item">{{item}}</li>
+			</template>
+			<li v-else>计算结果中...</li>
+		</ul>
+	</div>
+</template>
+
+<script setup>
+const props = defineProps({
+    ex: {
+        type: String,
+        default: ''
+    }
+})
+const dayRule = ref('')
+const dayRuleSup = ref('')
+const dateArr = ref([])
+const resultList = ref([])
+const isShow = ref(false)
+watch(() => props.ex, () => expressionChange())
+// 表达式值变化时,开始去计算结果
+function expressionChange() {
+    // 计算开始-隐藏结果
+    isShow.value = false
+    // 获取规则数组[0秒、1分、2时、3日、4月、5星期、6年]
+    let ruleArr = props.ex.split(' ')
+    // 用于记录进入循环的次数
+    let nums = 0
+    // 用于暂时存符号时间规则结果的数组
+    let resultArr = []
+    // 获取当前时间精确至[年、月、日、时、分、秒]
+    let nTime = new Date()
+    let nYear = nTime.getFullYear()
+    let nMonth = nTime.getMonth() + 1
+    let nDay = nTime.getDate()
+    let nHour = nTime.getHours()
+    let nMin = nTime.getMinutes()
+    let nSecond = nTime.getSeconds()
+    // 根据规则获取到近100年可能年数组、月数组等等
+    getSecondArr(ruleArr[0])
+    getMinArr(ruleArr[1])
+    getHourArr(ruleArr[2])
+    getDayArr(ruleArr[3])
+    getMonthArr(ruleArr[4])
+    getWeekArr(ruleArr[5])
+    getYearArr(ruleArr[6], nYear)
+    // 将获取到的数组赋值-方便使用
+    let sDate = dateArr.value[0]
+    let mDate = dateArr.value[1]
+    let hDate = dateArr.value[2]
+    let DDate = dateArr.value[3]
+    let MDate = dateArr.value[4]
+    let YDate = dateArr.value[5]
+    // 获取当前时间在数组中的索引
+    let sIdx = getIndex(sDate, nSecond)
+    let mIdx = getIndex(mDate, nMin)
+    let hIdx = getIndex(hDate, nHour)
+    let DIdx = getIndex(DDate, nDay)
+    let MIdx = getIndex(MDate, nMonth)
+    let YIdx = getIndex(YDate, nYear)
+    // 重置月日时分秒的函数(后面用的比较多)
+    const resetSecond = function () {
+        sIdx = 0
+        nSecond = sDate[sIdx]
+    }
+    const resetMin = function () {
+        mIdx = 0
+        nMin = mDate[mIdx]
+        resetSecond()
+    }
+    const resetHour = function () {
+        hIdx = 0
+        nHour = hDate[hIdx]
+        resetMin()
+    }
+    const resetDay = function () {
+        DIdx = 0
+        nDay = DDate[DIdx]
+        resetHour()
+    }
+    const resetMonth = function () {
+        MIdx = 0
+        nMonth = MDate[MIdx]
+        resetDay()
+    }
+    // 如果当前年份不为数组中当前值
+    if (nYear !== YDate[YIdx]) {
+        resetMonth()
+    }
+    // 如果当前月份不为数组中当前值
+    if (nMonth !== MDate[MIdx]) {
+        resetDay()
+    }
+    // 如果当前“日”不为数组中当前值
+    if (nDay !== DDate[DIdx]) {
+        resetHour()
+    }
+    // 如果当前“时”不为数组中当前值
+    if (nHour !== hDate[hIdx]) {
+        resetMin()
+    }
+    // 如果当前“分”不为数组中当前值
+    if (nMin !== mDate[mIdx]) {
+        resetSecond()
+    }
+    // 循环年份数组
+    goYear: for (let Yi = YIdx; Yi < YDate.length; Yi++) {
+        let YY = YDate[Yi]
+        // 如果到达最大值时
+        if (nMonth > MDate[MDate.length - 1]) {
+            resetMonth()
+            continue
+        }
+        // 循环月份数组
+        goMonth: for (let Mi = MIdx; Mi < MDate.length; Mi++) {
+            // 赋值、方便后面运算
+            let MM = MDate[Mi];
+            MM = MM < 10 ? '0' + MM : MM
+            // 如果到达最大值时
+            if (nDay > DDate[DDate.length - 1]) {
+                resetDay()
+                if (Mi === MDate.length - 1) {
+                    resetMonth()
+                    continue goYear
+                }
+                continue
+            }
+            // 循环日期数组
+            goDay: for (let Di = DIdx; Di < DDate.length; Di++) {
+                // 赋值、方便后面运算
+                let DD = DDate[Di]
+                let thisDD = DD < 10 ? '0' + DD : DD
+                // 如果到达最大值时
+                if (nHour > hDate[hDate.length - 1]) {
+                    resetHour()
+                    if (Di === DDate.length - 1) {
+                        resetDay()
+                        if (Mi === MDate.length - 1) {
+                            resetMonth()
+                            continue goYear
+                        }
+                        continue goMonth
+                    }
+                    continue
+                }
+                // 判断日期的合法性,不合法的话也是跳出当前循环
+                if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true && dayRule.value !== 'workDay' && dayRule.value !== 'lastWeek' && dayRule.value !== 'lastDay') {
+                    resetDay()
+                    continue goMonth
+                }
+                // 如果日期规则中有值时
+                if (dayRule.value === 'lastDay') {
+                    // 如果不是合法日期则需要将前将日期调到合法日期即月末最后一天
+                    if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+                        while (DD > 0 && checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+                            DD--
+                            thisDD = DD < 10 ? '0' + DD : DD
+                        }
+                    }
+                } else if (dayRule.value === 'workDay') {
+                    // 校验并调整如果是2月30号这种日期传进来时需调整至正常月底
+                    if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+                        while (DD > 0 && checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+                            DD--
+                            thisDD = DD < 10 ? '0' + DD : DD
+                        }
+                    }
+                    // 获取达到条件的日期是星期X
+                    let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + thisDD + ' 00:00:00'), 'week')
+                    // 当星期日时
+                    if (thisWeek === 1) {
+                        // 先找下一个日,并判断是否为月底
+                        DD++
+                        thisDD = DD < 10 ? '0' + DD : DD
+                        // 判断下一日已经不是合法日期
+                        if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+                            DD -= 3
+                        }
+                    } else if (thisWeek === 7) {
+                        // 当星期6时只需判断不是1号就可进行操作
+                        if (dayRuleSup.value !== 1) {
+                            DD--
+                        } else {
+                            DD += 2
+                        }
+                    }
+                } else if (dayRule.value === 'weekDay') {
+                    // 如果指定了是星期几
+                    // 获取当前日期是属于星期几
+                    let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + DD + ' 00:00:00'), 'week')
+                    // 校验当前星期是否在星期池(dayRuleSup)中
+                    if (dayRuleSup.value.indexOf(thisWeek) < 0) {
+                        // 如果到达最大值时
+                        if (Di === DDate.length - 1) {
+                            resetDay()
+                            if (Mi === MDate.length - 1) {
+                                resetMonth()
+                                continue goYear
+                            }
+                            continue goMonth
+                        }
+                        continue
+                    }
+                } else if (dayRule.value === 'assWeek') {
+                    // 如果指定了是第几周的星期几
+                    // 获取每月1号是属于星期几
+                    let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + DD + ' 00:00:00'), 'week')
+                    if (dayRuleSup.value[1] >= thisWeek) {
+                        DD = (dayRuleSup.value[0] - 1) * 7 + dayRuleSup.value[1] - thisWeek + 1
+                    } else {
+                        DD = dayRuleSup.value[0] * 7 + dayRuleSup.value[1] - thisWeek + 1
+                    }
+                } else if (dayRule.value === 'lastWeek') {
+                    // 如果指定了每月最后一个星期几
+                    // 校验并调整如果是2月30号这种日期传进来时需调整至正常月底
+                    if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+                        while (DD > 0 && checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
+                            DD--
+                            thisDD = DD < 10 ? '0' + DD : DD
+                        }
+                    }
+                    // 获取月末最后一天是星期几
+                    let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + thisDD + ' 00:00:00'), 'week')
+                    // 找到要求中最近的那个星期几
+                    if (dayRuleSup.value < thisWeek) {
+                        DD -= thisWeek - dayRuleSup.value
+                    } else if (dayRuleSup.value > thisWeek) {
+                        DD -= 7 - (dayRuleSup.value - thisWeek)
+                    }
+                }
+                // 判断时间值是否小于10置换成“05”这种格式
+                DD = DD < 10 ? '0' + DD : DD
+                // 循环“时”数组
+                goHour: for (let hi = hIdx; hi < hDate.length; hi++) {
+                    let hh = hDate[hi] < 10 ? '0' + hDate[hi] : hDate[hi]
+                    // 如果到达最大值时
+                    if (nMin > mDate[mDate.length - 1]) {
+                        resetMin()
+                        if (hi === hDate.length - 1) {
+                            resetHour()
+                            if (Di === DDate.length - 1) {
+                                resetDay()
+                                if (Mi === MDate.length - 1) {
+                                    resetMonth()
+                                    continue goYear
+                                }
+                                continue goMonth
+                            }
+                            continue goDay
+                        }
+                        continue
+                    }
+                    // 循环"分"数组
+                    goMin: for (let mi = mIdx; mi < mDate.length; mi++) {
+                        let mm = mDate[mi] < 10 ? '0' + mDate[mi] : mDate[mi]
+                        // 如果到达最大值时
+                        if (nSecond > sDate[sDate.length - 1]) {
+                            resetSecond()
+                            if (mi === mDate.length - 1) {
+                                resetMin()
+                                if (hi === hDate.length - 1) {
+                                    resetHour()
+                                    if (Di === DDate.length - 1) {
+                                        resetDay()
+                                        if (Mi === MDate.length - 1) {
+                                            resetMonth()
+                                            continue goYear
+                                        }
+                                        continue goMonth
+                                    }
+                                    continue goDay
+                                }
+                                continue goHour
+                            }
+                            continue
+                        }
+                        // 循环"秒"数组
+                        goSecond: for (let si = sIdx; si <= sDate.length - 1; si++) {
+                            let ss = sDate[si] < 10 ? '0' + sDate[si] : sDate[si]
+                            // 添加当前时间(时间合法性在日期循环时已经判断)
+                            if (MM !== '00' && DD !== '00') {
+                                resultArr.push(YY + '-' + MM + '-' + DD + ' ' + hh + ':' + mm + ':' + ss)
+                                nums++
+                            }
+                            // 如果条数满了就退出循环
+                            if (nums === 5) break goYear
+                            // 如果到达最大值时
+                            if (si === sDate.length - 1) {
+                                resetSecond()
+                                if (mi === mDate.length - 1) {
+                                    resetMin()
+                                    if (hi === hDate.length - 1) {
+                                        resetHour()
+                                        if (Di === DDate.length - 1) {
+                                            resetDay()
+                                            if (Mi === MDate.length - 1) {
+                                                resetMonth()
+                                                continue goYear
+                                            }
+                                            continue goMonth
+                                        }
+                                        continue goDay
+                                    }
+                                    continue goHour
+                                }
+                                continue goMin
+                            }
+                        } //goSecond
+                    } //goMin
+                }//goHour
+            }//goDay
+        }//goMonth
+    }
+    // 判断100年内的结果条数
+    if (resultArr.length === 0) {
+        resultList.value = ['没有达到条件的结果!']
+    } else {
+        resultList.value = resultArr
+        if (resultArr.length !== 5) {
+            resultList.value.push('最近100年内只有上面' + resultArr.length + '条结果!')
+        }
+    }
+    // 计算完成-显示结果
+    isShow.value = true
+}
+// 用于计算某位数字在数组中的索引
+function getIndex(arr, value) {
+    if (value <= arr[0] || value > arr[arr.length - 1]) {
+        return 0
+    } else {
+        for (let i = 0; i < arr.length - 1; i++) {
+            if (value > arr[i] && value <= arr[i + 1]) {
+                return i + 1
+            }
+        }
+    }
+}
+// 获取"年"数组
+function getYearArr(rule, year) {
+    dateArr.value[5] = getOrderArr(year, year + 100)
+    if (rule !== undefined) {
+        if (rule.indexOf('-') >= 0) {
+            dateArr.value[5] = getCycleArr(rule, year + 100, false)
+        } else if (rule.indexOf('/') >= 0) {
+            dateArr.value[5] = getAverageArr(rule, year + 100)
+        } else if (rule !== '*') {
+            dateArr.value[5] = getAssignArr(rule)
+        }
+    }
+}
+// 获取"月"数组
+function getMonthArr(rule) {
+    dateArr.value[4] = getOrderArr(1, 12)
+    if (rule.indexOf('-') >= 0) {
+        dateArr.value[4] = getCycleArr(rule, 12, false)
+    } else if (rule.indexOf('/') >= 0) {
+        dateArr.value[4] = getAverageArr(rule, 12)
+    } else if (rule !== '*') {
+        dateArr.value[4] = getAssignArr(rule)
+    }
+}
+// 获取"日"数组-主要为日期规则
+function getWeekArr(rule) {
+    // 只有当日期规则的两个值均为“”时则表达日期是有选项的
+    if (dayRule.value === '' && dayRuleSup.value === '') {
+        if (rule.indexOf('-') >= 0) {
+            dayRule.value = 'weekDay'
+            dayRuleSup.value = getCycleArr(rule, 7, false)
+        } else if (rule.indexOf('#') >= 0) {
+            dayRule.value = 'assWeek'
+            let matchRule = rule.match(/[0-9]{1}/g)
+            dayRuleSup.value = [Number(matchRule[1]), Number(matchRule[0])]
+            dateArr.value[3] = [1]
+            if (dayRuleSup.value[1] === 7) {
+                dayRuleSup.value[1] = 0
+            }
+        } else if (rule.indexOf('L') >= 0) {
+            dayRule.value = 'lastWeek'
+            dayRuleSup.value = Number(rule.match(/[0-9]{1,2}/g)[0])
+            dateArr.value[3] = [31]
+            if (dayRuleSup.value === 7) {
+                dayRuleSup.value = 0
+            }
+        } else if (rule !== '*' && rule !== '?') {
+            dayRule.value = 'weekDay'
+            dayRuleSup.value = getAssignArr(rule)
+        }
+    }
+}
+// 获取"日"数组-少量为日期规则
+function getDayArr(rule) {
+    dateArr.value[3] = getOrderArr(1, 31)
+    dayRule.value = ''
+    dayRuleSup.value = ''
+    if (rule.indexOf('-') >= 0) {
+        dateArr.value[3] = getCycleArr(rule, 31, false)
+        dayRuleSup.value = 'null'
+    } else if (rule.indexOf('/') >= 0) {
+        dateArr.value[3] = getAverageArr(rule, 31)
+        dayRuleSup.value = 'null'
+    } else if (rule.indexOf('W') >= 0) {
+        dayRule.value = 'workDay'
+        dayRuleSup.value = Number(rule.match(/[0-9]{1,2}/g)[0])
+        dateArr.value[3] = [dayRuleSup.value]
+    } else if (rule.indexOf('L') >= 0) {
+        dayRule.value = 'lastDay'
+        dayRuleSup.value = 'null'
+        dateArr.value[3] = [31]
+    } else if (rule !== '*' && rule !== '?') {
+        dateArr.value[3] = getAssignArr(rule)
+        dayRuleSup.value = 'null'
+    } else if (rule === '*') {
+        dayRuleSup.value = 'null'
+    }
+}
+// 获取"时"数组
+function getHourArr(rule) {
+    dateArr.value[2] = getOrderArr(0, 23)
+    if (rule.indexOf('-') >= 0) {
+        dateArr.value[2] = getCycleArr(rule, 24, true)
+    } else if (rule.indexOf('/') >= 0) {
+        dateArr.value[2] = getAverageArr(rule, 23)
+    } else if (rule !== '*') {
+        dateArr.value[2] = getAssignArr(rule)
+    }
+}
+// 获取"分"数组
+function getMinArr(rule) {
+    dateArr.value[1] = getOrderArr(0, 59)
+    if (rule.indexOf('-') >= 0) {
+        dateArr.value[1] = getCycleArr(rule, 60, true)
+    } else if (rule.indexOf('/') >= 0) {
+        dateArr.value[1] = getAverageArr(rule, 59)
+    } else if (rule !== '*') {
+        dateArr.value[1] = getAssignArr(rule)
+    }
+}
+// 获取"秒"数组
+function getSecondArr(rule) {
+    dateArr.value[0] = getOrderArr(0, 59)
+    if (rule.indexOf('-') >= 0) {
+        dateArr.value[0] = getCycleArr(rule, 60, true)
+    } else if (rule.indexOf('/') >= 0) {
+        dateArr.value[0] = getAverageArr(rule, 59)
+    } else if (rule !== '*') {
+        dateArr.value[0] = getAssignArr(rule)
+    }
+}
+// 根据传进来的min-max返回一个顺序的数组
+function getOrderArr(min, max) {
+    let arr = []
+    for (let i = min; i <= max; i++) {
+        arr.push(i)
+    }
+    return arr
+}
+// 根据规则中指定的零散值返回一个数组
+function getAssignArr(rule) {
+    let arr = []
+    let assiginArr = rule.split(',')
+    for (let i = 0; i < assiginArr.length; i++) {
+        arr[i] = Number(assiginArr[i])
+    }
+    arr.sort(compare)
+    return arr
+}
+// 根据一定算术规则计算返回一个数组
+function getAverageArr(rule, limit) {
+    let arr = []
+    let agArr = rule.split('/')
+    let min = Number(agArr[0])
+    let step = Number(agArr[1])
+    while (min <= limit) {
+        arr.push(min)
+        min += step
+    }
+    return arr
+}
+// 根据规则返回一个具有周期性的数组
+function getCycleArr(rule, limit, status) {
+    // status--表示是否从0开始(则从1开始)
+    let arr = []
+    let cycleArr = rule.split('-')
+    let min = Number(cycleArr[0])
+    let max = Number(cycleArr[1])
+    if (min > max) {
+        max += limit
+    }
+    for (let i = min; i <= max; i++) {
+        let add = 0
+        if (status === false && i % limit === 0) {
+            add = limit
+        }
+        arr.push(Math.round(i % limit + add))
+    }
+    arr.sort(compare)
+    return arr
+}
+// 比较数字大小(用于Array.sort)
+function compare(value1, value2) {
+    if (value2 - value1 > 0) {
+        return -1
+    } else {
+        return 1
+    }
+}
+// 格式化日期格式如:2017-9-19 18:04:33
+function formatDate(value, type) {
+    // 计算日期相关值
+    let time = typeof value == 'number' ? new Date(value) : value
+    let Y = time.getFullYear()
+    let M = time.getMonth() + 1
+    let D = time.getDate()
+    let h = time.getHours()
+    let m = time.getMinutes()
+    let s = time.getSeconds()
+    let week = time.getDay()
+    // 如果传递了type的话
+    if (type === undefined) {
+        return Y + '-' + (M < 10 ? '0' + M : M) + '-' + (D < 10 ? '0' + D : D) + ' ' + (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
+    } else if (type === 'week') {
+        // 在quartz中 1为星期日
+        return week + 1
+    }
+}
+// 检查日期是否存在
+function checkDate(value) {
+    let time = new Date(value)
+    let format = formatDate(time)
+    return value === format
+}
+onMounted(() => {
+    expressionChange()
+})
+</script>

+ 128 - 0
jd-logistics-ui-vue3/src/components/Crontab/second.vue

@@ -0,0 +1,128 @@
+<template>
+    <el-form>
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="1">
+                秒,允许的通配符[, - * /]
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="2">
+                周期从
+                <el-input-number v-model='cycle01' :min="0" :max="58" /> -
+                <el-input-number v-model='cycle02' :min="cycle01 + 1" :max="59" /> 秒
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="3">
+                从
+                <el-input-number v-model='average01' :min="0" :max="58" /> 秒开始,每
+                <el-input-number v-model='average02' :min="1" :max="59 - average01" /> 秒执行一次
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="4">
+                指定
+                <el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="10">
+                    <el-option v-for="item in 60" :key="item" :label="item - 1" :value="item - 1" />
+                </el-select>
+            </el-radio>
+        </el-form-item>
+    </el-form>
+</template>
+
+<script setup>
+const emit = defineEmits(['update'])
+const props = defineProps({
+    cron: {
+        type: Object,
+        default: {
+            second: "*",
+            min: "*",
+            hour: "*",
+            day: "*",
+            month: "*",
+            week: "?",
+            year: "",
+        }
+    },
+    check: {
+        type: Function,
+        default: () => {
+        }
+    }
+})
+const radioValue = ref(1)
+const cycle01 = ref(0)
+const cycle02 = ref(1)
+const average01 = ref(0)
+const average02 = ref(1)
+const checkboxList = ref([])
+const checkCopy = ref([0])
+const cycleTotal = computed(() => {
+    cycle01.value = props.check(cycle01.value, 0, 58)
+    cycle02.value = props.check(cycle02.value, cycle01.value + 1, 59)
+    return cycle01.value + '-' + cycle02.value
+})
+const averageTotal = computed(() => {
+    average01.value = props.check(average01.value, 0, 58)
+    average02.value = props.check(average02.value, 1, 59 - average01.value)
+    return average01.value + '/' + average02.value
+})
+const checkboxString = computed(() => {
+    return checkboxList.value.join(',')
+})
+watch(() => props.cron.second, value => changeRadioValue(value))
+watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
+function changeRadioValue(value) {
+    if (value === '*') {
+        radioValue.value = 1
+    } else if (value.indexOf('-') > -1) {
+        const indexArr = value.split('-')
+        cycle01.value = Number(indexArr[0])
+        cycle02.value = Number(indexArr[1])
+        radioValue.value = 2
+    } else if (value.indexOf('/') > -1) {
+        const indexArr = value.split('/')
+        average01.value = Number(indexArr[0])
+        average02.value = Number(indexArr[1])
+        radioValue.value = 3
+    } else {
+        checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
+        radioValue.value = 4
+    }
+}
+// 单选按钮值变化时
+function onRadioChange() {
+    switch (radioValue.value) {
+        case 1:
+            emit('update', 'second', '*', 'second')
+            break
+        case 2:
+            emit('update', 'second', cycleTotal.value, 'second')
+            break
+        case 3:
+            emit('update', 'second', averageTotal.value, 'second')
+            break
+        case 4:
+            if (checkboxList.value.length === 0) {
+                checkboxList.value.push(checkCopy.value[0])
+            } else {
+                checkCopy.value = checkboxList.value
+            }
+            emit('update', 'second', checkboxString.value, 'second')
+            break
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.el-input-number--small, .el-select, .el-select--small {
+    margin: 0 0.2rem;
+}
+.el-select, .el-select--small {
+    width: 18.8rem;
+}
+</style>

+ 197 - 0
jd-logistics-ui-vue3/src/components/Crontab/week.vue

@@ -0,0 +1,197 @@
+<template>
+    <el-form>
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="1">
+                周,允许的通配符[, - * ? / L #]
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="2">
+                不指定
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="3">
+                周期从
+                <el-select clearable v-model="cycle01">
+                    <el-option
+                        v-for="(item,index) of weekList"
+                        :key="index"
+                        :label="item.value"
+                        :value="item.key"
+                        :disabled="item.key === 7"
+                    >{{item.value}}</el-option>
+                </el-select>
+                -
+                <el-select clearable v-model="cycle02">
+                    <el-option
+                        v-for="(item,index) of weekList"
+                        :key="index"
+                        :label="item.value"
+                        :value="item.key"
+                        :disabled="item.key <= cycle01"
+                    >{{item.value}}</el-option>
+                </el-select>
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="4">
+                第
+                <el-input-number v-model='average01' :min="1" :max="4" /> 周的
+                <el-select clearable v-model="average02">
+                    <el-option v-for="item in weekList" :key="item.key" :label="item.value" :value="item.key" />
+                </el-select>
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="5">
+                本月最后一个
+                <el-select clearable v-model="weekday">
+                    <el-option v-for="item in weekList" :key="item.key" :label="item.value" :value="item.key" />
+                </el-select>
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio v-model='radioValue' :value="6">
+                指定
+                <el-select class="multiselect" clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="6">
+                    <el-option v-for="item in weekList" :key="item.key" :label="item.value" :value="item.key" />
+                </el-select>
+            </el-radio>
+        </el-form-item>
+
+    </el-form>
+</template>
+
+<script setup>
+const emit = defineEmits(['update'])
+const props = defineProps({
+    cron: {
+        type: Object,
+        default: {
+            second: "*",
+            min: "*",
+            hour: "*",
+            day: "*",
+            month: "*",
+            week: "?",
+            year: ""
+        }
+    },
+    check: {
+        type: Function,
+        default: () => {
+        }
+    }
+})
+const radioValue = ref(2)
+const cycle01 = ref(2)
+const cycle02 = ref(3)
+const average01 = ref(1)
+const average02 = ref(2)
+const weekday = ref(2)
+const checkboxList = ref([])
+const checkCopy = ref([2])
+const weekList = ref([
+    {key: 1, value: '星期日'},
+    {key: 2, value: '星期一'},
+    {key: 3, value: '星期二'},
+    {key: 4, value: '星期三'},
+    {key: 5, value: '星期四'},
+    {key: 6, value: '星期五'},
+    {key: 7, value: '星期六'}
+])
+const cycleTotal = computed(() => {
+    cycle01.value = props.check(cycle01.value, 1, 6)
+    cycle02.value = props.check(cycle02.value, cycle01.value + 1, 7)
+    return cycle01.value + '-' + cycle02.value
+})
+const averageTotal = computed(() => {
+    average01.value = props.check(average01.value, 1, 4)
+    average02.value = props.check(average02.value, 1, 7)
+    return average02.value + '#' + average01.value
+})
+const weekdayTotal = computed(() => {
+    weekday.value = props.check(weekday.value, 1, 7)
+    return weekday.value + 'L'
+})
+const checkboxString = computed(() => {
+    return checkboxList.value.join(',')
+})
+watch(() => props.cron.week, value => changeRadioValue(value))
+watch([radioValue, cycleTotal, averageTotal, weekdayTotal, checkboxString], () => onRadioChange())
+function changeRadioValue(value) {
+    if (value === "*") {
+        radioValue.value = 1
+    } else if (value === "?") {
+        radioValue.value = 2
+    } else if (value.indexOf("-") > -1) {
+        const indexArr = value.split('-')
+        cycle01.value = Number(indexArr[0])
+        cycle02.value = Number(indexArr[1])
+        radioValue.value = 3
+    } else if (value.indexOf("#") > -1) {
+        const indexArr = value.split('#')
+        average01.value = Number(indexArr[1])
+        average02.value = Number(indexArr[0])
+        radioValue.value = 4
+    } else if (value.indexOf("L") > -1) {
+        const indexArr = value.split("L")
+        weekday.value = Number(indexArr[0])
+        radioValue.value = 5
+    } else {
+        checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
+        radioValue.value = 6
+    }
+}
+function onRadioChange() {
+    if (radioValue.value === 2 && props.cron.day === '?') {
+        emit('update', 'day', '*', 'week')
+    }
+    if (radioValue.value !== 2 && props.cron.day !== '?') {
+        emit('update', 'day', '?', 'week')
+    }
+    switch (radioValue.value) {
+        case 1:
+            emit('update', 'week', '*', 'week')
+            break
+        case 2:
+            emit('update', 'week', '?', 'week')
+            break
+        case 3:
+            emit('update', 'week', cycleTotal.value, 'week')
+            break
+        case 4:
+            emit('update', 'week', averageTotal.value, 'week')
+            break
+        case 5:
+            emit('update', 'week', weekdayTotal.value, 'week')
+            break
+        case 6:
+            if (checkboxList.value.length === 0) {
+                checkboxList.value.push(checkCopy.value[0])
+            } else {
+                checkCopy.value = checkboxList.value
+            }
+            emit('update', 'week', checkboxString.value, 'week')
+            break
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.el-input-number--small, .el-select, .el-select--small {
+    margin: 0 0.5rem;
+}
+.el-select, .el-select--small {
+    width: 8rem;
+}
+.el-select.multiselect, .el-select--small.multiselect {
+    width: 17.8rem;
+}
+</style>

+ 143 - 0
jd-logistics-ui-vue3/src/components/Crontab/year.vue

@@ -0,0 +1,143 @@
+<template>
+    <el-form>
+        <el-form-item>
+            <el-radio :value="1" v-model='radioValue'>
+                不填,允许的通配符[, - * /]
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio :value="2" v-model='radioValue'>
+                每年
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio :value="3" v-model='radioValue'>
+                周期从
+                <el-input-number v-model='cycle01' :min='fullYear' :max="2098"/> -
+                <el-input-number v-model='cycle02' :min="cycle01 ? cycle01 + 1 : fullYear + 1" :max="2099"/>
+            </el-radio>
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio :value="4" v-model='radioValue'>
+                从
+                <el-input-number v-model='average01' :min='fullYear' :max="2098"/> 年开始,每
+                <el-input-number v-model='average02' :min="1" :max="2099 - average01 || fullYear"/> 年执行一次
+            </el-radio>
+
+        </el-form-item>
+
+        <el-form-item>
+            <el-radio :value="5" v-model='radioValue'>
+                指定
+                <el-select clearable v-model="checkboxList" placeholder="可多选" multiple :multiple-limit="8">
+                    <el-option v-for="item in 9" :key="item" :value="item - 1 + fullYear" :label="item -1 + fullYear" />
+                </el-select>
+            </el-radio>
+        </el-form-item>
+    </el-form>
+</template>
+
+<script setup>
+const emit = defineEmits(['update'])
+const props = defineProps({
+    cron: {
+        type: Object,
+        default: {
+            second: "*",
+            min: "*",
+            hour: "*",
+            day: "*",
+            month: "*",
+            week: "?",
+            year: ""
+        }
+    },
+    check: {
+        type: Function,
+        default: () => {
+        }
+    }
+})
+
+const fullYear = Number(new Date().getFullYear())
+const maxFullYear = fullYear + 10
+const radioValue = ref(1)
+const cycle01 = ref(fullYear)
+const cycle02 = ref(fullYear + 1)
+const average01 = ref(fullYear)
+const average02 = ref(1)
+const checkboxList = ref([])
+const checkCopy = ref([fullYear])
+
+const cycleTotal = computed(() => {
+    cycle01.value = props.check(cycle01.value, fullYear, maxFullYear - 1)
+    cycle02.value = props.check(cycle02.value, cycle01.value + 1, maxFullYear)
+    return cycle01.value + '-' + cycle02.value
+})
+const averageTotal = computed(() => {
+    average01.value = props.check(average01.value, fullYear, maxFullYear - 1)
+    average02.value = props.check(average02.value, 1, 10)
+    return average01.value + '/' + average02.value
+})
+const checkboxString = computed(() => {
+    return checkboxList.value.join(',')
+})
+watch(() => props.cron.year, value => changeRadioValue(value))
+watch([radioValue, cycleTotal, averageTotal, checkboxString], () => onRadioChange())
+function changeRadioValue(value) {
+    if (value === '') {
+        radioValue.value = 1
+    } else if (value === "*") {
+        radioValue.value = 2
+    } else if (value.indexOf("-") > -1) {
+        const indexArr = value.split('-')
+        cycle01.value = Number(indexArr[0])
+        cycle02.value = Number(indexArr[1])
+        radioValue.value = 3
+    } else if (value.indexOf("/") > -1) {
+        const indexArr = value.split('/')
+        average01.value = Number(indexArr[0])
+        average02.value = Number(indexArr[1])
+        radioValue.value = 4
+    } else {
+        checkboxList.value = [...new Set(value.split(',').map(item => Number(item)))]
+        radioValue.value = 5
+    }
+}
+function onRadioChange() {
+    switch (radioValue.value) {
+        case 1:
+            emit('update', 'year', '', 'year')
+            break
+        case 2:
+            emit('update', 'year', '*', 'year')
+            break
+        case 3:
+            emit('update', 'year', cycleTotal.value, 'year')
+            break
+        case 4:
+            emit('update', 'year', averageTotal.value, 'year')
+            break
+        case 5:
+            if (checkboxList.value.length === 0) {
+                checkboxList.value.push(checkCopy.value[0])
+            } else {
+                checkCopy.value = checkboxList.value
+            }
+            emit('update', 'year', checkboxString.value, 'year')
+            break
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.el-input-number--small, .el-select, .el-select--small {
+    margin: 0 0.2rem;
+}
+.el-select, .el-select--small {
+    width: 18.8rem;
+}
+</style>

+ 87 - 0
jd-logistics-ui-vue3/src/components/DictTag/index.vue

@@ -0,0 +1,87 @@
+<template>
+  <div>
+    <template v-for="(item, index) in options">
+      <template v-if="isValueMatch(item.value)">
+        <span
+          v-if="(item.elTagType == 'default' || item.elTagType == '') && (item.elTagClass == '' || item.elTagClass == null)"
+          :key="item.value"
+          :index="index"
+          :class="item.elTagClass"
+        >{{ item.label + " " }}</span>
+        <el-tag
+          v-else
+          :disable-transitions="true"
+          :key="item.value + ''"
+          :index="index"
+          :type="item.elTagType"
+          :class="item.elTagClass"
+        >{{ item.label + " " }}</el-tag>
+      </template>
+    </template>
+    <template v-if="unmatch && showValue">
+      {{ unmatchArray | handleArray }}
+    </template>
+  </div>
+</template>
+
+<script setup>
+// 记录未匹配的项
+const unmatchArray = ref([])
+
+const props = defineProps({
+  // 数据
+  options: {
+    type: Array,
+    default: null,
+  },
+  // 当前的值
+  value: [Number, String, Array],
+  // 当未找到匹配的数据时,显示value
+  showValue: {
+    type: Boolean,
+    default: true,
+  },
+  separator: {
+    type: String,
+    default: ",",
+  }
+})
+
+const values = computed(() => {
+  if (props.value === null || typeof props.value === 'undefined' || props.value === '') return []
+  if (typeof props.value === 'number' || typeof props.value === 'boolean') return [props.value]
+  return Array.isArray(props.value) ? props.value.map(item => '' + item) : String(props.value).split(props.separator)
+})
+
+const unmatch = computed(() => {
+  unmatchArray.value = []
+  // 没有value不显示
+  if (props.value === null || typeof props.value === 'undefined' || props.value === '' || !Array.isArray(props.options) || props.options.length === 0) return false
+  // 传入值为数组
+  let unmatch = false // 添加一个标志来判断是否有未匹配项
+  values.value.forEach(item => {
+    if (!props.options.some(v => v.value == item)) {
+      unmatchArray.value.push(item)
+      unmatch = true // 如果有未匹配项,将标志设置为true
+    }
+  })
+  return unmatch // 返回标志的值
+})
+
+function handleArray(array) {
+  if (array.length === 0) return ""
+  return array.reduce((pre, cur) => {
+    return pre + " " + cur
+  })
+}
+
+function isValueMatch(itemValue) {
+  return values.value.some(val => val == itemValue)
+}
+</script>
+
+<style scoped>
+.el-tag + .el-tag {
+  margin-left: 10px;
+}
+</style>

+ 276 - 0
jd-logistics-ui-vue3/src/components/Editor/index.vue

@@ -0,0 +1,276 @@
+<template>
+  <div>
+    <el-upload
+      :action="uploadUrl"
+      :before-upload="handleBeforeUpload"
+      :on-success="handleUploadSuccess"
+      :on-error="handleUploadError"
+      name="file"
+      :show-file-list="false"
+      :headers="headers"
+      class="editor-img-uploader"
+      v-if="type == 'url'"
+    >
+      <i ref="uploadRef" class="editor-img-uploader"></i>
+    </el-upload>
+  </div>
+  <div class="editor">
+    <quill-editor
+      ref="quillEditorRef"
+      v-model:content="content"
+      contentType="html"
+      @textChange="(e) => $emit('update:modelValue', content)"
+      :options="options"
+      :style="styles"
+    />
+  </div>
+</template>
+
+<script setup>
+import axios from 'axios'
+import { QuillEditor } from "@vueup/vue-quill"
+import "@vueup/vue-quill/dist/vue-quill.snow.css"
+import { getToken } from "@/utils/auth"
+
+const { proxy } = getCurrentInstance()
+
+const quillEditorRef = ref()
+const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + "/file/upload") // 上传的图片服务器地址
+const headers = ref({
+  Authorization: "Bearer " + getToken()
+})
+
+const props = defineProps({
+  /* 编辑器的内容 */
+  modelValue: {
+    type: String,
+  },
+  /* 高度 */
+  height: {
+    type: Number,
+    default: null,
+  },
+  /* 最小高度 */
+  minHeight: {
+    type: Number,
+    default: null,
+  },
+  /* 只读 */
+  readOnly: {
+    type: Boolean,
+    default: false,
+  },
+  /* 上传文件大小限制(MB) */
+  fileSize: {
+    type: Number,
+    default: 5,
+  },
+  /* 类型(base64格式、url格式) */
+  type: {
+    type: String,
+    default: "url",
+  }
+})
+
+const options = ref({
+  theme: "snow",
+  bounds: document.body,
+  debug: "warn",
+  modules: {
+    // 工具栏配置
+    toolbar: [
+      ["bold", "italic", "underline", "strike"],      // 加粗 斜体 下划线 删除线
+      ["blockquote", "code-block"],                   // 引用  代码块
+      [{ list: "ordered" }, { list: "bullet" }],      // 有序、无序列表
+      [{ indent: "-1" }, { indent: "+1" }],           // 缩进
+      [{ size: ["small", false, "large", "huge"] }],  // 字体大小
+      [{ header: [1, 2, 3, 4, 5, 6, false] }],        // 标题
+      [{ color: [] }, { background: [] }],            // 字体颜色、字体背景颜色
+      [{ align: [] }],                                // 对齐方式
+      ["clean"],                                      // 清除文本格式
+      ["link", "image", "video"]                      // 链接、图片、视频
+    ],
+  },
+  placeholder: "请输入内容",
+  readOnly: props.readOnly
+})
+
+const styles = computed(() => {
+  let style = {}
+  if (props.minHeight) {
+    style.minHeight = `${props.minHeight}px`
+  }
+  if (props.height) {
+    style.height = `${props.height}px`
+  }
+  return style
+})
+
+const content = ref("")
+watch(() => props.modelValue, (v) => {
+  if (v !== content.value) {
+    content.value = v == undefined ? "<p></p>" : v
+  }
+}, { immediate: true })
+
+// 如果设置了上传地址则自定义图片上传事件
+onMounted(() => {
+  if (props.type == 'url') {
+    let quill = quillEditorRef.value.getQuill()
+    let toolbar = quill.getModule("toolbar")
+    toolbar.addHandler("image", (value) => {
+      if (value) {
+        proxy.$refs.uploadRef.click()
+      } else {
+        quill.format("image", false)
+      }
+    })
+    quill.root.addEventListener('paste', handlePasteCapture, true)
+  }
+})
+
+// 上传前校检格式和大小
+function handleBeforeUpload(file) {
+  const type = ["image/jpeg", "image/jpg", "image/png", "image/svg"]
+  const isJPG = type.includes(file.type)
+  //检验文件格式
+  if (!isJPG) {
+    proxy.$modal.msgError(`图片格式错误!`)
+    return false
+  }
+  // 校检文件大小
+  if (props.fileSize) {
+    const isLt = file.size / 1024 / 1024 < props.fileSize
+    if (!isLt) {
+      proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`)
+      return false
+    }
+  }
+  return true
+}
+
+// 上传成功处理
+function handleUploadSuccess(res, file) {
+  // 如果上传成功
+  if (res.code == 200) {
+    // 获取富文本实例
+    let quill = toRaw(quillEditorRef.value).getQuill()
+    // 获取光标位置
+    let length = quill.selection.savedRange.index
+    // 插入图片,res.url为服务器返回的图片链接地址
+    quill.insertEmbed(length, "image", res.data.url)
+    // 调整光标到最后
+    quill.setSelection(length + 1)
+  } else {
+    proxy.$modal.msgError("图片插入失败")
+  }
+}
+
+// 上传失败处理
+function handleUploadError() {
+  proxy.$modal.msgError("图片插入失败")
+}
+
+// 复制粘贴图片处理
+function handlePasteCapture(e) {
+  const clipboard = e.clipboardData || window.clipboardData
+  if (clipboard && clipboard.items) {
+    for (let i = 0; i < clipboard.items.length; i++) {
+      const item = clipboard.items[i]
+      if (item.type.indexOf('image') !== -1) {
+        e.preventDefault()
+        const file = item.getAsFile()
+        insertImage(file)
+      }
+    }
+  }
+}
+
+function insertImage(file) {
+  const formData = new FormData()
+  formData.append("file", file)
+  axios.post(uploadUrl.value, formData, { headers: { "Content-Type": "multipart/form-data", Authorization: headers.value.Authorization } }).then(res => {
+    handleUploadSuccess(res.data)
+  })
+}
+</script>
+
+<style>
+.editor-img-uploader {
+  display: none;
+}
+.editor, .ql-toolbar {
+  white-space: pre-wrap !important;
+  line-height: normal !important;
+}
+.quill-img {
+  display: none;
+}
+.ql-snow .ql-tooltip[data-mode="link"]::before {
+  content: "请输入链接地址:";
+}
+.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
+  border-right: 0px;
+  content: "保存";
+  padding-right: 0px;
+}
+.ql-snow .ql-tooltip[data-mode="video"]::before {
+  content: "请输入视频地址:";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item::before {
+  content: "14px";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="small"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="small"]::before {
+  content: "10px";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="large"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="large"]::before {
+  content: "18px";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="huge"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="huge"]::before {
+  content: "32px";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item::before {
+  content: "文本";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
+  content: "标题1";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
+  content: "标题2";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
+  content: "标题3";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
+  content: "标题4";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
+  content: "标题5";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
+  content: "标题6";
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item::before {
+  content: "标准字体";
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="serif"]::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="serif"]::before {
+  content: "衬线字体";
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="monospace"]::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="monospace"]::before {
+  content: "等宽字体";
+}
+</style>

+ 255 - 0
jd-logistics-ui-vue3/src/components/FileUpload/index.vue

@@ -0,0 +1,255 @@
+<template>
+  <div class="upload-file">
+    <el-upload
+      multiple
+      :action="uploadFileUrl"
+      :before-upload="handleBeforeUpload"
+      :file-list="fileList"
+      :data="data"
+      :limit="limit"
+      :on-error="handleUploadError"
+      :on-exceed="handleExceed"
+      :on-success="handleUploadSuccess"
+      :show-file-list="false"
+      :headers="headers"
+      class="upload-file-uploader"
+      ref="fileUpload"
+      v-if="!disabled"
+    >
+      <!-- 上传按钮 -->
+      <el-button type="primary">选取文件</el-button>
+    </el-upload>
+    <!-- 上传提示 -->
+    <div class="el-upload__tip" v-if="showTip && !disabled">
+      请上传
+      <template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
+      <template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
+      的文件
+    </div>
+    <!-- 文件列表 -->
+    <transition-group ref="uploadFileList" class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
+      <li :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in fileList">
+        <el-link :href="file.url" :underline="false" target="_blank">
+          <span class="el-icon-document"> {{ getFileName(file.name) }} </span>
+        </el-link>
+        <div class="ele-upload-list__item-content-action">
+          <el-link :underline="false" @click="handleDelete(index)" type="danger" v-if="!disabled">&nbsp;删除</el-link>
+        </div>
+      </li>
+    </transition-group>
+  </div>
+</template>
+
+<script setup>
+import { getToken } from "@/utils/auth"
+import Sortable from 'sortablejs'
+
+const props = defineProps({
+  modelValue: [String, Object, Array],
+  // 上传接口地址
+  action: {
+    type: String,
+    default: "/file/upload"
+  },
+  // 上传携带的参数
+  data: {
+    type: Object
+  },
+  // 数量限制
+  limit: {
+    type: Number,
+    default: 5
+  },
+  // 大小限制(MB)
+  fileSize: {
+    type: Number,
+    default: 5
+  },
+  // 文件类型, 例如['png', 'jpg', 'jpeg']
+  fileType: {
+    type: Array,
+    default: () => ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "pdf"]
+  },
+  // 是否显示提示
+  isShowTip: {
+    type: Boolean,
+    default: true
+  },
+  // 禁用组件(仅查看文件)
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  // 拖动排序
+  drag: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const { proxy } = getCurrentInstance()
+const emit = defineEmits()
+const number = ref(0)
+const uploadList = ref([])
+const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + props.action) // 上传文件服务器地址
+const headers = ref({ Authorization: "Bearer " + getToken() })
+const fileList = ref([])
+const showTip = computed(
+  () => props.isShowTip && (props.fileType || props.fileSize)
+)
+
+watch(() => props.modelValue, val => {
+  if (val) {
+    let temp = 1
+    // 首先将值转为数组
+    const list = Array.isArray(val) ? val : props.modelValue.split(',')
+    // 然后将数组转为对象数组
+    fileList.value = list.map(item => {
+      if (typeof item === "string") {
+        item = { name: item, url: item }
+      }
+      item.uid = item.uid || new Date().getTime() + temp++
+      return item
+    })
+  } else {
+    fileList.value = []
+    return []
+  }
+},{ deep: true, immediate: true })
+
+// 上传前校检格式和大小
+function handleBeforeUpload(file) {
+  // 校检文件类型
+  if (props.fileType.length) {
+    const fileName = file.name.split('.')
+    const fileExt = fileName[fileName.length - 1]
+    const isTypeOk = props.fileType.indexOf(fileExt) >= 0
+    if (!isTypeOk) {
+      proxy.$modal.msgError(`文件格式不正确,请上传${props.fileType.join("/")}格式文件!`)
+      return false
+    }
+  }
+  // 校检文件名是否包含特殊字符
+  if (file.name.includes(',')) {
+    proxy.$modal.msgError('文件名不正确,不能包含英文逗号!')
+    return false
+  }
+  // 校检文件大小
+  if (props.fileSize) {
+    const isLt = file.size / 1024 / 1024 < props.fileSize
+    if (!isLt) {
+      proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`)
+      return false
+    }
+  }
+  proxy.$modal.loading("正在上传文件,请稍候...")
+  number.value++
+  return true
+}
+
+// 文件个数超出
+function handleExceed() {
+  proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`)
+}
+
+// 上传失败
+function handleUploadError(err) {
+  proxy.$modal.msgError("上传文件失败")
+  proxy.$modal.closeLoading()
+}
+
+// 上传成功回调
+function handleUploadSuccess(res, file) {
+  if (res.code === 200) {
+    uploadList.value.push({ name: res.data.url, url: res.data.url })
+    uploadedSuccessfully()
+  } else {
+    number.value--
+    proxy.$modal.closeLoading()
+    proxy.$modal.msgError(res.msg)
+    proxy.$refs.fileUpload.handleRemove(file)
+    uploadedSuccessfully()
+  }
+}
+
+// 删除文件
+function handleDelete(index) {
+  fileList.value.splice(index, 1)
+  emit("update:modelValue", listToString(fileList.value))
+}
+
+// 上传结束处理
+function uploadedSuccessfully() {
+  if (number.value > 0 && uploadList.value.length === number.value) {
+    fileList.value = fileList.value.filter(f => f.url !== undefined).concat(uploadList.value)
+    uploadList.value = []
+    number.value = 0
+    emit("update:modelValue", listToString(fileList.value))
+    proxy.$modal.closeLoading()
+  }
+}
+
+// 获取文件名称
+function getFileName(name) {
+  // 如果是url那么取最后的名字 如果不是直接返回
+  if (name.lastIndexOf("/") > -1) {
+    return name.slice(name.lastIndexOf("/") + 1)
+  } else {
+    return name
+  }
+}
+
+// 对象转成指定字符串分隔
+function listToString(list, separator) {
+  let strs = ""
+  separator = separator || ","
+  for (let i in list) {
+    if (list[i].url) {
+      strs += list[i].url + separator
+    }
+  }
+  return strs != '' ? strs.substr(0, strs.length - 1) : ''
+}
+
+// 初始化拖拽排序
+onMounted(() => {
+  if (props.drag && !props.disabled) {
+    nextTick(() => {
+      const element = proxy.$refs.uploadFileList?.$el || proxy.$refs.uploadFileList
+      Sortable.create(element, {
+        ghostClass: 'file-upload-darg',
+        onEnd: (evt) => {
+          const movedItem = fileList.value.splice(evt.oldIndex, 1)[0]
+          fileList.value.splice(evt.newIndex, 0, movedItem)
+          emit('update:modelValue', listToString(fileList.value))
+        }
+      })
+    })
+  }
+})
+</script>
+<style scoped lang="scss">
+.file-upload-darg {
+  opacity: 0.5;
+  background: #c8ebfb;
+}
+.upload-file-uploader {
+  margin-bottom: 5px;
+}
+.upload-file-list .el-upload-list__item {
+  border: 1px solid #e4e7ed;
+  line-height: 2;
+  margin-bottom: 10px;
+  position: relative;
+  transition: none !important;
+}
+.upload-file-list .ele-upload-list__item-content {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  color: inherit;
+}
+.ele-upload-list__item-content-action .el-link {
+  margin-right: 10px;
+}
+</style>

+ 42 - 0
jd-logistics-ui-vue3/src/components/Hamburger/index.vue

@@ -0,0 +1,42 @@
+<template>
+  <div style="padding: 0 15px;" @click="toggleClick">
+    <svg
+      :class="{'is-active':isActive}"
+      class="hamburger"
+      viewBox="0 0 1024 1024"
+      xmlns="http://www.w3.org/2000/svg"
+      width="64"
+      height="64"
+      fill="currentColor"
+    >
+      <path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
+    </svg>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  isActive: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits()
+const toggleClick = () => {
+  emit('toggleClick')
+}
+</script>
+
+<style scoped>
+.hamburger {
+  display: inline-block;
+  vertical-align: middle;
+  width: 20px;
+  height: 20px;
+}
+
+.hamburger.is-active {
+  transform: rotate(180deg);
+}
+</style>

+ 252 - 0
jd-logistics-ui-vue3/src/components/HeaderSearch/index.vue

@@ -0,0 +1,252 @@
+<template>
+  <div class="header-search">
+    <svg-icon class-name="search-icon" icon-class="search" @click.stop="click" />
+    <el-dialog
+      v-model="show"
+      width="600"
+      @close="close"
+      :show-close="false"
+      append-to-body
+    >
+      <el-input
+        v-model="search"
+        ref="headerSearchSelectRef"
+        size="large"
+        @input="querySearch"
+        prefix-icon="Search"
+        placeholder="菜单搜索,支持标题、URL模糊查询"
+        clearable
+        @keyup.enter="selectActiveResult"
+        @keydown.up.prevent="navigateResult('up')"
+        @keydown.down.prevent="navigateResult('down')"
+      >
+      </el-input>
+
+      <div class="result-wrap">
+        <el-scrollbar>
+          <div class="search-item" tabindex="1" v-for="(item, index) in options" :key="item.path" :style="activeStyle(index)" @mouseenter="activeIndex = index" @mouseleave="activeIndex = -1">
+            <div class="left">
+              <svg-icon class="menu-icon" :icon-class="item.icon" />
+            </div>
+            <div class="search-info" @click="change(item)">
+              <div class="menu-title">
+                {{ item.title.join(" / ") }}
+              </div>
+              <div class="menu-path">
+                {{ item.path }}
+              </div>
+            </div>
+            <svg-icon icon-class="enter" v-show="index === activeIndex"/>
+          </div>
+        </el-scrollbar>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import Fuse from 'fuse.js'
+import { getNormalPath } from '@/utils/ruoyi'
+import { isHttp } from '@/utils/validate'
+import useSettingsStore from '@/store/modules/settings'
+import usePermissionStore from '@/store/modules/permission'
+
+const search = ref('')
+const options = ref([])
+const searchPool = ref([])
+const activeIndex = ref(-1)
+const show = ref(false)
+const fuse = ref(undefined)
+const headerSearchSelectRef = ref(null)
+const router = useRouter()
+const theme = computed(() => useSettingsStore().theme)
+const routes = computed(() => usePermissionStore().defaultRoutes)
+
+function click() {
+  show.value = !show.value
+  if (show.value) {
+    headerSearchSelectRef.value && headerSearchSelectRef.value.focus()
+    options.value = searchPool.value
+  }
+}
+
+function close() {
+  headerSearchSelectRef.value && headerSearchSelectRef.value.blur()
+  search.value = ''
+  options.value = []
+  show.value = false
+  activeIndex.value = -1
+}
+
+function change(val) {
+  const path = val.path
+  const query = val.query
+  if (isHttp(path)) {
+    // http(s):// 路径新窗口打开
+    const pindex = path.indexOf("http")
+    window.open(path.substr(pindex, path.length), "_blank")
+  } else {
+    if (query) {
+      router.push({ path: path, query: JSON.parse(query) })
+    } else {
+      router.push(path)
+    }
+  }
+
+  search.value = ''
+  options.value = []
+  nextTick(() => {
+    show.value = false
+  })
+}
+
+function initFuse(list) {
+  fuse.value = new Fuse(list, {
+    shouldSort: true,
+    threshold: 0.4,
+    location: 0,
+    distance: 100,
+    minMatchCharLength: 1,
+    keys: [{
+      name: 'title',
+      weight: 0.7
+    }, {
+      name: 'path',
+      weight: 0.3
+    }]
+  })
+}
+
+// Filter out the routes that can be displayed in the sidebar
+// And generate the internationalized title
+function generateRoutes(routes, basePath = '', prefixTitle = []) {
+  let res = []
+
+  for (const r of routes) {
+    // skip hidden router
+    if (r.hidden) { continue }
+    const p = r.path.length > 0 && r.path[0] === '/' ? r.path : '/' + r.path
+    const data = {
+      path: !isHttp(r.path) ? getNormalPath(basePath + p) : r.path,
+      title: [...prefixTitle],
+      icon: ''
+    }
+
+    if (r.meta && r.meta.title) {
+      data.title = [...data.title, r.meta.title]
+      data.icon = r.meta.icon
+      if (r.redirect !== "noRedirect") {
+        // only push the routes with title
+        // special case: need to exclude parent router without redirect
+        res.push(data)
+      }
+    }
+    if (r.query) {
+      data.query = r.query
+    }
+
+    // recursive child routes
+    if (r.children) {
+      const tempRoutes = generateRoutes(r.children, data.path, data.title)
+      if (tempRoutes.length >= 1) {
+        res = [...res, ...tempRoutes]
+      }
+    }
+  }
+  return res
+}
+
+function querySearch(query) {
+  activeIndex.value = -1
+  if (query !== '') {
+    options.value = fuse.value.search(query).map((item) => item.item) ?? searchPool.value
+  } else {
+    options.value = searchPool.value
+  }
+}
+
+function activeStyle(index) {
+  if (index !== activeIndex.value) return {}
+  return {
+    "background-color": theme.value,
+    "color": "#fff"
+  }
+}
+
+function navigateResult(direction) {
+  if (direction === "up") {
+    activeIndex.value = activeIndex.value <= 0 ? options.value.length - 1 : activeIndex.value - 1
+  } else if (direction === "down") {
+    activeIndex.value = activeIndex.value >= options.value.length - 1 ? 0 : activeIndex.value + 1
+  }
+}
+
+function selectActiveResult() {
+  if (options.value.length > 0 && activeIndex.value >= 0) {
+    change(options.value[activeIndex.value])
+  }
+}
+
+onMounted(() => {
+  searchPool.value = generateRoutes(routes.value)
+})
+
+watch(searchPool, (list) => {
+  initFuse(list)
+})
+</script>
+
+<style lang='scss' scoped>
+.header-search {
+  .search-icon {
+    cursor: pointer;
+    font-size: 18px;
+    vertical-align: middle;
+  }
+}
+
+.result-wrap {	
+  height: 280px;
+  margin: 6px 0;
+
+  .search-item {
+    display: flex;
+    height: 48px;
+    align-items: center;
+    padding-right: 10px;
+
+    .left {
+      width: 60px;
+      text-align: center;
+
+      .menu-icon {
+        width: 18px;
+        height: 18px;
+      }
+    }
+
+    .search-info {
+      padding-left: 5px;
+      margin-top: 10px;
+      width: 100%;
+      display: flex;
+      flex-direction: column;
+      justify-content: flex-start;
+      flex: 1;
+
+      .menu-title,
+      .menu-path {
+        height: 20px;
+      }
+      .menu-path {
+        color: #ccc;
+        font-size: 10px;
+      }
+    }
+  }
+
+  .search-item:hover {
+    cursor: pointer;
+  }
+}
+</style>

+ 111 - 0
jd-logistics-ui-vue3/src/components/IconSelect/index.vue

@@ -0,0 +1,111 @@
+<template>
+  <div class="icon-body">
+    <el-input
+      v-model="iconName"
+      class="icon-search"
+      clearable
+      placeholder="请输入图标名称"
+      @clear="filterIcons"
+      @input="filterIcons"
+    >
+      <template #suffix><i class="el-icon-search el-input__icon" /></template>
+    </el-input>
+    <div class="icon-list">
+      <div class="list-container">
+        <div v-for="(item, index) in iconList" class="icon-item-wrapper" :key="index" @click="selectedIcon(item)">
+          <div :class="['icon-item', { active: activeIcon === item }]">
+            <svg-icon :icon-class="item" class-name="icon" style="height: 25px;width: 16px;"/>
+            <span>{{ item }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import icons from './requireIcons'
+
+const props = defineProps({
+  activeIcon: {
+    type: String
+  }
+})
+
+const iconName = ref('')
+const iconList = ref(icons)
+const emit = defineEmits(['selected'])
+
+function filterIcons() {
+  iconList.value = icons
+  if (iconName.value) {
+    iconList.value = icons.filter(item => item.indexOf(iconName.value) !== -1)
+  }
+}
+
+function selectedIcon(name) {
+  emit('selected', name)
+  document.body.click()
+}
+
+function reset() {
+  iconName.value = ''
+  iconList.value = icons
+}
+
+defineExpose({
+  reset
+})
+</script>
+
+<style lang='scss' scoped>
+   .icon-body {
+    width: 100%;
+    padding: 10px;
+    .icon-search {
+      position: relative;
+      margin-bottom: 5px;
+    }
+    .icon-list {
+      height: 200px;
+      overflow: auto;
+      .list-container {
+        display: flex;
+        flex-wrap: wrap;
+        .icon-item-wrapper {
+          width: calc(100% / 3);
+          height: 25px;
+          line-height: 25px;
+          cursor: pointer;
+          display: flex;
+          .icon-item {
+            display: flex;
+            max-width: 100%;
+            height: 100%;
+            padding: 0 5px;
+            &:hover {
+              background: #ececec;
+              border-radius: 5px;
+            }
+            .icon {
+              flex-shrink: 0;
+            }
+            span {
+              display: inline-block;
+              vertical-align: -0.15em;
+              fill: currentColor;
+              padding-left: 2px;
+              overflow: hidden;
+              text-overflow: ellipsis;
+              white-space: nowrap;
+            }
+          }
+          .icon-item.active {
+            background: #ececec;
+            border-radius: 5px;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 8 - 0
jd-logistics-ui-vue3/src/components/IconSelect/requireIcons.js

@@ -0,0 +1,8 @@
+let icons = []
+const modules = import.meta.glob('./../../assets/icons/svg/*.svg')
+for (const path in modules) {
+  const p = path.split('assets/icons/svg/')[1].split('.svg')[0]
+  icons.push(p)
+}
+
+export default icons

+ 84 - 0
jd-logistics-ui-vue3/src/components/ImagePreview/index.vue

@@ -0,0 +1,84 @@
+<template>
+  <el-image
+    :src="`${realSrc}`"
+    fit="cover"
+    :style="`width:${realWidth};height:${realHeight};`"
+    :preview-src-list="realSrcList"
+    preview-teleported
+  >
+    <template #error>
+      <div class="image-slot">
+        <el-icon><picture-filled /></el-icon>
+      </div>
+    </template>
+  </el-image>
+</template>
+
+<script setup>
+const props = defineProps({
+  src: {
+    type: String,
+    default: ""
+  },
+  width: {
+    type: [Number, String],
+    default: ""
+  },
+  height: {
+    type: [Number, String],
+    default: ""
+  }
+})
+
+const realSrc = computed(() => {
+  if (!props.src) {
+    return
+  }
+  let real_src = props.src.split(",")[0]
+  return real_src
+})
+
+const realSrcList = computed(() => {
+  if (!props.src) {
+    return
+  }
+  let real_src_list = props.src.split(",")
+  let srcList = []
+  real_src_list.forEach(item => {
+    return srcList.push(item)
+  })
+  return srcList
+})
+
+const realWidth = computed(() =>
+  typeof props.width == "string" ? props.width : `${props.width}px`
+)
+
+const realHeight = computed(() =>
+  typeof props.height == "string" ? props.height : `${props.height}px`
+)
+</script>
+
+<style lang="scss" scoped>
+.el-image {
+  border-radius: 5px;
+  background-color: #ebeef5;
+  box-shadow: 0 0 5px 1px #ccc;
+  :deep(.el-image__inner) {
+    transition: all 0.3s;
+    cursor: pointer;
+    &:hover {
+      transform: scale(1.2);
+    }
+  }
+  :deep(.image-slot) {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    width: 100%;
+    height: 100%;
+    color: #909399;
+    font-size: 30px;
+  }
+}
+</style>

+ 253 - 0
jd-logistics-ui-vue3/src/components/ImageUpload/index.vue

@@ -0,0 +1,253 @@
+<template>
+  <div class="component-upload-image">
+    <el-upload
+      multiple
+      :disabled="disabled"
+      :action="uploadImgUrl"
+      list-type="picture-card"
+      :on-success="handleUploadSuccess"
+      :before-upload="handleBeforeUpload"
+      :data="data"
+      :limit="limit"
+      :on-error="handleUploadError"
+      :on-exceed="handleExceed"
+      ref="imageUpload"
+      :before-remove="handleDelete"
+      :show-file-list="true"
+      :headers="headers"
+      :file-list="fileList"
+      :on-preview="handlePictureCardPreview"
+      :class="{ hide: fileList.length >= limit }"
+    >
+      <el-icon class="avatar-uploader-icon"><plus /></el-icon>
+    </el-upload>
+    <!-- 上传提示 -->
+    <div class="el-upload__tip" v-if="showTip && !disabled">
+      请上传
+      <template v-if="fileSize">
+        大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
+      </template>
+      <template v-if="fileType">
+        格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b>
+      </template>
+      的文件
+    </div>
+
+    <el-dialog
+      v-model="dialogVisible"
+      title="预览"
+      width="800px"
+      append-to-body
+    >
+      <img
+        :src="dialogImageUrl"
+        style="display: block; max-width: 100%; margin: 0 auto"
+      />
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { getToken } from "@/utils/auth"
+import Sortable from 'sortablejs'
+
+const props = defineProps({
+  modelValue: [String, Object, Array],
+  // 上传接口地址
+  action: {
+    type: String,
+    default: "/file/upload"
+  },
+  // 上传携带的参数
+  data: {
+    type: Object
+  },
+  // 图片数量限制
+  limit: {
+    type: Number,
+    default: 5
+  },
+  // 大小限制(MB)
+  fileSize: {
+    type: Number,
+    default: 5
+  },
+  // 文件类型, 例如['png', 'jpg', 'jpeg']
+  fileType: {
+    type: Array,
+    default: () => ["png", "jpg", "jpeg"]
+  },
+  // 是否显示提示
+  isShowTip: {
+    type: Boolean,
+    default: true
+  },
+  // 禁用组件(仅查看图片)
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  // 拖动排序
+  drag: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const { proxy } = getCurrentInstance()
+const emit = defineEmits()
+const number = ref(0)
+const uploadList = ref([])
+const dialogImageUrl = ref("")
+const dialogVisible = ref(false)
+const baseUrl = import.meta.env.VITE_APP_BASE_API
+const uploadImgUrl = ref(baseUrl + props.action) // 上传的图片服务器地址
+const headers = ref({ Authorization: "Bearer " + getToken() })
+const fileList = ref([])
+const showTip = computed(
+  () => props.isShowTip && (props.fileType || props.fileSize)
+)
+
+watch(() => props.modelValue, val => {
+  if (val) {
+    // 首先将值转为数组
+    const list = Array.isArray(val) ? val : props.modelValue.split(",")
+    // 然后将数组转为对象数组
+    fileList.value = list.map(item => {
+      if (typeof item === "string") {
+        item = { name: item, url: item }
+      }
+      return item
+    })
+  } else {
+    fileList.value = []
+    return []
+  }
+},{ deep: true, immediate: true })
+
+// 上传前loading加载
+function handleBeforeUpload(file) {
+  let isImg = false
+  if (props.fileType.length) {
+    let fileExtension = ""
+    if (file.name.lastIndexOf(".") > -1) {
+      fileExtension = file.name.slice(file.name.lastIndexOf(".") + 1)
+    }
+    isImg = props.fileType.some(type => {
+      if (file.type.indexOf(type) > -1) return true
+      if (fileExtension && fileExtension.indexOf(type) > -1) return true
+      return false
+    })
+  } else {
+    isImg = file.type.indexOf("image") > -1
+  }
+  if (!isImg) {
+    proxy.$modal.msgError(`文件格式不正确,请上传${props.fileType.join("/")}图片格式文件!`)
+    return false
+  }
+  if (file.name.includes(',')) {
+    proxy.$modal.msgError('文件名不正确,不能包含英文逗号!')
+    return false
+  }
+  if (props.fileSize) {
+    const isLt = file.size / 1024 / 1024 < props.fileSize
+    if (!isLt) {
+      proxy.$modal.msgError(`上传头像图片大小不能超过 ${props.fileSize} MB!`)
+      return false
+    }
+  }
+  proxy.$modal.loading("正在上传图片,请稍候...")
+  number.value++
+}
+
+// 文件个数超出
+function handleExceed() {
+  proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`)
+}
+
+// 上传成功回调
+function handleUploadSuccess(res, file) {
+  if (res.code === 200) {
+    uploadList.value.push({ name: res.data.url, url: res.data.url })
+    uploadedSuccessfully()
+  } else {
+    number.value--
+    proxy.$modal.closeLoading()
+    proxy.$modal.msgError(res.msg)
+    proxy.$refs.imageUpload.handleRemove(file)
+    uploadedSuccessfully()
+  }
+}
+
+// 删除图片
+function handleDelete(file) {
+  const findex = fileList.value.map(f => f.name).indexOf(file.name)
+  if (findex > -1 && uploadList.value.length === number.value) {
+    fileList.value.splice(findex, 1)
+    emit("update:modelValue", listToString(fileList.value))
+    return false
+  }
+}
+
+// 上传结束处理
+function uploadedSuccessfully() {
+  if (number.value > 0 && uploadList.value.length === number.value) {
+    fileList.value = fileList.value.filter(f => f.url !== undefined).concat(uploadList.value)
+    uploadList.value = []
+    number.value = 0
+    emit("update:modelValue", listToString(fileList.value))
+    proxy.$modal.closeLoading()
+  }
+}
+
+// 上传失败
+function handleUploadError() {
+  proxy.$modal.msgError("上传图片失败")
+  proxy.$modal.closeLoading()
+}
+
+// 预览
+function handlePictureCardPreview(file) {
+  dialogImageUrl.value = file.url
+  dialogVisible.value = true
+}
+
+// 对象转成指定字符串分隔
+function listToString(list, separator) {
+  let strs = ""
+  separator = separator || ","
+  for (let i in list) {
+    if (undefined !== list[i].url && list[i].url.indexOf("blob:") !== 0) {
+      strs += list[i].url.replace(baseUrl, "") + separator
+    }
+  }
+  return strs != "" ? strs.substr(0, strs.length - 1) : ""
+}
+
+// 初始化拖拽排序
+onMounted(() => {
+  if (props.drag && !props.disabled) {
+    nextTick(() => {
+      const element = proxy.$refs.imageUpload?.$el?.querySelector('.el-upload-list')
+      Sortable.create(element, {
+        onEnd: (evt) => {
+          const movedItem = fileList.value.splice(evt.oldIndex, 1)[0]
+          fileList.value.splice(evt.newIndex, 0, movedItem)
+          emit('update:modelValue', listToString(fileList.value))
+        }
+      })
+    })
+  }
+})
+</script>
+
+<style scoped lang="scss">
+// .el-upload--picture-card 控制加号部分
+:deep(.hide .el-upload--picture-card) {
+    display: none;
+}
+
+:deep(.el-upload.el-upload--picture-card.is-disabled) {
+  display: none !important;
+} 
+</style>

+ 105 - 0
jd-logistics-ui-vue3/src/components/Pagination/index.vue

@@ -0,0 +1,105 @@
+<template>
+  <div :class="{ 'hidden': hidden }" class="pagination-container">
+    <el-pagination
+      :background="background"
+      v-model:current-page="currentPage"
+      v-model:page-size="pageSize"
+      :layout="layout"
+      :page-sizes="pageSizes"
+      :pager-count="pagerCount"
+      :total="total"
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+    />
+  </div>
+</template>
+
+<script setup>
+import { scrollTo } from '@/utils/scroll-to'
+
+const props = defineProps({
+  total: {
+    required: true,
+    type: Number
+  },
+  page: {
+    type: Number,
+    default: 1
+  },
+  limit: {
+    type: Number,
+    default: 20
+  },
+  pageSizes: {
+    type: Array,
+    default() {
+      return [10, 20, 30, 50]
+    }
+  },
+  // 移动端页码按钮的数量端默认值5
+  pagerCount: {
+    type: Number,
+    default: document.body.clientWidth < 992 ? 5 : 7
+  },
+  layout: {
+    type: String,
+    default: 'total, sizes, prev, pager, next, jumper'
+  },
+  background: {
+    type: Boolean,
+    default: true
+  },
+  autoScroll: {
+    type: Boolean,
+    default: true
+  },
+  hidden: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits()
+const currentPage = computed({
+  get() {
+    return props.page
+  },
+  set(val) {
+    emit('update:page', val)
+  }
+})
+const pageSize = computed({
+  get() {
+    return props.limit
+  },
+  set(val){
+    emit('update:limit', val)
+  }
+})
+
+function handleSizeChange(val) {
+  if (currentPage.value * val > props.total) {
+    currentPage.value = 1
+  }
+  emit('pagination', { page: currentPage.value, limit: val })
+  if (props.autoScroll) {
+    scrollTo(0, 800)
+  }
+}
+
+function handleCurrentChange(val) {
+  emit('pagination', { page: val, limit: pageSize.value })
+  if (props.autoScroll) {
+    scrollTo(0, 800)
+  }
+}
+</script>
+
+<style scoped>
+.pagination-container {
+  background: #fff;
+}
+.pagination-container.hidden {
+  display: none;
+}
+</style>

+ 3 - 0
jd-logistics-ui-vue3/src/components/ParentView/index.vue

@@ -0,0 +1,3 @@
+<template >
+  <router-view />
+</template>

+ 181 - 0
jd-logistics-ui-vue3/src/components/RightToolbar/index.vue

@@ -0,0 +1,181 @@
+<template>
+  <div class="top-right-btn" :style="style">
+    <el-row>
+      <el-tooltip class="item" effect="dark" :content="showSearch ? '隐藏搜索' : '显示搜索'" placement="top" v-if="search">
+        <el-button circle icon="Search" @click="toggleSearch()" />
+      </el-tooltip>
+      <el-tooltip class="item" effect="dark" content="刷新" placement="top">
+        <el-button circle icon="Refresh" @click="refresh()" />
+      </el-tooltip>
+      <el-tooltip class="item" effect="dark" content="显隐列" placement="top" v-if="Object.keys(columns).length > 0">
+        <el-button circle icon="Menu" @click="showColumn()" v-if="showColumnsType == 'transfer'"/>
+        <el-dropdown trigger="click" :hide-on-click="false" style="padding-left: 12px" v-if="showColumnsType == 'checkbox'">
+          <el-button circle icon="Menu" />
+          <template #dropdown>
+            <el-dropdown-menu>
+              <!-- 全选/反选 按钮 -->
+              <el-dropdown-item>
+                <el-checkbox :indeterminate="isIndeterminate" v-model="isChecked" @change="toggleCheckAll"> 列展示 </el-checkbox>
+              </el-dropdown-item>
+              <div class="check-line"></div>
+              <template v-for="(item, key) in columns" :key="item.key">
+                <el-dropdown-item>
+                  <el-checkbox v-model="item.visible" @change="checkboxChange($event, key)" :label="item.label" />
+                </el-dropdown-item>
+              </template>
+            </el-dropdown-menu>
+          </template>
+        </el-dropdown>
+      </el-tooltip>
+    </el-row>
+    <el-dialog :title="title" v-model="open" append-to-body>
+      <el-transfer
+        :titles="['显示', '隐藏']"
+        v-model="value"
+        :data="transferData"
+        @change="dataChange"
+      ></el-transfer>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  /* 是否显示检索条件 */
+  showSearch: {
+    type: Boolean,
+    default: true
+  },
+  /* 显隐列信息(数组格式、对象格式) */
+  columns: {
+    type: [Array, Object],
+    default: () => ({})
+  },
+  /* 是否显示检索图标 */
+  search: {
+    type: Boolean,
+    default: true
+  },
+  /* 显隐列类型(transfer穿梭框、checkbox复选框) */
+  showColumnsType: {
+    type: String,
+    default: "checkbox"
+  },
+  /* 右外边距 */
+  gutter: {
+    type: Number,
+    default: 10
+  },
+})
+
+const emits = defineEmits(['update:showSearch', 'queryTable'])
+
+// 显隐数据
+const value = ref([])
+// 弹出层标题
+const title = ref("显示/隐藏")
+// 是否显示弹出层
+const open = ref(false)
+
+const style = computed(() => {
+  const ret = {}
+  if (props.gutter) {
+    ret.marginRight = `${props.gutter / 2}px`
+  }
+  return ret
+})
+
+// 是否全选/半选 状态
+const isChecked = computed({
+  get: () => Array.isArray(props.columns) ? props.columns.every(col => col.visible) : Object.values(props.columns).every((col) => col.visible),
+  set: () => {}
+})
+const isIndeterminate = computed(() => Array.isArray(props.columns) ? props.columns.some((col) => col.visible) && !isChecked.value : Object.values(props.columns).some((col) => col.visible) && !isChecked.value)
+const transferData = computed(() => Array.isArray(props.columns) ? props.columns.map((item, index) => ({ key: index, label: item.label })) : Object.keys(props.columns).map((key, index) => ({ key: index, label: props.columns[key].label })))
+
+// 搜索
+function toggleSearch() {
+  emits("update:showSearch", !props.showSearch)
+}
+
+// 刷新
+function refresh() {
+  emits("queryTable")
+}
+
+// 右侧列表元素变化
+function dataChange(data) {
+  if (Array.isArray(props.columns)) {
+    for (let item in props.columns) {
+      const key = props.columns[item].key
+      props.columns[item].visible = !data.includes(key)
+    }
+  } else {
+    Object.keys(props.columns).forEach((key, index) => {
+      props.columns[key].visible = !data.includes(index)
+    })
+  }
+}
+
+// 打开显隐列dialog
+function showColumn() {
+  open.value = true
+}
+
+if (props.showColumnsType == "transfer") {
+  // transfer穿梭显隐列初始默认隐藏列
+  if (Array.isArray(props.columns)) {
+    for (let item in props.columns) {
+      if (props.columns[item].visible === false) {
+        value.value.push(parseInt(item))
+      }
+    }
+  } else {
+    Object.keys(props.columns).forEach((key, index) => {
+      if (props.columns[key].visible === false) {
+        value.value.push(index)
+      }
+    })
+  }
+}
+
+// 单勾选
+function checkboxChange(event, key) {
+  if (Array.isArray(props.columns)) {
+    props.columns.filter(item => item.key == key)[0].visible = event
+  } else {
+    props.columns[key].visible = event
+  }
+}
+
+// 切换全选/反选
+function toggleCheckAll() {
+  const newValue = !isChecked.value
+  if (Array.isArray(props.columns)) {
+    props.columns.forEach((col) => (col.visible = newValue))
+  } else {
+    Object.values(props.columns).forEach((col) => (col.visible = newValue))
+  }
+}
+</script>
+
+<style lang='scss' scoped>
+:deep(.el-transfer__button) {
+  border-radius: 50%;
+  display: block;
+  margin-left: 0px;
+}
+:deep(.el-transfer__button:first-child) {
+  margin-bottom: 10px;
+}
+:deep(.el-dropdown-menu__item) {
+  line-height: 30px;
+  padding: 0 17px;
+}
+.check-line {
+  width: 90%;
+  height: 1px;
+  background-color: #ccc;
+  margin: 3px auto;
+}
+</style>

+ 13 - 0
jd-logistics-ui-vue3/src/components/RuoYi/Doc/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div>
+    <svg-icon icon-class="question" @click="goto" />
+  </div>
+</template>
+
+<script setup>
+const url = ref('http://doc.ruoyi.vip/ruoyi-cloud')
+
+function goto() {
+  window.open(url.value)
+}
+</script>

+ 13 - 0
jd-logistics-ui-vue3/src/components/RuoYi/Git/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div>
+    <svg-icon icon-class="github" @click="goto" />
+  </div>
+</template>
+
+<script setup>
+const url = ref('https://gitee.com/y_project/RuoYi-Cloud')
+
+function goto() {
+  window.open(url.value)
+}
+</script>

+ 22 - 0
jd-logistics-ui-vue3/src/components/Screenfull/index.vue

@@ -0,0 +1,22 @@
+<template>
+  <div>
+    <svg-icon :icon-class="isFullscreen ? 'exit-fullscreen' : 'fullscreen'" @click="toggle" />
+  </div>
+</template>
+
+<script setup>
+import { useFullscreen } from '@vueuse/core'
+
+const { isFullscreen, enter, exit, toggle } = useFullscreen()
+</script>
+
+<style lang='scss' scoped>
+.screenfull-svg {
+  display: inline-block;
+  cursor: pointer;
+  fill: #5a5e66;
+  width: 20px;
+  height: 20px;
+  vertical-align: 10px;
+}
+</style>

+ 45 - 0
jd-logistics-ui-vue3/src/components/SizeSelect/index.vue

@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <el-dropdown trigger="click" @command="handleSetSize">
+      <div class="size-icon--style">
+        <svg-icon class-name="size-icon" icon-class="size" />
+      </div>
+      <template #dropdown>
+        <el-dropdown-menu>
+          <el-dropdown-item v-for="item of sizeOptions" :key="item.value" :disabled="size === item.value" :command="item.value">
+            {{ item.label }}
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </template>
+    </el-dropdown>
+  </div>
+</template>
+
+<script setup>
+import useAppStore from "@/store/modules/app"
+
+const appStore = useAppStore()
+const size = computed(() => appStore.size)
+const route = useRoute()
+const router = useRouter()
+const { proxy } = getCurrentInstance()
+const sizeOptions = ref([
+  { label: "较大", value: "large" },
+  { label: "默认", value: "default" },
+  { label: "稍小", value: "small" },
+])
+
+function handleSetSize(size) {
+  proxy.$modal.loading("正在设置布局大小,请稍候...")
+  appStore.setSize(size)
+  setTimeout("window.location.reload()", 1000)
+}
+</script>
+
+<style lang='scss' scoped>
+.size-icon--style {
+  font-size: 18px;
+  line-height: 50px;
+  padding-right: 7px;
+}
+</style>

+ 53 - 0
jd-logistics-ui-vue3/src/components/SvgIcon/index.vue

@@ -0,0 +1,53 @@
+<template>
+  <svg :class="svgClass" aria-hidden="true">
+    <use :xlink:href="iconName" :fill="color" />
+  </svg>
+</template>
+
+<script>
+export default defineComponent({
+  props: {
+    iconClass: {
+      type: String,
+      required: true
+    },
+    className: {
+      type: String,
+      default: ''
+    },
+    color: {
+      type: String,
+      default: ''
+    },
+  },
+  setup(props) {
+    return {
+      iconName: computed(() => `#icon-${props.iconClass}`),
+      svgClass: computed(() => {
+        if (props.className) {
+          return `svg-icon ${props.className}`
+        }
+        return 'svg-icon'
+      })
+    }
+  }
+})
+</script>
+
+<style scope lang="scss">
+.sub-el-icon,
+.nav-icon {
+  display: inline-block;
+  font-size: 15px;
+  margin-right: 12px;
+  position: relative;
+}
+
+.svg-icon {
+  width: 1em;
+  height: 1em;
+  position: relative;
+  fill: currentColor;
+  vertical-align: -2px;
+}
+</style>

+ 10 - 0
jd-logistics-ui-vue3/src/components/SvgIcon/svgicon.js

@@ -0,0 +1,10 @@
+import * as components from '@element-plus/icons-vue'
+
+export default {
+  install: (app) => {
+    for (const key in components) {
+      const componentConfig = components[key]
+      app.component(componentConfig.name, componentConfig)
+    }
+  }
+}

+ 215 - 0
jd-logistics-ui-vue3/src/components/TopNav/index.vue

@@ -0,0 +1,215 @@
+<template>
+  <el-menu
+    :default-active="activeMenu"
+    mode="horizontal"
+    @select="handleSelect"
+    :ellipsis="false"
+  >
+    <template v-for="(item, index) in topMenus">
+      <el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber">
+        <svg-icon
+        v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
+        :icon-class="item.meta.icon"/>
+        {{ item.meta.title }}
+      </el-menu-item>
+    </template>
+
+    <!-- 顶部菜单超出数量折叠 -->
+    <el-sub-menu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
+      <template #title>更多菜单</template>
+      <template v-for="(item, index) in topMenus">
+        <el-menu-item
+          :index="item.path"
+          :key="index"
+          v-if="index >= visibleNumber">
+        <svg-icon
+          v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
+          :icon-class="item.meta.icon"/>
+        {{ item.meta.title }}
+        </el-menu-item>
+      </template>
+    </el-sub-menu>
+  </el-menu>
+</template>
+
+<script setup>
+import { constantRoutes } from "@/router"
+import { isHttp } from '@/utils/validate'
+import useAppStore from '@/store/modules/app'
+import useSettingsStore from '@/store/modules/settings'
+import usePermissionStore from '@/store/modules/permission'
+
+// 顶部栏初始数
+const visibleNumber = ref(null)
+// 当前激活菜单的 index
+const currentIndex = ref(null)
+// 隐藏侧边栏路由
+const hideList = ['/index', '/user/profile']
+
+const appStore = useAppStore()
+const settingsStore = useSettingsStore()
+const permissionStore = usePermissionStore()
+const route = useRoute()
+const router = useRouter()
+
+// 主题颜色
+const theme = computed(() => settingsStore.theme)
+// 所有的路由信息
+const routers = computed(() => permissionStore.topbarRouters)
+
+// 顶部显示菜单
+const topMenus = computed(() => {
+  let topMenus = []
+  routers.value.map((menu) => {
+    if (menu.hidden !== true) {
+      // 兼容顶部栏一级菜单内部跳转
+      if (menu.path === '/' && menu.children) {
+          topMenus.push(menu.children[0])
+      } else {
+          topMenus.push(menu)
+      }
+    }
+  })
+  return topMenus
+})
+
+// 设置子路由
+const childrenMenus = computed(() => {
+  let childrenMenus = []
+  routers.value.map((router) => {
+    for (let item in router.children) {
+      if (router.children[item].parentPath === undefined) {
+        if(router.path === "/") {
+          router.children[item].path = "/" + router.children[item].path
+        } else {
+          if(!isHttp(router.children[item].path)) {
+            router.children[item].path = router.path + "/" + router.children[item].path
+          }
+        }
+        router.children[item].parentPath = router.path
+      }
+      childrenMenus.push(router.children[item])
+    }
+  })
+  return constantRoutes.concat(childrenMenus)
+})
+
+// 默认激活的菜单
+const activeMenu = computed(() => {
+  const path = route.path
+  let activePath = path
+  if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
+    const tmpPath = path.substring(1, path.length)
+    if (!route.meta.link) {
+      activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"))
+      appStore.toggleSideBarHide(false)
+    }
+  } else if(!route.children) {
+    activePath = path
+    appStore.toggleSideBarHide(true)
+  }
+  activeRoutes(activePath)
+  return activePath
+})
+
+function setVisibleNumber() {
+  const width = document.body.getBoundingClientRect().width / 3
+  visibleNumber.value = parseInt(width / 85)
+}
+
+function handleSelect(key, keyPath) {
+  currentIndex.value = key
+  const route = routers.value.find(item => item.path === key)
+  if (isHttp(key)) {
+    // http(s):// 路径新窗口打开
+    window.open(key, "_blank")
+  } else if (!route || !route.children) {
+    // 没有子路由路径内部打开
+    const routeMenu = childrenMenus.value.find(item => item.path === key)
+    if (routeMenu && routeMenu.query) {
+      let query = JSON.parse(routeMenu.query)
+      router.push({ path: key, query: query })
+    } else {
+      router.push({ path: key })
+    }
+    appStore.toggleSideBarHide(true)
+  } else {
+    // 显示左侧联动菜单
+    activeRoutes(key)
+    appStore.toggleSideBarHide(false)
+  }
+}
+
+function activeRoutes(key) {
+  let routes = []
+  if (childrenMenus.value && childrenMenus.value.length > 0) {
+    childrenMenus.value.map((item) => {
+      if (key == item.parentPath || (key == "index" && "" == item.path)) {
+        routes.push(item)
+      }
+    })
+  }
+  if(routes.length > 0) {
+    permissionStore.setSidebarRouters(routes)
+  } else {
+    appStore.toggleSideBarHide(true)
+  }
+  return routes
+}
+
+onMounted(() => {
+  window.addEventListener('resize', setVisibleNumber)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', setVisibleNumber)
+})
+
+onMounted(() => {
+  setVisibleNumber()
+})
+</script>
+
+<style lang="scss">
+.topmenu-container.el-menu--horizontal > .el-menu-item {
+  float: left;
+  height: 50px !important;
+  line-height: 50px !important;
+  color: #303133 !important;
+  padding: 0 5px !important;
+  margin: 0 10px !important;
+}
+
+.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-sub-menu.is-active .el-submenu__title {
+  border-bottom: 2px solid #{'var(--theme)'} !important;
+  color: #303133;
+}
+
+/* sub-menu item */
+.topmenu-container.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
+  float: left;
+  height: 50px !important;
+  line-height: 50px !important;
+  color: #303133 !important;
+  padding: 0 5px !important;
+  margin: 0 10px !important;
+}
+
+/* 背景色隐藏 */
+.topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):focus, .topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):hover, .topmenu-container.el-menu--horizontal>.el-submenu .el-submenu__title:hover {
+  background-color: #ffffff;
+}
+
+/* 图标右间距 */
+.topmenu-container .svg-icon {
+  margin-right: 4px;
+}
+
+/* topmenu more arrow */
+.topmenu-container .el-sub-menu .el-sub-menu__icon-arrow {
+  position: static;
+  vertical-align: middle;
+  margin-left: 8px;
+  margin-top: 0px;
+}
+</style>

+ 31 - 0
jd-logistics-ui-vue3/src/components/iFrame/index.vue

@@ -0,0 +1,31 @@
+<template>
+  <div v-loading="loading" :style="'height:' + height">
+    <iframe 
+      :src="url" 
+      frameborder="no" 
+      style="width: 100%; height: 100%" 
+      scrolling="auto" />
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  src: {
+    type: String,
+    required: true
+  }
+})
+
+const height = ref(document.documentElement.clientHeight - 94.5 + "px;")
+const loading = ref(true)
+const url = computed(() => props.src)
+
+onMounted(() => {
+  setTimeout(() => {
+    loading.value = false
+  }, 300)
+  window.onresize = function temp() {
+    height.value = document.documentElement.clientHeight - 94.5 + "px;"
+  }
+})
+</script>

+ 65 - 0
jd-logistics-ui-vue3/src/directive/common/copyText.js

@@ -0,0 +1,65 @@
+/**
+* v-copyText 复制文本内容
+* Copyright (c) 2022 ruoyi
+*/
+export default {
+  beforeMount(el, { value, arg }) {
+    if (arg === "callback") {
+      el.$copyCallback = value
+    } else {
+      el.$copyValue = value
+      const handler = () => {
+        copyTextToClipboard(el.$copyValue)
+        if (el.$copyCallback) {
+          el.$copyCallback(el.$copyValue)
+        }
+      }
+      el.addEventListener("click", handler)
+      el.$destroyCopy = () => el.removeEventListener("click", handler)
+    }
+  }
+}
+
+function copyTextToClipboard(input, { target = document.body } = {}) {
+  const element = document.createElement('textarea')
+  const previouslyFocusedElement = document.activeElement
+
+  element.value = input
+
+  // Prevent keyboard from showing on mobile
+  element.setAttribute('readonly', '')
+
+  element.style.contain = 'strict'
+  element.style.position = 'absolute'
+  element.style.left = '-9999px'
+  element.style.fontSize = '12pt' // Prevent zooming on iOS
+
+  const selection = document.getSelection()
+  const originalRange = selection.rangeCount > 0 && selection.getRangeAt(0)
+
+  target.append(element)
+  element.select()
+
+  // Explicit selection workaround for iOS
+  element.selectionStart = 0
+  element.selectionEnd = input.length
+
+  let isSuccess = false
+  try {
+    isSuccess = document.execCommand('copy')
+  } catch { }
+
+  element.remove()
+
+  if (originalRange) {
+    selection.removeAllRanges()
+    selection.addRange(originalRange)
+  }
+
+  // Get the focus back on the previously focused element, if any
+  if (previouslyFocusedElement) {
+    previouslyFocusedElement.focus()
+  }
+
+  return isSuccess
+}

+ 9 - 0
jd-logistics-ui-vue3/src/directive/index.js

@@ -0,0 +1,9 @@
+import hasRole from './permission/hasRole'
+import hasPermi from './permission/hasPermi'
+import copyText from './common/copyText'
+
+export default function directive(app){
+  app.directive('hasRole', hasRole)
+  app.directive('hasPermi', hasPermi)
+  app.directive('copyText', copyText)
+}

+ 27 - 0
jd-logistics-ui-vue3/src/directive/permission/hasPermi.js

@@ -0,0 +1,27 @@
+ /**
+ * v-hasPermi 操作权限处理
+ * Copyright (c) 2019 ruoyi
+ */
+import useUserStore from '@/store/modules/user'
+
+export default {
+  mounted(el, binding, vnode) {
+    const { value } = binding
+    const all_permission = "*:*:*"
+    const permissions = useUserStore().permissions
+
+    if (value && value instanceof Array && value.length > 0) {
+      const permissionFlag = value
+
+      const hasPermissions = permissions.some(permission => {
+        return all_permission === permission || permissionFlag.includes(permission)
+      })
+
+      if (!hasPermissions) {
+        el.parentNode && el.parentNode.removeChild(el)
+      }
+    } else {
+      throw new Error(`请设置操作权限标签值`)
+    }
+  }
+}

+ 27 - 0
jd-logistics-ui-vue3/src/directive/permission/hasRole.js

@@ -0,0 +1,27 @@
+ /**
+ * v-hasRole 角色权限处理
+ * Copyright (c) 2019 ruoyi
+ */
+import useUserStore from '@/store/modules/user'
+
+export default {
+  mounted(el, binding, vnode) {
+    const { value } = binding
+    const super_admin = "admin"
+    const roles = useUserStore().roles
+
+    if (value && value instanceof Array && value.length > 0) {
+      const roleFlag = value
+
+      const hasRole = roles.some(role => {
+        return super_admin === role || roleFlag.includes(role)
+      })
+
+      if (!hasRole) {
+        el.parentNode && el.parentNode.removeChild(el)
+      }
+    } else {
+      throw new Error(`请设置角色权限标签值`)
+    }
+  }
+}

+ 123 - 0
jd-logistics-ui-vue3/src/layout/components/AppMain.vue

@@ -0,0 +1,123 @@
+<template>
+  <section class="app-main">
+    <router-view v-slot="{ Component, route }">
+      <transition name="fade-transform" mode="out-in">
+        <keep-alive :include="tagsViewStore.cachedViews">
+          <component v-if="!route.meta.link" :is="Component" :key="route.path"/>
+        </keep-alive>
+      </transition>
+    </router-view>
+    <iframe-toggle />
+    <copyright />
+  </section>
+</template>
+
+<script setup>
+import copyright from "./Copyright/index"
+import iframeToggle from "./IframeToggle/index"
+import useTagsViewStore from '@/store/modules/tagsView'
+
+const route = useRoute()
+const tagsViewStore = useTagsViewStore()
+
+onMounted(() => {
+  addIframe()
+})
+
+watchEffect(() => {
+  addIframe()
+})
+
+function addIframe() {
+  if (route.meta.link) {
+    useTagsViewStore().addIframeView(route)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-main {
+  /* 50= navbar  50  */
+  min-height: calc(100vh - 50px);
+  width: 100%;
+  position: relative;
+  overflow: hidden;
+}
+
+.fixed-header + .app-main {
+  overflow-y: auto;
+  scrollbar-gutter: auto;
+  height: calc(100vh - 50px);
+  min-height: 0px;
+}
+
+.app-main:has(.copyright) {
+  padding-bottom: 36px;
+}
+
+.fixed-header + .app-main {
+  margin-top: 50px;
+}
+
+.hasTagsView {
+  .app-main {
+    /* 84 = navbar + tags-view = 50 + 34 */
+    min-height: calc(100vh - 84px);
+  }
+
+  .fixed-header + .app-main {
+    margin-top: 84px;
+    height: calc(100vh - 84px);
+    min-height: 0px;
+  }
+}
+
+/* 移动端fixed-header优化 */
+@media screen and (max-width: 991px) {
+  .fixed-header + .app-main {
+    padding-bottom: max(60px, calc(constant(safe-area-inset-bottom) + 40px));
+    padding-bottom: max(60px, calc(env(safe-area-inset-bottom) + 40px));
+    overscroll-behavior-y: none;
+  }
+
+  .hasTagsView .fixed-header + .app-main {
+    padding-bottom: max(60px, calc(constant(safe-area-inset-bottom) + 40px));
+    padding-bottom: max(60px, calc(env(safe-area-inset-bottom) + 40px));
+    overscroll-behavior-y: none;
+  }
+}
+
+@supports (-webkit-touch-callout: none) {
+  @media screen and (max-width: 991px) {
+    .fixed-header + .app-main {
+      padding-bottom: max(17px, calc(constant(safe-area-inset-bottom) + 10px));
+      padding-bottom: max(17px, calc(env(safe-area-inset-bottom) + 10px));
+      height: calc(100svh - 50px);
+      height: calc(100dvh - 50px);
+    }
+
+    .hasTagsView .fixed-header + .app-main {
+      padding-bottom: max(17px, calc(constant(safe-area-inset-bottom) + 10px));
+      padding-bottom: max(17px, calc(env(safe-area-inset-bottom) + 10px));
+      height: calc(100svh - 84px);
+      height: calc(100dvh - 84px);
+    }
+  }
+}
+</style>
+
+<style lang="scss">
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::-webkit-scrollbar-track {
+  background-color: #f1f1f1;
+}
+
+::-webkit-scrollbar-thumb {
+  background-color: #c0c0c0;
+  border-radius: 3px;
+}
+</style>

+ 31 - 0
jd-logistics-ui-vue3/src/layout/components/Copyright/index.vue

@@ -0,0 +1,31 @@
+<template>
+  <footer v-if="visible" class="copyright">
+    <span>{{ content }}</span>
+  </footer>
+</template>
+
+<script setup>
+import useSettingsStore from '@/store/modules/settings'
+
+const settingsStore = useSettingsStore()
+
+const visible = computed(() => settingsStore.footerVisible)
+const content = computed(() => settingsStore.footerContent)
+</script>
+
+<style scoped>
+.copyright {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 36px;
+  padding: 10px 20px;
+  text-align: right;
+  background-color: #f8f8f8;
+  color: #666;
+  font-size: 14px;
+  border-top: 1px solid #e7e7e7;
+  z-index: 999;
+}
+</style>

+ 25 - 0
jd-logistics-ui-vue3/src/layout/components/IframeToggle/index.vue

@@ -0,0 +1,25 @@
+<template>
+  <inner-link
+    v-for="(item, index) in tagsViewStore.iframeViews"
+    :key="item.path"
+    :iframeId="'iframe' + index"
+    v-show="route.path === item.path"
+    :src="iframeUrl(item.meta.link, item.query)"
+  ></inner-link>
+</template>
+
+<script setup>
+import InnerLink from "../InnerLink/index"
+import useTagsViewStore from "@/store/modules/tagsView"
+
+const route = useRoute()
+const tagsViewStore = useTagsViewStore()
+
+function iframeUrl(url, query) {
+  if (Object.keys(query).length > 0) {
+    let params = Object.keys(query).map((key) => key + "=" + query[key]).join("&")
+    return url + "?" + params
+  }
+  return url
+}
+</script>

+ 35 - 0
jd-logistics-ui-vue3/src/layout/components/InnerLink/index.vue

@@ -0,0 +1,35 @@
+<template>
+  <div :style="'height:' + height" v-loading="loading" element-loading-text="正在加载页面,请稍候!">
+    <iframe
+      :id="iframeId"
+      style="width: 100%; height: 100%"
+      :src="src"
+      ref="iframeRef"
+      frameborder="no"
+    ></iframe>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  src: {
+    type: String,
+    default: "/"
+  },
+  iframeId: {
+    type: String
+  }
+})
+
+const loading = ref(true)
+const height = ref(document.documentElement.clientHeight - 94.5 + 'px')
+const iframeRef = ref(null)
+
+onMounted(() => {
+  if (iframeRef.value) {
+    iframeRef.value.onload = () => {
+      loading.value = false
+    }
+  }
+})
+</script>

+ 290 - 0
jd-logistics-ui-vue3/src/layout/components/Navbar.vue

@@ -0,0 +1,290 @@
+<template>
+  <div class="navbar" :class="'nav' + settingsStore.navType">
+    <hamburger id="hamburger-container" :is-active="appStore.sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
+    <breadcrumb v-if="settingsStore.navType == 1" id="breadcrumb-container" class="breadcrumb-container" />
+    <top-nav v-if="settingsStore.navType == 2" id="topmenu-container" class="topmenu-container" />
+    <template v-if="settingsStore.navType == 3">
+      <logo v-show="settingsStore.sidebarLogo" :collapse="false"></logo>
+      <top-bar id="topbar-container" class="topbar-container" />
+    </template>
+
+    <div class="right-menu">
+      <template v-if="appStore.device !== 'mobile'">
+        <header-search id="header-search" class="right-menu-item" />
+
+        <el-tooltip content="源码地址" effect="dark" placement="bottom">
+          <ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
+        </el-tooltip>
+
+        <el-tooltip content="文档地址" effect="dark" placement="bottom">
+          <ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
+        </el-tooltip>
+
+        <screenfull id="screenfull" class="right-menu-item hover-effect" />
+
+        <el-tooltip content="主题模式" effect="dark" placement="bottom">
+          <div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme">
+            <svg-icon v-if="settingsStore.isDark" icon-class="sunny" />
+            <svg-icon v-if="!settingsStore.isDark" icon-class="moon" />
+          </div>
+        </el-tooltip>
+
+        <el-tooltip content="布局大小" effect="dark" placement="bottom">
+          <size-select id="size-select" class="right-menu-item hover-effect" />
+        </el-tooltip>
+      </template>
+
+      <el-dropdown @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover">
+        <div class="avatar-wrapper">
+          <img :src="userStore.avatar" class="user-avatar" />
+          <span class="user-nickname"> {{ userStore.nickName }} </span>
+        </div>
+        <template #dropdown>
+          <el-dropdown-menu>
+            <router-link to="/user/profile">
+              <el-dropdown-item>个人中心</el-dropdown-item>
+            </router-link>
+            <el-dropdown-item command="setLayout" v-if="settingsStore.showSettings">
+                <span>布局设置</span>
+              </el-dropdown-item>
+            <el-dropdown-item divided command="logout">
+              <span>退出登录</span>
+            </el-dropdown-item>
+          </el-dropdown-menu>
+        </template>
+      </el-dropdown>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ElMessageBox } from 'element-plus'
+import Breadcrumb from '@/components/Breadcrumb'
+import TopNav from '@/components/TopNav'
+import TopBar from './TopBar'
+import Logo from './Sidebar/Logo'
+import Hamburger from '@/components/Hamburger'
+import Screenfull from '@/components/Screenfull'
+import SizeSelect from '@/components/SizeSelect'
+import HeaderSearch from '@/components/HeaderSearch'
+import RuoYiGit from '@/components/RuoYi/Git'
+import RuoYiDoc from '@/components/RuoYi/Doc'
+import useAppStore from '@/store/modules/app'
+import useUserStore from '@/store/modules/user'
+import useSettingsStore from '@/store/modules/settings'
+
+const appStore = useAppStore()
+const userStore = useUserStore()
+const settingsStore = useSettingsStore()
+
+function toggleSideBar() {
+  appStore.toggleSideBar()
+}
+
+function handleCommand(command) {
+  switch (command) {
+    case "setLayout":
+      setLayout()
+      break
+    case "logout":
+      logout()
+      break
+    default:
+      break
+  }
+}
+
+function logout() {
+  ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(() => {
+    userStore.logOut().then(() => {
+      location.href = '/index'
+    })
+  }).catch(() => { })
+}
+
+const emits = defineEmits(['setLayout'])
+function setLayout() {
+  emits('setLayout')
+}
+
+async function toggleTheme(event) {
+  const x = event?.clientX || window.innerWidth / 2
+  const y = event?.clientY || window.innerHeight / 2
+  const wasDark = settingsStore.isDark
+
+  const isReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
+  const isSupported = document.startViewTransition && !isReducedMotion
+
+  if (!isSupported) {
+    settingsStore.toggleTheme()
+    return
+  }
+
+  try {
+    const transition = document.startViewTransition(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 10))
+      settingsStore.toggleTheme()
+      await nextTick()
+    })
+    await transition.ready
+
+    const endRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y))
+    const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]
+    document.documentElement.animate(
+      {
+        clipPath: !wasDark ? [...clipPath].reverse() : clipPath
+      }, {
+        duration: 650,
+        easing: "cubic-bezier(0.4, 0, 0.2, 1)",
+        fill: "forwards",
+        pseudoElement: !wasDark ? "::view-transition-old(root)" : "::view-transition-new(root)"
+      }
+    )
+    await transition.finished
+  } catch (error) {
+    console.warn("View transition failed, falling back to immediate toggle:", error)
+    settingsStore.toggleTheme()
+  }
+}
+</script>
+
+<style lang='scss' scoped>
+.navbar.nav3 {
+  .hamburger-container {
+    display: none !important;
+  }
+}
+
+.navbar {
+  height: 50px;
+  overflow: hidden;
+  position: relative;
+  background: var(--navbar-bg);
+  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
+  display: flex;
+  align-items: center;
+  // padding: 0 8px;
+  box-sizing: border-box;
+
+  .hamburger-container {
+    line-height: 46px;
+    height: 100%;
+    cursor: pointer;
+    transition: background 0.3s;
+    -webkit-tap-highlight-color: transparent;
+    display: flex;
+    align-items: center;
+    flex-shrink: 0;
+    margin-right: 8px;
+
+    &:hover {
+      background: rgba(0, 0, 0, 0.025);
+    }
+  }
+
+  .breadcrumb-container {
+    flex-shrink: 0;
+  }
+
+  .topmenu-container {
+    position: absolute;
+    left: 50px;
+  }
+
+  .topbar-container {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    overflow: hidden;
+    margin-left: 8px;
+  }
+
+  .errLog-container {
+    display: inline-block;
+    vertical-align: top;
+  }
+
+  .right-menu {
+    height: 100%;
+    line-height: 50px;
+    display: flex;
+    align-items: center;
+    margin-left: auto;
+
+    &:focus {
+      outline: none;
+    }
+
+    .right-menu-item {
+      display: inline-block;
+      padding: 0 8px;
+      height: 100%;
+      font-size: 18px;
+      color: #5a5e66;
+      vertical-align: text-bottom;
+
+      &.hover-effect {
+        cursor: pointer;
+        transition: background 0.3s;
+
+        &:hover {
+          background: rgba(0, 0, 0, 0.025);
+        }
+      }
+
+      &.theme-switch-wrapper {
+        display: flex;
+        align-items: center;
+
+        svg {
+          transition: transform 0.3s;
+          
+          &:hover {
+            transform: scale(1.15);
+          }
+        }
+      }
+    }
+
+    .avatar-container {
+      margin-right: 0px;
+      padding-right: 0px;
+
+      .avatar-wrapper {
+        margin-top: 10px;
+        right: 8px;
+        position: relative;
+
+        .user-avatar {
+          cursor: pointer;
+          width: 30px;
+          height: 30px;
+          margin-right: 8px;
+          border-radius: 50%;
+        }
+
+        .user-nickname{
+          position: relative;
+          left: 0px;
+          bottom: 10px;
+          font-size: 14px;
+          font-weight: bold;
+        }
+
+        i {
+          cursor: pointer;
+          position: absolute;
+          right: -20px;
+          top: 25px;
+          font-size: 12px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 315 - 0
jd-logistics-ui-vue3/src/layout/components/Settings/index.vue

@@ -0,0 +1,315 @@
+<template>
+  <el-drawer v-model="showSettings" :withHeader="false" :lock-scroll="false" direction="rtl" size="300px">
+    <div class="setting-drawer-title">
+      <h3 class="drawer-title">菜单导航设置</h3>
+    </div>
+    <div class="nav-wrap">
+      <el-tooltip content="左侧菜单" placement="bottom">
+        <div class="item left" @click="handleNavType(1)" :class="{ activeItem: navType == 1 }">
+          <b></b><b></b>
+        </div>
+      </el-tooltip>
+
+      <el-tooltip content="混合菜单" placement="bottom">
+        <div class="item mix" @click="handleNavType(2)" :class="{ activeItem: navType == 2 }">
+          <b></b><b></b>
+        </div>
+      </el-tooltip>
+      <el-tooltip content="顶部菜单" placement="bottom">
+        <div class="item top" @click="handleNavType(3)" :class="{ activeItem: navType == 3 }">
+          <b></b><b></b>
+        </div>
+      </el-tooltip>
+    </div>
+    <div class="setting-drawer-title">
+      <h3 class="drawer-title">主题风格设置</h3>
+    </div>
+    <div class="setting-drawer-block-checbox">
+      <div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-dark')">
+        <img src="@/assets/images/dark.svg" alt="dark" />
+        <div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
+          <i aria-label="图标: check" class="anticon anticon-check">
+            <svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
+              <path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" />
+            </svg>
+          </i>
+        </div>
+      </div>
+      <div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-light')">
+        <img src="@/assets/images/light.svg" alt="light" />
+        <div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
+          <i aria-label="图标: check" class="anticon anticon-check">
+            <svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
+              <path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" />
+            </svg>
+          </i>
+        </div>
+      </div>
+    </div>
+    <div class="drawer-item">
+      <span>主题颜色</span>
+      <span class="comp-style">
+        <el-color-picker v-model="theme" :predefine="predefineColors" @change="themeChange"/>
+      </span>
+    </div>
+    <el-divider />
+
+    <h3 class="drawer-title">系统布局配置</h3>
+
+    <div class="drawer-item">
+      <span>开启 Tags-Views</span>
+      <span class="comp-style">
+        <el-switch v-model="settingsStore.tagsView" class="drawer-switch" />
+      </span>
+    </div>
+
+    <div class="drawer-item">
+      <span>显示页签图标</span>
+      <span class="comp-style">
+        <el-switch v-model="settingsStore.tagsIcon" :disabled="!settingsStore.tagsView" class="drawer-switch" />
+      </span>
+    </div>
+
+    <div class="drawer-item">
+      <span>固定 Header</span>
+      <span class="comp-style">
+        <el-switch v-model="settingsStore.fixedHeader" class="drawer-switch" />
+      </span>
+    </div>
+
+    <div class="drawer-item">
+      <span>显示 Logo</span>
+      <span class="comp-style">
+        <el-switch v-model="settingsStore.sidebarLogo" class="drawer-switch" />
+      </span>
+    </div>
+
+    <div class="drawer-item">
+      <span>动态标题</span>
+      <span class="comp-style">
+        <el-switch v-model="settingsStore.dynamicTitle" @change="dynamicTitleChange" class="drawer-switch" />
+      </span>
+    </div>
+
+    <div class="drawer-item">
+      <span>底部版权</span>
+      <span class="comp-style">
+        <el-switch v-model="settingsStore.footerVisible" class="drawer-switch" />
+      </span>
+    </div>
+
+    <el-divider />
+
+    <el-button type="primary" plain icon="DocumentAdd" @click="saveSetting">保存配置</el-button>
+    <el-button plain icon="Refresh" @click="resetSetting">重置配置</el-button>
+  </el-drawer>
+
+</template>
+
+<script setup>
+import useAppStore from '@/store/modules/app'
+import useSettingsStore from '@/store/modules/settings'
+import usePermissionStore from '@/store/modules/permission'
+import { handleThemeStyle } from '@/utils/theme'
+
+const { proxy } = getCurrentInstance()
+const appStore = useAppStore()
+const settingsStore = useSettingsStore()
+const permissionStore = usePermissionStore()
+const showSettings = ref(false)
+const navType = ref(settingsStore.navType)
+const theme = ref(settingsStore.theme)
+const sideTheme = ref(settingsStore.sideTheme)
+const storeSettings = computed(() => settingsStore)
+const predefineColors = ref(["#409EFF", "#ff4500", "#ff8c00", "#ffd700", "#90ee90", "#00ced1", "#1e90ff", "#c71585"])
+
+/** 是否需要dynamicTitle */
+function dynamicTitleChange() {
+  useSettingsStore().setTitle(useSettingsStore().title)
+}
+
+function themeChange(val) {
+  settingsStore.theme = val
+  handleThemeStyle(val)
+}
+
+function handleTheme(val) {
+  settingsStore.sideTheme = val
+  sideTheme.value = val
+}
+
+function handleNavType(val) {
+  settingsStore.navType = val
+  navType.value = val
+}
+
+/** 菜单导航设置 */
+watch(() => navType, val => {
+  if (val.value == 1) {
+    appStore.sidebar.opened = true
+    appStore.toggleSideBarHide(false)
+  }
+  if (val.value == 2) {
+    appStore.sidebar.opened = true
+  }
+  if (val.value == 3) {
+    appStore.sidebar.opened = false
+    appStore.toggleSideBarHide(true)
+  }
+  if ([1, 3].includes(val.value)) {
+      permissionStore.setSidebarRouters(permissionStore.defaultRoutes)
+  }
+  }, { immediate: true, deep: true }
+)
+
+function saveSetting() {
+  proxy.$modal.loading("正在保存到本地,请稍候...")
+  let layoutSetting = {
+    "navType": storeSettings.value.navType,
+    "tagsView": storeSettings.value.tagsView,
+    "tagsIcon": storeSettings.value.tagsIcon,
+    "fixedHeader": storeSettings.value.fixedHeader,
+    "sidebarLogo": storeSettings.value.sidebarLogo,
+    "dynamicTitle": storeSettings.value.dynamicTitle,
+    "footerVisible": storeSettings.value.footerVisible,
+    "sideTheme": storeSettings.value.sideTheme,
+    "theme": storeSettings.value.theme
+  }
+  localStorage.setItem("layout-setting", JSON.stringify(layoutSetting))
+  setTimeout(proxy.$modal.closeLoading(), 1000)
+}
+
+function resetSetting() {
+  proxy.$modal.loading("正在清除设置缓存并刷新,请稍候...")
+  localStorage.removeItem("layout-setting")
+  setTimeout("window.location.reload()", 1000)
+}
+
+function openSetting() {
+  showSettings.value = true
+}
+
+defineExpose({
+  openSetting
+})
+</script>
+
+<style lang='scss' scoped>
+.setting-drawer-title {
+  margin-bottom: 12px;
+  color: var(--el-text-color-primary, rgba(0, 0, 0, 0.85));
+  line-height: 22px;
+  font-weight: bold;
+
+  .drawer-title {
+    font-size: 14px;
+  }
+}
+
+.setting-drawer-block-checbox {
+  display: flex;
+  justify-content: flex-start;
+  align-items: center;
+  margin-top: 10px;
+  margin-bottom: 20px;
+
+  .setting-drawer-block-checbox-item {
+    position: relative;
+    margin-right: 16px;
+    border-radius: 2px;
+    cursor: pointer;
+
+    img {
+      width: 48px;
+      height: 48px;
+    }
+
+    .setting-drawer-block-checbox-selectIcon {
+      position: absolute;
+      top: 0;
+      right: 0;
+      width: 100%;
+      height: 100%;
+      padding-top: 15px;
+      padding-left: 24px;
+      color: #1890ff;
+      font-weight: 700;
+      font-size: 14px;
+    }
+  }
+}
+
+.drawer-item {
+  color: var(--el-text-color-regular, rgba(0, 0, 0, 0.65));
+  padding: 12px 0;
+  font-size: 14px;
+
+  .comp-style {
+    float: right;
+    margin: -3px 8px 0px 0px;
+  }
+}
+
+// 导航模式
+.nav-wrap {
+  display: flex;
+  justify-content: flex-start;
+  align-items: center;
+  margin-top: 10px;
+  margin-bottom: 20px;
+
+  .activeItem {
+    border: 2px solid var(--el-color-primary) !important;
+  }
+
+  .item {
+    position: relative;
+    margin-right: 16px;
+    cursor: pointer;
+    width: 56px;
+    height: 48px;
+    border-radius: 4px;
+    background: #f0f2f5;
+    border: 2px solid transparent;
+  }
+
+  .left {
+    b:first-child {
+      display: block;
+      height: 30%;
+      background: #fff;
+    }
+    b:last-child {
+      width: 30%;
+      background: #1b2a47;
+      position: absolute;
+      height: 100%;
+      top: 0;
+      border-radius: 4px 0 0 4px;
+    }
+  }
+  .mix {
+    b:first-child {
+      border-radius: 4px 4px 0 0;
+      display: block;
+      height: 30%;
+      background: #1b2a47;
+    }
+    b:last-child {
+      width: 30%;
+      background: #1b2a47;
+      position: absolute;
+      height: 70%;
+      border-radius: 0 0 0 4px;
+    }
+  }
+  .top {
+    b:first-child {
+      display: block;
+      height: 30%;
+      background: #1b2a47;
+      border-radius: 4px 4px 0 0;
+    }
+  }
+}
+</style>

+ 40 - 0
jd-logistics-ui-vue3/src/layout/components/Sidebar/Link.vue

@@ -0,0 +1,40 @@
+<template>
+  <component :is="type" v-bind="linkProps()">
+    <slot />
+  </component>
+</template>
+
+<script setup>
+import { isExternal } from '@/utils/validate'
+
+const props = defineProps({
+  to: {
+    type: [String, Object],
+    required: true
+  }
+})
+
+const isExt = computed(() => {
+  return isExternal(props.to)
+})
+
+const type = computed(() => {
+  if (isExt.value) {
+    return 'a'
+  }
+  return 'router-link'
+})
+
+function linkProps() {
+  if (isExt.value) {
+    return {
+      href: props.to,
+      target: '_blank',
+      rel: 'noopener'
+    }
+  }
+  return {
+    to: props.to
+  }
+}
+</script>

+ 102 - 0
jd-logistics-ui-vue3/src/layout/components/Sidebar/Logo.vue

@@ -0,0 +1,102 @@
+<template>
+  <div class="sidebar-logo-container" :class="{ 'collapse': collapse }">
+    <transition name="sidebarLogoFade">
+      <router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
+        <img v-if="logo" :src="logo" class="sidebar-logo" />
+        <h1 v-else class="sidebar-title">{{ title }}</h1>
+      </router-link>
+      <router-link v-else key="expand" class="sidebar-logo-link" to="/">
+        <img v-if="logo" :src="logo" class="sidebar-logo" />
+        <h1 class="sidebar-title">{{ title }}</h1>
+      </router-link>
+    </transition>
+  </div>
+</template>
+
+<script setup>
+import logo from '@/assets/logo/logo.png'
+import useSettingsStore from '@/store/modules/settings'
+import variables from '@/assets/styles/variables.module.scss'
+
+defineProps({
+  collapse: {
+    type: Boolean,
+    required: true
+  }
+})
+
+const title = import.meta.env.VITE_APP_TITLE
+const settingsStore = useSettingsStore()
+const sideTheme = computed(() => settingsStore.sideTheme)
+
+// 获取Logo背景色
+const getLogoBackground = computed(() => {
+  if (settingsStore.isDark) {
+    return 'var(--sidebar-bg)'
+  }
+  if (settingsStore.navType == 3) {
+    return variables.menuLightBg
+  }
+  return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBg
+})
+
+// 获取Logo文字颜色
+const getLogoTextColor = computed(() => {
+  if (settingsStore.isDark) {
+    return 'var(--sidebar-text)'
+  }
+  if (settingsStore.navType == 3) {
+    return variables.menuLightText
+  }
+  return sideTheme.value === 'theme-dark' ? '#fff' : variables.menuLightText
+})
+</script>
+
+<style lang="scss" scoped>
+.sidebarLogoFade-enter-active {
+  transition: opacity 1.5s;
+}
+
+.sidebarLogoFade-enter,
+.sidebarLogoFade-leave-to {
+  opacity: 0;
+}
+
+.sidebar-logo-container {
+  position: relative;
+  height: 50px;
+  line-height: 50px;
+  background: v-bind(getLogoBackground);
+  text-align: center;
+  overflow: hidden;
+
+  & .sidebar-logo-link {
+    height: 100%;
+    width: 100%;
+
+    & .sidebar-logo {
+      width: 32px;
+      height: 32px;
+      vertical-align: middle;
+      margin-right: 12px;
+    }
+
+    & .sidebar-title {
+      display: inline-block;
+      margin: 0;
+      color: v-bind(getLogoTextColor);
+      font-weight: 600;
+      line-height: 50px;
+      font-size: 14px;
+      font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
+      vertical-align: middle;
+    }
+  }
+
+  &.collapse {
+    .sidebar-logo {
+      margin-right: 0px;
+    }
+  }
+}
+</style>

+ 100 - 0
jd-logistics-ui-vue3/src/layout/components/Sidebar/SidebarItem.vue

@@ -0,0 +1,100 @@
+<template>
+  <div v-if="!item.hidden">
+    <template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren) && !item.alwaysShow">
+      <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
+        <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
+          <svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)"/>
+          <template #title><span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span></template>
+        </el-menu-item>
+      </app-link>
+    </template>
+
+    <el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported>
+      <template v-if="item.meta" #title>
+        <svg-icon :icon-class="item.meta && item.meta.icon" />
+        <span class="menu-title" :title="hasTitle(item.meta.title)">{{ item.meta.title }}</span>
+      </template>
+
+      <sidebar-item
+        v-for="(child, index) in item.children"
+        :key="child.path + index"
+        :is-nest="true"
+        :item="child"
+        :base-path="resolvePath(child.path)"
+        class="nest-menu"
+      />
+    </el-sub-menu>
+  </div>
+</template>
+
+<script setup>
+import { isExternal } from '@/utils/validate'
+import AppLink from './Link'
+import { getNormalPath } from '@/utils/ruoyi'
+
+const props = defineProps({
+  // route object
+  item: {
+    type: Object,
+    required: true
+  },
+  isNest: {
+    type: Boolean,
+    default: false
+  },
+  basePath: {
+    type: String,
+    default: ''
+  }
+})
+
+const onlyOneChild = ref({})
+
+function hasOneShowingChild(children = [], parent) {
+  if (!children) {
+    children = []
+  }
+  const showingChildren = children.filter(item => {
+    if (item.hidden) {
+      return false
+    }
+    onlyOneChild.value = item
+    return true
+  })
+
+  // When there is only one child router, the child router is displayed by default
+  if (showingChildren.length === 1) {
+    return true
+  }
+
+  // Show parent if there are no child router to display
+  if (showingChildren.length === 0) {
+    onlyOneChild.value = { ...parent, path: '', noShowingChildren: true }
+    return true
+  }
+
+  return false
+}
+
+function resolvePath(routePath, routeQuery) {
+  if (isExternal(routePath)) {
+    return routePath
+  }
+  if (isExternal(props.basePath)) {
+    return props.basePath
+  }
+  if (routeQuery) {
+    let query = JSON.parse(routeQuery)
+    return { path: getNormalPath(props.basePath + '/' + routePath), query: query }
+  }
+  return getNormalPath(props.basePath + '/' + routePath)
+}
+
+function hasTitle(title){
+  if (title.length > 5) {
+    return title
+  } else {
+    return ""
+  }
+}
+</script>

+ 104 - 0
jd-logistics-ui-vue3/src/layout/components/Sidebar/index.vue

@@ -0,0 +1,104 @@
+<template>
+  <div :class="{ 'has-logo': showLogo }" class="sidebar-container">
+    <logo v-if="showLogo" :collapse="isCollapse" />
+    <el-scrollbar wrap-class="scrollbar-wrapper">
+      <el-menu
+        :default-active="activeMenu"
+        :collapse="isCollapse"
+        :background-color="getMenuBackground"
+        :text-color="getMenuTextColor"
+        :unique-opened="true"
+        :active-text-color="theme"
+        :collapse-transition="false"
+        mode="vertical"
+        :class="sideTheme"
+      >
+        <sidebar-item
+          v-for="(route, index) in sidebarRouters"
+          :key="route.path + index"
+          :item="route"
+          :base-path="route.path"
+        />
+      </el-menu>
+    </el-scrollbar>
+  </div>
+</template>
+
+<script setup>
+import Logo from './Logo'
+import SidebarItem from './SidebarItem'
+import variables from '@/assets/styles/variables.module.scss'
+import useAppStore from '@/store/modules/app'
+import useSettingsStore from '@/store/modules/settings'
+import usePermissionStore from '@/store/modules/permission'
+
+const route = useRoute()
+const appStore = useAppStore()
+const settingsStore = useSettingsStore()
+const permissionStore = usePermissionStore()
+
+const sidebarRouters = computed(() => permissionStore.sidebarRouters)
+const showLogo = computed(() => settingsStore.sidebarLogo)
+const sideTheme = computed(() => settingsStore.sideTheme)
+const theme = computed(() => settingsStore.theme)
+const isCollapse = computed(() => !appStore.sidebar.opened)
+
+// 获取菜单背景色
+const getMenuBackground = computed(() => {
+  if (settingsStore.isDark) {
+    return 'var(--sidebar-bg)'
+  }
+  return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBg
+})
+
+// 获取菜单文字颜色
+const getMenuTextColor = computed(() => {
+  if (settingsStore.isDark) {
+    return 'var(--sidebar-text)'
+  }
+  return sideTheme.value === 'theme-dark' ? variables.menuText : variables.menuLightText
+})
+
+const activeMenu = computed(() => {
+  const { meta, path } = route
+  if (meta.activeMenu) {
+    return meta.activeMenu
+  }
+  return path
+})
+</script>
+
+<style lang="scss" scoped>
+.sidebar-container {
+  background-color: v-bind(getMenuBackground);
+  
+  .scrollbar-wrapper {
+    background-color: v-bind(getMenuBackground);
+  }
+
+  .el-menu {
+    border: none;
+    height: 100%;
+    width: 100% !important;
+    
+    .el-menu-item, .el-sub-menu__title {
+      &:hover {
+        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
+      }
+    }
+
+    .el-menu-item {
+      color: v-bind(getMenuTextColor);
+      
+      &.is-active {
+        color: var(--menu-active-text, #409eff);
+        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
+      }
+    }
+
+    .el-sub-menu__title {
+      color: v-bind(getMenuTextColor);
+    }
+  }
+}
+</style>

+ 107 - 0
jd-logistics-ui-vue3/src/layout/components/TagsView/ScrollPane.vue

@@ -0,0 +1,107 @@
+<template>
+  <el-scrollbar
+    ref="scrollContainer"
+    :vertical="false"
+    class="scroll-container"
+    @wheel.prevent="handleScroll"
+  >
+    <slot />
+  </el-scrollbar>
+</template>
+
+<script setup>
+import useTagsViewStore from '@/store/modules/tagsView'
+
+const tagAndTagSpacing = ref(4)
+const { proxy } = getCurrentInstance()
+
+const scrollWrapper = computed(() => proxy.$refs.scrollContainer.$refs.wrapRef)
+
+onMounted(() => {
+  scrollWrapper.value.addEventListener('scroll', emitScroll, true)
+})
+
+onBeforeUnmount(() => {
+  scrollWrapper.value.removeEventListener('scroll', emitScroll)
+})
+
+function handleScroll(e) {
+  const eventDelta = e.wheelDelta || -e.deltaY * 40
+  const $scrollWrapper = scrollWrapper.value
+  $scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
+}
+
+const emits = defineEmits()
+const emitScroll = () => {
+  emits('scroll')
+}
+
+const tagsViewStore = useTagsViewStore()
+const visitedViews = computed(() => tagsViewStore.visitedViews)
+
+function moveToTarget(currentTag) {
+  const $container = proxy.$refs.scrollContainer.$el
+  const $containerWidth = $container.offsetWidth
+  const $scrollWrapper = scrollWrapper.value
+
+  let firstTag = null
+  let lastTag = null
+
+  // find first tag and last tag
+  if (visitedViews.value.length > 0) {
+    firstTag = visitedViews.value[0]
+    lastTag = visitedViews.value[visitedViews.value.length - 1]
+  }
+
+  if (firstTag === currentTag) {
+    $scrollWrapper.scrollLeft = 0
+  } else if (lastTag === currentTag) {
+    $scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
+  } else {
+    const tagListDom = document.getElementsByClassName('tags-view-item')
+    const currentIndex = visitedViews.value.findIndex(item => item === currentTag)
+    let prevTag = null
+    let nextTag = null
+    for (const k in tagListDom) {
+      if (k !== 'length' && Object.hasOwnProperty.call(tagListDom, k)) {
+        if (tagListDom[k].dataset.path === visitedViews.value[currentIndex - 1].path) {
+          prevTag = tagListDom[k]
+        }
+        if (tagListDom[k].dataset.path === visitedViews.value[currentIndex + 1].path) {
+          nextTag = tagListDom[k]
+        }
+      }
+    }
+
+    // the tag's offsetLeft after of nextTag
+    const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + tagAndTagSpacing.value
+
+    // the tag's offsetLeft before of prevTag
+    const beforePrevTagOffsetLeft = prevTag.offsetLeft - tagAndTagSpacing.value
+    if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
+      $scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
+    } else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
+      $scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
+    }
+  }
+}
+
+defineExpose({
+  moveToTarget,
+})
+</script>
+
+<style lang='scss' scoped>
+.scroll-container {
+  white-space: nowrap;
+  position: relative;
+  overflow: hidden;
+  width: 100%;
+  :deep(.el-scrollbar__bar) {
+    bottom: 0px;
+  }
+  :deep(.el-scrollbar__wrap) {
+    height: 39px;
+  }
+}
+</style>

+ 371 - 0
jd-logistics-ui-vue3/src/layout/components/TagsView/index.vue

@@ -0,0 +1,371 @@
+<template>
+  <div id="tags-view-container" class="tags-view-container">
+    <scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
+      <router-link
+        v-for="tag in visitedViews"
+        :key="tag.path"
+        :data-path="tag.path"
+        :class="{ 'active': isActive(tag), 'has-icon': tagsIcon }"
+        :to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
+        class="tags-view-item"
+        :style="activeStyle(tag)"
+        @click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
+        @contextmenu.prevent="openMenu(tag, $event)"
+      >
+        <svg-icon v-if="tagsIcon && tag.meta && tag.meta.icon && tag.meta.icon !== '#'" :icon-class="tag.meta.icon" />
+        {{ tag.title }}
+        <span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)">
+          <close class="el-icon-close" style="width: 1em; height: 1em;vertical-align: middle;" />
+        </span>
+      </router-link>
+    </scroll-pane>
+    <ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
+      <li @click="refreshSelectedTag(selectedTag)">
+        <refresh-right style="width: 1em; height: 1em;" /> 刷新页面
+      </li>
+      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
+        <close style="width: 1em; height: 1em;" /> 关闭当前
+      </li>
+      <li @click="closeOthersTags">
+        <circle-close style="width: 1em; height: 1em;" /> 关闭其他
+      </li>
+      <li v-if="!isFirstView()" @click="closeLeftTags">
+        <back style="width: 1em; height: 1em;" /> 关闭左侧
+      </li>
+      <li v-if="!isLastView()" @click="closeRightTags">
+        <right style="width: 1em; height: 1em;" /> 关闭右侧
+      </li>
+      <li @click="closeAllTags(selectedTag)">
+        <circle-close style="width: 1em; height: 1em;" /> 全部关闭
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script setup>
+import ScrollPane from './ScrollPane'
+import { getNormalPath } from '@/utils/ruoyi'
+import useTagsViewStore from '@/store/modules/tagsView'
+import useSettingsStore from '@/store/modules/settings'
+import usePermissionStore from '@/store/modules/permission'
+
+const visible = ref(false)
+const top = ref(0)
+const left = ref(0)
+const selectedTag = ref({})
+const affixTags = ref([])
+const scrollPaneRef = ref(null)
+
+const { proxy } = getCurrentInstance()
+const route = useRoute()
+const router = useRouter()
+
+const visitedViews = computed(() => useTagsViewStore().visitedViews)
+const routes = computed(() => usePermissionStore().routes)
+const theme = computed(() => useSettingsStore().theme)
+const tagsIcon = computed(() => useSettingsStore().tagsIcon)
+
+watch(route, () => {
+  addTags()
+  moveToCurrentTag()
+})
+
+watch(visible, (value) => {
+  if (value) {
+    document.body.addEventListener('click', closeMenu)
+  } else {
+    document.body.removeEventListener('click', closeMenu)
+  }
+})
+
+onMounted(() => {
+  initTags()
+  addTags()
+})
+
+function isActive(r) {
+  return r.path === route.path
+}
+
+function activeStyle(tag) {
+  if (!isActive(tag)) return {}
+  return {
+    "background-color": theme.value,
+    "border-color": theme.value
+  }
+}
+
+function isAffix(tag) {
+  return tag.meta && tag.meta.affix
+}
+
+function isFirstView() {
+  try {
+    return selectedTag.value.fullPath === '/index' || selectedTag.value.fullPath === visitedViews.value[1].fullPath
+  } catch (err) {
+    return false
+  }
+}
+
+function isLastView() {
+  try {
+    return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1].fullPath
+  } catch (err) {
+    return false
+  }
+}
+
+function filterAffixTags(routes, basePath = '') {
+  let tags = []
+  routes.forEach(route => {
+    if (route.meta && route.meta.affix) {
+      const tagPath = getNormalPath(basePath + '/' + route.path)
+      tags.push({
+        fullPath: tagPath,
+        path: tagPath,
+        name: route.name,
+        meta: { ...route.meta }
+      })
+    }
+    if (route.children) {
+      const tempTags = filterAffixTags(route.children, route.path)
+      if (tempTags.length >= 1) {
+        tags = [...tags, ...tempTags]
+      }
+    }
+  })
+  return tags
+}
+
+function initTags() {
+  const res = filterAffixTags(routes.value)
+  affixTags.value = res
+  for (const tag of res) {
+    // Must have tag name
+    if (tag.name) {
+       useTagsViewStore().addVisitedView(tag)
+    }
+  }
+}
+
+function addTags() {
+  const { name } = route
+  if (name) {
+    useTagsViewStore().addView(route)
+  }
+}
+
+function moveToCurrentTag() {
+  nextTick(() => {
+    for (const r of visitedViews.value) {
+      if (r.path === route.path) {
+        scrollPaneRef.value.moveToTarget(r)
+        // when query is different then update
+        if (r.fullPath !== route.fullPath) {
+          useTagsViewStore().updateVisitedView(route)
+        }
+      }
+    }
+  })
+}
+
+function refreshSelectedTag(view) {
+  proxy.$tab.refreshPage(view)
+  if (route.meta.link) {
+    useTagsViewStore().delIframeView(route)
+  }
+}
+
+function closeSelectedTag(view) {
+  proxy.$tab.closePage(view).then(({ visitedViews }) => {
+    if (isActive(view)) {
+      toLastView(visitedViews, view)
+    }
+  })
+}
+
+function closeRightTags() {
+  proxy.$tab.closeRightPage(selectedTag.value).then(visitedViews => {
+    if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
+      toLastView(visitedViews)
+    }
+  })
+}
+
+function closeLeftTags() {
+  proxy.$tab.closeLeftPage(selectedTag.value).then(visitedViews => {
+    if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
+      toLastView(visitedViews)
+    }
+  })
+}
+
+function closeOthersTags() {
+  router.push(selectedTag.value).catch(() => { })
+  proxy.$tab.closeOtherPage(selectedTag.value).then(() => {
+    moveToCurrentTag()
+  })
+}
+
+function closeAllTags(view) {
+  proxy.$tab.closeAllPage().then(({ visitedViews }) => {
+    if (affixTags.value.some(tag => tag.path === route.path)) {
+      return
+    }
+    toLastView(visitedViews, view)
+  })
+}
+
+function toLastView(visitedViews, view) {
+  const latestView = visitedViews.slice(-1)[0]
+  if (latestView) {
+    router.push(latestView.fullPath)
+  } else {
+    // now the default is to redirect to the home page if there is no tags-view,
+    // you can adjust it according to your needs.
+    if (view.name === 'Dashboard') {
+      // to reload home page
+      router.replace({ path: '/redirect' + view.fullPath })
+    } else {
+      router.push('/')
+    }
+  }
+}
+
+function openMenu(tag, e) {
+  const menuMinWidth = 105
+  const offsetLeft = proxy.$el.getBoundingClientRect().left // container margin left
+  const offsetWidth = proxy.$el.offsetWidth // container width
+  const maxLeft = offsetWidth - menuMinWidth // left boundary
+  const l = e.clientX - offsetLeft + 15 // 15: margin right
+
+  if (l > maxLeft) {
+    left.value = maxLeft
+  } else {
+    left.value = l
+  }
+
+  top.value = e.clientY
+  visible.value = true
+  selectedTag.value = tag
+}
+
+function closeMenu() {
+  visible.value = false
+}
+
+function handleScroll() {
+  closeMenu()
+}
+</script>
+
+<style lang="scss" scoped>
+.tags-view-container {
+  height: 34px;
+  width: 100%;
+  background: var(--tags-bg, #fff);
+  border-bottom: 1px solid var(--tags-item-border, #d8dce5);
+  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
+
+  .tags-view-wrapper {
+    .tags-view-item {
+      display: inline-block;
+      position: relative;
+      cursor: pointer;
+      height: 26px;
+      line-height: 26px;
+      border: 1px solid var(--tags-item-border, #d8dce5);
+      color: var(--tags-item-text, #495060);
+      background: var(--tags-item-bg, #fff);
+      padding: 0 8px;
+      font-size: 12px;
+      margin-left: 5px;
+      margin-top: 4px;
+
+      &:first-of-type {
+        margin-left: 15px;
+      }
+
+      &:last-of-type {
+        margin-right: 15px;
+      }
+
+      &.active {
+        background-color: #42b983;
+        color: #fff;
+        border-color: #42b983;
+
+        &::before {
+          content: '';
+          background: #fff;
+          display: inline-block;
+          width: 8px;
+          height: 8px;
+          border-radius: 50%;
+          position: relative;
+          margin-right: 5px;
+        }
+      }
+    }
+  }
+
+  .tags-view-item.active.has-icon::before {
+    content: none !important;
+  }
+
+  .contextmenu {
+    margin: 0;
+    background: var(--el-bg-color-overlay, #fff);
+    z-index: 3000;
+    position: absolute;
+    list-style-type: none;
+    padding: 5px 0;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 400;
+    color: var(--tags-item-text, #333);
+    box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
+    border: 1px solid var(--el-border-color-light, #e4e7ed);
+
+    li {
+      margin: 0;
+      padding: 7px 16px;
+      cursor: pointer;
+
+      &:hover {
+        background: var(--tags-item-hover, #eee);
+      }
+    }
+  }
+}
+</style>
+
+<style lang="scss">
+//reset element css of el-icon-close
+.tags-view-wrapper {
+  .tags-view-item {
+    .el-icon-close {
+      width: 16px;
+      height: 16px;
+      vertical-align: 2px;
+      border-radius: 50%;
+      text-align: center;
+      transition: all .3s cubic-bezier(.645, .045, .355, 1);
+      transform-origin: 100% 50%;
+
+      &:before {
+        transform: scale(.6);
+        display: inline-block;
+        vertical-align: -3px;
+      }
+
+      &:hover {
+        background-color: var(--tags-close-hover, #b4bccc);
+        color: #fff;
+        width: 12px !important;
+        height: 12px !important;
+      }
+    }
+  }
+}
+</style>

+ 99 - 0
jd-logistics-ui-vue3/src/layout/components/TopBar/index.vue

@@ -0,0 +1,99 @@
+<template>
+  <el-menu class="topbar-menu" :ellipsis="false" :default-active="activeMenu" :active-text-color="theme" mode="horizontal">
+    <sidebar-item :key="route.path + index" v-for="(route, index) in topMenus" :item="route" :base-path="route.path" />
+
+    <el-sub-menu index="more" class="el-sub-menu__hide-arrow" v-if="moreRoutes.length > 0">
+      <template #title>
+        <span>更多菜单</span>
+      </template>
+      <sidebar-item :key="route.path + index" v-for="(route, index) in moreRoutes" :item="route" :base-path="route.path" />
+    </el-sub-menu>
+  </el-menu>
+</template>
+
+<script setup>
+import SidebarItem from '../Sidebar/SidebarItem'
+import useAppStore from '@/store/modules/app'
+import useSettingsStore from '@/store/modules/settings'
+import usePermissionStore from '@/store/modules/permission'
+
+const route = useRoute()
+const appStore = useAppStore()
+const settingsStore = useSettingsStore()
+const permissionStore = usePermissionStore()
+
+const sidebarRouters = computed(() => permissionStore.sidebarRouters)
+const theme = computed(() => settingsStore.theme)
+const device = computed(() => appStore.device)
+const activeMenu = computed(() => {
+  const { meta, path } = route
+  if (meta.activeMenu) {
+    return meta.activeMenu
+  }
+  return path
+})
+
+const visibleNumber = ref(5)
+const topMenus = computed(() => {
+  return permissionStore.sidebarRouters.filter((f) => !f.hidden).slice(0, visibleNumber.value)
+})
+const moreRoutes = computed(() => {
+  return permissionStore.sidebarRouters.filter((f) => !f.hidden).slice(visibleNumber.value, sidebarRouters.value.length - visibleNumber.value)
+})
+function setVisibleNumber() {
+  const width = document.body.getBoundingClientRect().width / 3
+  visibleNumber.value = parseInt(width / 85)
+}
+
+onMounted(() => {
+  window.addEventListener('resize', setVisibleNumber)
+})
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', setVisibleNumber)
+})
+
+onMounted(() => {
+  setVisibleNumber()
+})
+</script>
+
+<style lang="scss">
+/* menu item */
+.topbar-menu.el-menu--horizontal .el-submenu__title, .topbar-menu.el-menu--horizontal .el-menu-item {
+  padding: 0 10px !important;
+}
+
+.topbar-menu.el-menu--horizontal > .el-menu-item {
+  float: left;
+  height: 50px !important;
+  line-height: 50px !important;
+  color: #303133 !important;
+  padding: 0 5px !important;
+  margin: 0 10px !important;
+}
+
+.el-sub-menu.is-active .svg-icon, .el-menu-item.is-active .svg-icon + span, .el-sub-menu.is-active .svg-icon + span, .el-sub-menu.is-active .el-sub-menu__title span {
+  color: v-bind(theme);
+}
+
+/* sub-menu item */
+.topbar-menu.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
+  float: left;
+  line-height: 50px !important;
+  color: #303133 !important;
+  margin: 0 15px -3px!important;
+}
+
+/* topbar more arrow */
+.topbar-menu .el-sub-menu .el-sub-menu__icon-arrow {
+  position: static;
+  margin-left: 8px;
+  margin-top: 0px;
+  display: block !important;
+}
+
+/* menu__title el-menu-item */
+.topbar-menu.el-menu--horizontal .el-sub-menu__title, .topbar-menu.el-menu--horizontal .el-menu-item {
+  height: 60px;
+}
+</style>

+ 4 - 0
jd-logistics-ui-vue3/src/layout/components/index.js

@@ -0,0 +1,4 @@
+export { default as AppMain } from './AppMain'
+export { default as Navbar } from './Navbar'
+export { default as Settings } from './Settings'
+export { default as TagsView } from './TagsView/index.vue'

+ 117 - 0
jd-logistics-ui-vue3/src/layout/index.vue

@@ -0,0 +1,117 @@
+<template>
+  <div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
+    <div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
+    <sidebar v-if="!sidebar.hide" class="sidebar-container" />
+    <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
+      <div :class="{ 'fixed-header': fixedHeader }">
+        <navbar @setLayout="setLayout" />
+        <tags-view v-if="needTagsView" />
+      </div>
+      <app-main />
+      <settings ref="settingRef" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { useWindowSize } from '@vueuse/core'
+import Sidebar from './components/Sidebar/index.vue'
+import { AppMain, Navbar, Settings, TagsView } from './components'
+import useAppStore from '@/store/modules/app'
+import useSettingsStore from '@/store/modules/settings'
+
+const settingsStore = useSettingsStore()
+const theme = computed(() => settingsStore.theme)
+const sideTheme = computed(() => settingsStore.sideTheme)
+const sidebar = computed(() => useAppStore().sidebar)
+const device = computed(() => useAppStore().device)
+const needTagsView = computed(() => settingsStore.tagsView)
+const fixedHeader = computed(() => settingsStore.fixedHeader)
+
+const classObj = computed(() => ({
+  hideSidebar: !sidebar.value.opened,
+  openSidebar: sidebar.value.opened,
+  withoutAnimation: sidebar.value.withoutAnimation,
+  mobile: device.value === 'mobile'
+}))
+
+const { width, height } = useWindowSize()
+const WIDTH = 992 // refer to Bootstrap's responsive design
+
+watch(() => device.value, () => {
+  if (device.value === 'mobile' && sidebar.value.opened) {
+    useAppStore().closeSideBar({ withoutAnimation: false })
+  }
+})
+
+watchEffect(() => {
+  if (width.value - 1 < WIDTH) {
+    useAppStore().toggleDevice('mobile')
+    useAppStore().closeSideBar({ withoutAnimation: true })
+  } else {
+    useAppStore().toggleDevice('desktop')
+  }
+})
+
+function handleClickOutside() {
+  useAppStore().closeSideBar({ withoutAnimation: false })
+}
+
+const settingRef = ref(null)
+function setLayout() {
+  settingRef.value.openSetting()
+}
+</script>
+
+<style lang="scss" scoped>
+@use "@/assets/styles/mixin.scss" as mix;
+@use "@/assets/styles/variables.module.scss" as vars;
+
+.app-wrapper {
+  @include mix.clearfix;
+  position: relative;
+  height: 100%;
+  width: 100%;
+
+  &.mobile.openSidebar {
+    position: fixed;
+    top: 0;
+  }
+}
+
+.main-container:has(.fixed-header) {
+  height: 100vh;
+  overflow: hidden;
+}
+
+.drawer-bg {
+  background: #000;
+  opacity: 0.3;
+  width: 100%;
+  top: 0;
+  height: 100%;
+  position: absolute;
+  z-index: 999;
+}
+
+.fixed-header {
+  position: fixed;
+  top: 0;
+  right: 0;
+  z-index: 9;
+  width: calc(100% - #{vars.$base-sidebar-width});
+  transition: width 0.28s;
+}
+
+.hideSidebar .fixed-header {
+  width: calc(100% - 54px);
+}
+
+.sidebarHide .fixed-header {
+  width: 100%;
+}
+
+.mobile .fixed-header {
+  width: 100%;
+}
+</style>

+ 84 - 0
jd-logistics-ui-vue3/src/main.js

@@ -0,0 +1,84 @@
+import { createApp } from 'vue'
+
+import Cookies from 'js-cookie'
+
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import 'element-plus/theme-chalk/dark/css-vars.css'
+import locale from 'element-plus/es/locale/lang/zh-cn'
+
+import '@/assets/styles/index.scss' // global css
+
+import App from './App'
+import store from './store'
+import router from './router'
+import directive from './directive' // directive
+
+// 注册指令
+import plugins from './plugins' // plugins
+import { download } from '@/utils/request'
+
+// svg图标
+import 'virtual:svg-icons-register'
+import SvgIcon from '@/components/SvgIcon'
+import elementIcons from '@/components/SvgIcon/svgicon'
+
+import './permission' // permission control
+
+import { useDict } from '@/utils/dict'
+import { getConfigKey } from "@/api/system/config"
+import { parseTime, resetForm, addDateRange, handleTree, selectDictLabel, selectDictLabels } from '@/utils/ruoyi'
+
+// 分页组件
+import Pagination from '@/components/Pagination'
+// 自定义表格工具组件
+import RightToolbar from '@/components/RightToolbar'
+// 富文本组件
+import Editor from "@/components/Editor"
+// 文件上传组件
+import FileUpload from "@/components/FileUpload"
+// 图片上传组件
+import ImageUpload from "@/components/ImageUpload"
+// 图片预览组件
+import ImagePreview from "@/components/ImagePreview"
+// 字典标签组件
+import DictTag from '@/components/DictTag'
+
+const app = createApp(App)
+
+// 全局方法挂载
+app.config.globalProperties.useDict = useDict
+app.config.globalProperties.download = download
+app.config.globalProperties.parseTime = parseTime
+app.config.globalProperties.resetForm = resetForm
+app.config.globalProperties.handleTree = handleTree
+app.config.globalProperties.addDateRange = addDateRange
+app.config.globalProperties.getConfigKey = getConfigKey
+app.config.globalProperties.selectDictLabel = selectDictLabel
+app.config.globalProperties.selectDictLabels = selectDictLabels
+
+// 全局组件挂载
+app.component('DictTag', DictTag)
+app.component('Pagination', Pagination)
+app.component('FileUpload', FileUpload)
+app.component('ImageUpload', ImageUpload)
+app.component('ImagePreview', ImagePreview)
+app.component('RightToolbar', RightToolbar)
+app.component('Editor', Editor)
+
+app.use(router)
+app.use(store)
+app.use(plugins)
+app.use(elementIcons)
+app.component('svg-icon', SvgIcon)
+
+directive(app)
+
+// 使用element-plus 并且设置全局的大小
+app.use(ElementPlus, {
+  locale: locale,
+  // 支持 large、default、small
+  size: Cookies.get('size') || 'default'
+})
+
+app.mount('#app')

+ 69 - 0
jd-logistics-ui-vue3/src/permission.js

@@ -0,0 +1,69 @@
+import router from './router'
+import { ElMessage } from 'element-plus'
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+import { getToken } from '@/utils/auth'
+import { isHttp, isPathMatch } from '@/utils/validate'
+import { isRelogin } from '@/utils/request'
+import useUserStore from '@/store/modules/user'
+import useSettingsStore from '@/store/modules/settings'
+import usePermissionStore from '@/store/modules/permission'
+
+NProgress.configure({ showSpinner: false })
+
+const whiteList = ['/login', '/register']
+
+const isWhiteList = (path) => {
+  return whiteList.some(pattern => isPathMatch(pattern, path))
+}
+
+router.beforeEach((to, from, next) => {
+  NProgress.start()
+  if (getToken()) {
+    to.meta.title && useSettingsStore().setTitle(to.meta.title)
+    /* has token*/
+    if (to.path === '/login') {
+      next({ path: '/' })
+      NProgress.done()
+    } else if (isWhiteList(to.path)) {
+      next()
+    } else {
+      if (useUserStore().roles.length === 0) {
+        isRelogin.show = true
+        // 判断当前用户是否已拉取完user_info信息
+        useUserStore().getInfo().then(() => {
+          isRelogin.show = false
+          usePermissionStore().generateRoutes().then(accessRoutes => {
+            // 根据roles权限生成可访问的路由表
+            accessRoutes.forEach(route => {
+              if (!isHttp(route.path)) {
+                router.addRoute(route) // 动态添加可访问路由表
+              }
+            })
+            next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
+          })
+        }).catch(err => {
+          useUserStore().logOut().then(() => {
+            ElMessage.error(err)
+            next({ path: '/' })
+          })
+        })
+      } else {
+        next()
+      }
+    }
+  } else {
+    // 没有token
+    if (isWhiteList(to.path)) {
+      // 在免登录白名单,直接进入
+      next()
+    } else {
+      next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
+      NProgress.done()
+    }
+  }
+})
+
+router.afterEach(() => {
+  NProgress.done()
+})

+ 60 - 0
jd-logistics-ui-vue3/src/plugins/auth.js

@@ -0,0 +1,60 @@
+import useUserStore from '@/store/modules/user'
+
+function authPermission(permission) {
+  const all_permission = "*:*:*"
+  const permissions = useUserStore().permissions
+  if (permission && permission.length > 0) {
+    return permissions.some(v => {
+      return all_permission === v || v === permission
+    })
+  } else {
+    return false
+  }
+}
+
+function authRole(role) {
+  const super_admin = "admin"
+  const roles = useUserStore().roles
+  if (role && role.length > 0) {
+    return roles.some(v => {
+      return super_admin === v || v === role
+    })
+  } else {
+    return false
+  }
+}
+
+export default {
+  // 验证用户是否具备某权限
+  hasPermi(permission) {
+    return authPermission(permission)
+  },
+  // 验证用户是否含有指定权限,只需包含其中一个
+  hasPermiOr(permissions) {
+    return permissions.some(item => {
+      return authPermission(item)
+    })
+  },
+  // 验证用户是否含有指定权限,必须全部拥有
+  hasPermiAnd(permissions) {
+    return permissions.every(item => {
+      return authPermission(item)
+    })
+  },
+  // 验证用户是否具备某角色
+  hasRole(role) {
+    return authRole(role)
+  },
+  // 验证用户是否含有指定角色,只需包含其中一个
+  hasRoleOr(roles) {
+    return roles.some(item => {
+      return authRole(item)
+    })
+  },
+  // 验证用户是否含有指定角色,必须全部拥有
+  hasRoleAnd(roles) {
+    return roles.every(item => {
+      return authRole(item)
+    })
+  }
+}

+ 79 - 0
jd-logistics-ui-vue3/src/plugins/cache.js

@@ -0,0 +1,79 @@
+const sessionCache = {
+  set (key, value) {
+    if (!sessionStorage) {
+      return
+    }
+    if (key != null && value != null) {
+      sessionStorage.setItem(key, value)
+    }
+  },
+  get (key) {
+    if (!sessionStorage) {
+      return null
+    }
+    if (key == null) {
+      return null
+    }
+    return sessionStorage.getItem(key)
+  },
+  setJSON (key, jsonValue) {
+    if (jsonValue != null) {
+      this.set(key, JSON.stringify(jsonValue))
+    }
+  },
+  getJSON (key) {
+    const value = this.get(key)
+    if (value != null) {
+      return JSON.parse(value)
+    }
+    return null
+  },
+  remove (key) {
+    sessionStorage.removeItem(key)
+  }
+}
+const localCache = {
+  set (key, value) {
+    if (!localStorage) {
+      return
+    }
+    if (key != null && value != null) {
+      localStorage.setItem(key, value)
+    }
+  },
+  get (key) {
+    if (!localStorage) {
+      return null
+    }
+    if (key == null) {
+      return null
+    }
+    return localStorage.getItem(key)
+  },
+  setJSON (key, jsonValue) {
+    if (jsonValue != null) {
+      this.set(key, JSON.stringify(jsonValue))
+    }
+  },
+  getJSON (key) {
+    const value = this.get(key)
+    if (value != null) {
+      return JSON.parse(value)
+    }
+    return null
+  },
+  remove (key) {
+    localStorage.removeItem(key)
+  }
+}
+
+export default {
+  /**
+   * 会话级缓存
+   */
+  session: sessionCache,
+  /**
+   * 本地缓存
+   */
+  local: localCache
+}

+ 45 - 0
jd-logistics-ui-vue3/src/plugins/download.js

@@ -0,0 +1,45 @@
+import axios from 'axios'
+import { ElLoading, ElMessage } from 'element-plus'
+import { saveAs } from 'file-saver'
+import { getToken } from '@/utils/auth'
+import errorCode from '@/utils/errorCode'
+import { blobValidate } from '@/utils/ruoyi'
+
+const baseURL = import.meta.env.VITE_APP_BASE_API
+let downloadLoadingInstance
+
+export default {
+  zip(url, name) {
+    var url = baseURL + url
+    downloadLoadingInstance = ElLoading.service({ text: "正在下载数据,请稍候", background: "rgba(0, 0, 0, 0.7)", })
+    axios({
+      method: 'get',
+      url: url,
+      responseType: 'blob',
+      headers: { 'Authorization': 'Bearer ' + getToken() }
+    }).then((res) => {
+      const isBlob = blobValidate(res.data)
+      if (isBlob) {
+        const blob = new Blob([res.data], { type: 'application/zip' })
+        this.saveAs(blob, name)
+      } else {
+        this.printErrMsg(res.data)
+      }
+      downloadLoadingInstance.close()
+    }).catch((r) => {
+      console.error(r)
+      ElMessage.error('下载文件出现错误,请联系管理员!')
+      downloadLoadingInstance.close()
+    })
+  },
+  saveAs(text, name, opts) {
+    saveAs(text, name, opts)
+  },
+  async printErrMsg(data) {
+    const resText = await data.text()
+    const rspObj = JSON.parse(resText)
+    const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default']
+    ElMessage.error(errMsg)
+  }
+}
+

+ 18 - 0
jd-logistics-ui-vue3/src/plugins/index.js

@@ -0,0 +1,18 @@
+import tab from './tab'
+import auth from './auth'
+import cache from './cache'
+import modal from './modal'
+import download from './download'
+
+export default function installPlugins(app){
+  // 页签操作
+  app.config.globalProperties.$tab = tab
+  // 认证对象
+  app.config.globalProperties.$auth = auth
+  // 缓存对象
+  app.config.globalProperties.$cache = cache
+  // 模态框对象
+  app.config.globalProperties.$modal = modal
+  // 下载文件
+  app.config.globalProperties.$download = download
+}

+ 82 - 0
jd-logistics-ui-vue3/src/plugins/modal.js

@@ -0,0 +1,82 @@
+import { ElMessage, ElMessageBox, ElNotification, ElLoading } from 'element-plus'
+
+let loadingInstance
+
+export default {
+  // 消息提示
+  msg(content) {
+    ElMessage.info(content)
+  },
+  // 错误消息
+  msgError(content) {
+    ElMessage.error(content)
+  },
+  // 成功消息
+  msgSuccess(content) {
+    ElMessage.success(content)
+  },
+  // 警告消息
+  msgWarning(content) {
+    ElMessage.warning(content)
+  },
+  // 弹出提示
+  alert(content) {
+    ElMessageBox.alert(content, "系统提示")
+  },
+  // 错误提示
+  alertError(content) {
+    ElMessageBox.alert(content, "系统提示", { type: 'error' })
+  },
+  // 成功提示
+  alertSuccess(content) {
+    ElMessageBox.alert(content, "系统提示", { type: 'success' })
+  },
+  // 警告提示
+  alertWarning(content) {
+    ElMessageBox.alert(content, "系统提示", { type: 'warning' })
+  },
+  // 通知提示
+  notify(content) {
+    ElNotification.info(content)
+  },
+  // 错误通知
+  notifyError(content) {
+    ElNotification.error(content)
+  },
+  // 成功通知
+  notifySuccess(content) {
+    ElNotification.success(content)
+  },
+  // 警告通知
+  notifyWarning(content) {
+    ElNotification.warning(content)
+  },
+  // 确认窗体
+  confirm(content) {
+    return ElMessageBox.confirm(content, "系统提示", {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: "warning",
+    })
+  },
+  // 提交内容
+  prompt(content) {
+    return ElMessageBox.prompt(content, "系统提示", {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: "warning",
+    })
+  },
+  // 打开遮罩层
+  loading(content) {
+    loadingInstance = ElLoading.service({
+      lock: true,
+      text: content,
+      background: "rgba(0, 0, 0, 0.7)",
+    })
+  },
+  // 关闭遮罩层
+  closeLoading() {
+    loadingInstance.close()
+  }
+}

+ 71 - 0
jd-logistics-ui-vue3/src/plugins/tab.js

@@ -0,0 +1,71 @@
+import useTagsViewStore from '@/store/modules/tagsView'
+import router from '@/router'
+
+export default {
+  // 刷新当前tab页签
+  refreshPage(obj) {
+    const { path, query, matched } = router.currentRoute.value
+    if (obj === undefined) {
+      matched.forEach((m) => {
+        if (m.components && m.components.default && m.components.default.name) {
+          if (!['Layout', 'ParentView'].includes(m.components.default.name)) {
+            obj = { name: m.components.default.name, path: path, query: query }
+          }
+        }
+      })
+    }
+    return useTagsViewStore().delCachedView(obj).then(() => {
+      const { path, query } = obj
+      router.replace({
+        path: '/redirect' + path,
+        query: query
+      })
+    })
+  },
+  // 关闭当前tab页签,打开新页签
+  closeOpenPage(obj) {
+    useTagsViewStore().delView(router.currentRoute.value)
+    if (obj !== undefined) {
+      return router.push(obj)
+    }
+  },
+  // 关闭指定tab页签
+  closePage(obj) {
+    if (obj === undefined) {
+      return useTagsViewStore().delView(router.currentRoute.value).then(({ visitedViews }) => {
+        const latestView = visitedViews.slice(-1)[0]
+        if (latestView) {
+          return router.push(latestView.fullPath)
+        }
+        return router.push('/')
+      })
+    }
+    return useTagsViewStore().delView(obj)
+  },
+  // 关闭所有tab页签
+  closeAllPage() {
+    return useTagsViewStore().delAllViews()
+  },
+  // 关闭左侧tab页签
+  closeLeftPage(obj) {
+    return useTagsViewStore().delLeftTags(obj || router.currentRoute.value)
+  },
+  // 关闭右侧tab页签
+  closeRightPage(obj) {
+    return useTagsViewStore().delRightTags(obj || router.currentRoute.value)
+  },
+  // 关闭其他tab页签
+  closeOtherPage(obj) {
+    return useTagsViewStore().delOthersViews(obj || router.currentRoute.value)
+  },
+  // 打开tab页签
+  openPage(title, url, params) {
+    const obj = { path: url, meta: { title: title } }
+    useTagsViewStore().addView(obj)
+    return router.push({ path: url, query: params })
+  },
+  // 修改tab页签
+  updatePage(obj) {
+    return useTagsViewStore().updateVisitedView(obj)
+  }
+}

+ 174 - 0
jd-logistics-ui-vue3/src/router/index.js

@@ -0,0 +1,174 @@
+import { createWebHistory, createRouter } from 'vue-router'
+/* Layout */
+import Layout from '@/layout'
+
+/**
+ * Note: 路由配置项
+ *
+ * hidden: true                     // 当设置 true 的时候该路由不会再侧边栏出现 如401,login等页面,或者如一些编辑页面/edit/1
+ * alwaysShow: true                 // 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式--如组件页面
+ *                                  // 只有一个时,会将那个子路由当做根路由显示在侧边栏--如引导页面
+ *                                  // 若你想不管路由下面的 children 声明的个数都显示你的根路由
+ *                                  // 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由
+ * redirect: noRedirect             // 当设置 noRedirect 的时候该路由在面包屑导航中不可被点击
+ * name:'router-name'               // 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
+ * query: '{"id": 1, "name": "ry"}' // 访问路由的默认传递参数
+ * roles: ['admin', 'common']       // 访问路由的角色权限
+ * permissions: ['a:a:a', 'b:b:b']  // 访问路由的菜单权限
+ * meta : {
+    noCache: true                   // 如果设置为true,则不会被 <keep-alive> 缓存(默认 false)
+    title: 'title'                  // 设置该路由在侧边栏和面包屑中展示的名字
+    icon: 'svg-name'                // 设置该路由的图标,对应路径src/assets/icons/svg
+    breadcrumb: false               // 如果设置为false,则不会在breadcrumb面包屑中显示
+    activeMenu: '/system/user'      // 当路由设置了该属性,则会高亮相对应的侧边栏。
+  }
+ */
+
+// 公共路由
+export const constantRoutes = [
+  {
+    path: '/redirect',
+    component: Layout,
+    hidden: true,
+    children: [
+      {
+        path: '/redirect/:path(.*)',
+        component: () => import('@/views/redirect/index.vue')
+      }
+    ]
+  },
+  {
+    path: '/login',
+    component: () => import('@/views/login'),
+    hidden: true
+  },
+  {
+    path: '/register',
+    component: () => import('@/views/register'),
+    hidden: true
+  },
+  {
+    path: "/:pathMatch(.*)*",
+    component: () => import('@/views/error/404'),
+    hidden: true
+  },
+  {
+    path: '/401',
+    component: () => import('@/views/error/401'),
+    hidden: true
+  },
+  {
+    path: '',
+    component: Layout,
+    redirect: '/index',
+    children: [
+      {
+        path: '/index',
+        component: () => import('@/views/index'),
+        name: 'Index',
+        meta: { title: '首页', icon: 'dashboard', affix: true }
+      }
+    ]
+  },
+  {
+    path: '/user',
+    component: Layout,
+    hidden: true,
+    redirect: 'noredirect',
+    children: [
+      {
+        path: 'profile/:activeTab?',
+        component: () => import('@/views/system/user/profile/index'),
+        name: 'Profile',
+        meta: { title: '个人中心', icon: 'user' }
+      }
+    ]
+  }
+]
+
+// 动态路由,基于用户权限动态去加载
+export const dynamicRoutes = [
+  {
+    path: '/system/user-auth',
+    component: Layout,
+    hidden: true,
+    permissions: ['system:user:edit'],
+    children: [
+      {
+        path: 'role/:userId(\\d+)',
+        component: () => import('@/views/system/user/authRole'),
+        name: 'AuthRole',
+        meta: { title: '分配角色', activeMenu: '/system/user' }
+      }
+    ]
+  },
+  {
+    path: '/system/role-auth',
+    component: Layout,
+    hidden: true,
+    permissions: ['system:role:edit'],
+    children: [
+      {
+        path: 'user/:roleId(\\d+)',
+        component: () => import('@/views/system/role/authUser'),
+        name: 'AuthUser',
+        meta: { title: '分配用户', activeMenu: '/system/role' }
+      }
+    ]
+  },
+  {
+    path: '/system/dict-data',
+    component: Layout,
+    hidden: true,
+    permissions: ['system:dict:list'],
+    children: [
+      {
+        path: 'index/:dictId(\\d+)',
+        component: () => import('@/views/system/dict/data'),
+        name: 'Data',
+        meta: { title: '字典数据', activeMenu: '/system/dict' }
+      }
+    ]
+  },
+  {
+    path: '/monitor/job-log',
+    component: Layout,
+    hidden: true,
+    permissions: ['monitor:job:list'],
+    children: [
+      {
+        path: 'index/:jobId(\\d+)',
+        component: () => import('@/views/monitor/job/log'),
+        name: 'JobLog',
+        meta: { title: '调度日志', activeMenu: '/monitor/job' }
+      }
+    ]
+  },
+  {
+    path: '/tool/gen-edit',
+    component: Layout,
+    hidden: true,
+    permissions: ['tool:gen:edit'],
+    children: [
+      {
+        path: 'index/:tableId(\\d+)',
+        component: () => import('@/views/tool/gen/editTable'),
+        name: 'GenEdit',
+        meta: { title: '修改生成配置', activeMenu: '/tool/gen' }
+      }
+    ]
+  }
+]
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: constantRoutes,
+  scrollBehavior(to, from, savedPosition) {
+    if (savedPosition) {
+      return savedPosition
+    }
+    return { top: 0 }
+  },
+})
+
+export default router

+ 57 - 0
jd-logistics-ui-vue3/src/settings.js

@@ -0,0 +1,57 @@
+export default {
+  /**
+   * 网页标题
+   */
+  title: import.meta.env.VITE_APP_TITLE,
+
+  /**
+   * 侧边栏主题 深色主题theme-dark,浅色主题theme-light
+   */
+  sideTheme: 'theme-dark',
+
+  /**
+   * 是否系统布局配置
+   */
+  showSettings: true,
+
+  /**
+   * 菜单导航模式 1、纯左侧 2、混合(左侧+顶部) 3、纯顶部
+   */
+  navType: 1,
+
+  /**
+   * 是否显示 tagsView
+   */
+  tagsView: true,
+  
+  /**
+   * 显示页签图标
+   */
+  tagsIcon: false,
+
+  /**
+   * 是否固定头部
+   */
+  fixedHeader: true,
+
+  /**
+   * 是否显示logo
+   */
+  sidebarLogo: true,
+
+  /**
+   * 是否显示动态标题
+   */
+  dynamicTitle: false,
+
+  /**
+   * 是否显示底部版权
+   */
+  footerVisible: false,
+
+  /**
+   * 底部版权文本内容
+   */
+  footerContent: 'Copyright © 2018-2026 RuoYi. All Rights Reserved.'
+}
+

+ 3 - 0
jd-logistics-ui-vue3/src/store/index.js

@@ -0,0 +1,3 @@
+const store = createPinia()
+
+export default store

+ 46 - 0
jd-logistics-ui-vue3/src/store/modules/app.js

@@ -0,0 +1,46 @@
+import Cookies from 'js-cookie'
+
+const useAppStore = defineStore(
+  'app',
+  {
+    state: () => ({
+      sidebar: {
+        opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
+        withoutAnimation: false,
+        hide: false
+      },
+      device: 'desktop',
+      size: Cookies.get('size') || 'default'
+    }),
+    actions: {
+      toggleSideBar(withoutAnimation) {
+        if (this.sidebar.hide) {
+          return false
+        }
+        this.sidebar.opened = !this.sidebar.opened
+        this.sidebar.withoutAnimation = withoutAnimation
+        if (this.sidebar.opened) {
+          Cookies.set('sidebarStatus', 1)
+        } else {
+          Cookies.set('sidebarStatus', 0)
+        }
+      },
+      closeSideBar({ withoutAnimation }) {
+        Cookies.set('sidebarStatus', 0)
+        this.sidebar.opened = false
+        this.sidebar.withoutAnimation = withoutAnimation
+      },
+      toggleDevice(device) {
+        this.device = device
+      },
+      setSize(size) {
+        this.size = size
+        Cookies.set('size', size)
+      },
+      toggleSideBarHide(status) {
+        this.sidebar.hide = status
+      }
+    }
+  })
+
+export default useAppStore

+ 57 - 0
jd-logistics-ui-vue3/src/store/modules/dict.js

@@ -0,0 +1,57 @@
+const useDictStore = defineStore(
+  'dict',
+  {
+    state: () => ({
+      dict: new Array()
+    }),
+    actions: {
+      // 获取字典
+      getDict(_key) {
+        if (_key == null && _key == "") {
+          return null
+        }
+        try {
+          for (let i = 0; i < this.dict.length; i++) {
+            if (this.dict[i].key == _key) {
+              return this.dict[i].value
+            }
+          }
+        } catch (e) {
+          return null
+        }
+      },
+      // 设置字典
+      setDict(_key, value) {
+        if (_key !== null && _key !== "") {
+          this.dict.push({
+            key: _key,
+            value: value
+          })
+        }
+      },
+      // 删除字典
+      removeDict(_key) {
+        var bln = false
+        try {
+          for (let i = 0; i < this.dict.length; i++) {
+            if (this.dict[i].key == _key) {
+              this.dict.splice(i, 1)
+              return true
+            }
+          }
+        } catch (e) {
+          bln = false
+        }
+        return bln
+      },
+      // 清空字典
+      cleanDict() {
+        this.dict = new Array()
+      },
+      // 初始字典
+      initDict() {
+      }
+    }
+  })
+
+export default useDictStore

+ 127 - 0
jd-logistics-ui-vue3/src/store/modules/permission.js

@@ -0,0 +1,127 @@
+import auth from '@/plugins/auth'
+import router, { constantRoutes, dynamicRoutes } from '@/router'
+import { getRouters } from '@/api/menu'
+import Layout from '@/layout/index'
+import ParentView from '@/components/ParentView'
+import InnerLink from '@/layout/components/InnerLink'
+
+// 匹配views里面所有的.vue文件
+const modules = import.meta.glob('./../../views/**/*.vue')
+
+const usePermissionStore = defineStore(
+  'permission',
+  {
+    state: () => ({
+      routes: [],
+      addRoutes: [],
+      defaultRoutes: [],
+      topbarRouters: [],
+      sidebarRouters: []
+    }),
+    actions: {
+      setRoutes(routes) {
+        this.addRoutes = routes
+        this.routes = constantRoutes.concat(routes)
+      },
+      setDefaultRoutes(routes) {
+        this.defaultRoutes = constantRoutes.concat(routes)
+      },
+      setTopbarRoutes(routes) {
+        this.topbarRouters = routes
+      },
+      setSidebarRouters(routes) {
+        this.sidebarRouters = routes
+      },
+      generateRoutes(roles) {
+        return new Promise(resolve => {
+          // 向后端请求路由数据
+          getRouters().then(res => {
+            const sdata = JSON.parse(JSON.stringify(res.data))
+            const rdata = JSON.parse(JSON.stringify(res.data))
+            const defaultData = JSON.parse(JSON.stringify(res.data))
+            const sidebarRoutes = filterAsyncRouter(sdata)
+            const rewriteRoutes = filterAsyncRouter(rdata, false, true)
+            const defaultRoutes = filterAsyncRouter(defaultData)
+            const asyncRoutes = filterDynamicRoutes(dynamicRoutes)
+            asyncRoutes.forEach(route => { router.addRoute(route) })
+            this.setRoutes(rewriteRoutes)
+            this.setSidebarRouters(constantRoutes.concat(sidebarRoutes))
+            this.setDefaultRoutes(sidebarRoutes)
+            this.setTopbarRoutes(defaultRoutes)
+            resolve(rewriteRoutes)
+          })
+        })
+      }
+    }
+  })
+
+// 遍历后台传来的路由字符串,转换为组件对象
+function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
+  return asyncRouterMap.filter(route => {
+    if (type && route.children) {
+      route.children = filterChildren(route.children)
+    }
+    if (route.component) {
+      // Layout ParentView 组件特殊处理
+      if (route.component === 'Layout') {
+        route.component = Layout
+      } else if (route.component === 'ParentView') {
+        route.component = ParentView
+      } else if (route.component === 'InnerLink') {
+        route.component = InnerLink
+      } else {
+        route.component = loadView(route.component)
+      }
+    }
+    if (route.children != null && route.children && route.children.length) {
+      route.children = filterAsyncRouter(route.children, route, type)
+    } else {
+      delete route['children']
+      delete route['redirect']
+    }
+    return true
+  })
+}
+
+function filterChildren(childrenMap, lastRouter = false) {
+  var children = []
+  childrenMap.forEach(el => {
+    el.path = lastRouter ? lastRouter.path + '/' + el.path : el.path
+    if (el.children && el.children.length && el.component === 'ParentView') {
+      children = children.concat(filterChildren(el.children, el))
+    } else {
+      children.push(el)
+    }
+  })
+  return children
+}
+
+// 动态路由遍历,验证是否具备权限
+export function filterDynamicRoutes(routes) {
+  const res = []
+  routes.forEach(route => {
+    if (route.permissions) {
+      if (auth.hasPermiOr(route.permissions)) {
+        res.push(route)
+      }
+    } else if (route.roles) {
+      if (auth.hasRoleOr(route.roles)) {
+        res.push(route)
+      }
+    }
+  })
+  return res
+}
+
+export const loadView = (view) => {
+  let res
+  for (const path in modules) {
+    const dir = path.split('views/')[1].split('.vue')[0]
+    if (dir === view) {
+      res = () => modules[path]()
+    }
+  }
+  return res
+}
+
+export default usePermissionStore

+ 51 - 0
jd-logistics-ui-vue3/src/store/modules/settings.js

@@ -0,0 +1,51 @@
+import defaultSettings from '@/settings'
+import { useDark, useToggle } from '@vueuse/core'
+import { useDynamicTitle } from '@/utils/dynamicTitle'
+
+const isDark = useDark()
+const toggleDark = useToggle(isDark)
+
+const { sideTheme, showSettings, navType, tagsView, tagsIcon, fixedHeader, sidebarLogo, dynamicTitle, footerVisible, footerContent } = defaultSettings
+
+const storageSetting = JSON.parse(localStorage.getItem('layout-setting')) || ''
+
+const useSettingsStore = defineStore(
+  'settings',
+  {
+    state: () => ({
+      title: '',
+      theme: storageSetting.theme || '#409EFF',
+      sideTheme: storageSetting.sideTheme || sideTheme,
+      showSettings: showSettings,
+      navType: storageSetting.navType === undefined ? navType : storageSetting.navType,
+      tagsView: storageSetting.tagsView === undefined ? tagsView : storageSetting.tagsView,
+      tagsIcon: storageSetting.tagsIcon === undefined ? tagsIcon : storageSetting.tagsIcon,
+      fixedHeader: storageSetting.fixedHeader === undefined ? fixedHeader : storageSetting.fixedHeader,
+      sidebarLogo: storageSetting.sidebarLogo === undefined ? sidebarLogo : storageSetting.sidebarLogo,
+      dynamicTitle: storageSetting.dynamicTitle === undefined ? dynamicTitle : storageSetting.dynamicTitle,
+      footerVisible: storageSetting.footerVisible === undefined ? footerVisible : storageSetting.footerVisible,
+      footerContent: footerContent,
+      isDark: isDark.value
+    }),
+    actions: {
+      // 修改布局设置
+      changeSetting(data) {
+        const { key, value } = data
+        if (this.hasOwnProperty(key)) {
+          this[key] = value
+        }
+      },
+      // 设置网页标题
+      setTitle(title) {
+        this.title = title
+        useDynamicTitle()
+      },
+      // 切换暗黑模式
+      toggleTheme() {
+        this.isDark = !this.isDark
+        toggleDark()
+      }
+    }
+  })
+
+export default useSettingsStore

+ 182 - 0
jd-logistics-ui-vue3/src/store/modules/tagsView.js

@@ -0,0 +1,182 @@
+const useTagsViewStore = defineStore(
+  'tags-view',
+  {
+    state: () => ({
+      visitedViews: [],
+      cachedViews: [],
+      iframeViews: []
+    }),
+    actions: {
+      addView(view) {
+        this.addVisitedView(view)
+        this.addCachedView(view)
+      },
+      addIframeView(view) {
+        if (this.iframeViews.some(v => v.path === view.path)) return
+        this.iframeViews.push(
+          Object.assign({}, view, {
+            title: view.meta.title || 'no-name'
+          })
+        )
+      },
+      addVisitedView(view) {
+        if (this.visitedViews.some(v => v.path === view.path)) return
+        this.visitedViews.push(
+          Object.assign({}, view, {
+            title: view.meta.title || 'no-name'
+          })
+        )
+      },
+      addCachedView(view) {
+        if (this.cachedViews.includes(view.name)) return
+        if (!view.meta.noCache) {
+          this.cachedViews.push(view.name)
+        }
+      },
+      delView(view) {
+        return new Promise(resolve => {
+          this.delVisitedView(view)
+          this.delCachedView(view)
+          resolve({
+            visitedViews: [...this.visitedViews],
+            cachedViews: [...this.cachedViews]
+          })
+        })
+      },
+      delVisitedView(view) {
+        return new Promise(resolve => {
+          for (const [i, v] of this.visitedViews.entries()) {
+            if (v.path === view.path) {
+              this.visitedViews.splice(i, 1)
+              break
+            }
+          }
+          this.iframeViews = this.iframeViews.filter(item => item.path !== view.path)
+          resolve([...this.visitedViews])
+        })
+      },
+      delIframeView(view) {
+        return new Promise(resolve => {
+          this.iframeViews = this.iframeViews.filter(item => item.path !== view.path)
+          resolve([...this.iframeViews])
+        })
+      },
+      delCachedView(view) {
+        return new Promise(resolve => {
+          const index = this.cachedViews.indexOf(view.name)
+          index > -1 && this.cachedViews.splice(index, 1)
+          resolve([...this.cachedViews])
+        })
+      },
+      delOthersViews(view) {
+        return new Promise(resolve => {
+          this.delOthersVisitedViews(view)
+          this.delOthersCachedViews(view)
+          resolve({
+            visitedViews: [...this.visitedViews],
+            cachedViews: [...this.cachedViews]
+          })
+        })
+      },
+      delOthersVisitedViews(view) {
+        return new Promise(resolve => {
+          this.visitedViews = this.visitedViews.filter(v => {
+            return v.meta.affix || v.path === view.path
+          })
+          this.iframeViews = this.iframeViews.filter(item => item.path === view.path)
+          resolve([...this.visitedViews])
+        })
+      },
+      delOthersCachedViews(view) {
+        return new Promise(resolve => {
+          const index = this.cachedViews.indexOf(view.name)
+          if (index > -1) {
+            this.cachedViews = this.cachedViews.slice(index, index + 1)
+          } else {
+            this.cachedViews = []
+          }
+          resolve([...this.cachedViews])
+        })
+      },
+      delAllViews(view) {
+        return new Promise(resolve => {
+          this.delAllVisitedViews(view)
+          this.delAllCachedViews(view)
+          resolve({
+            visitedViews: [...this.visitedViews],
+            cachedViews: [...this.cachedViews]
+          })
+        })
+      },
+      delAllVisitedViews(view) {
+        return new Promise(resolve => {
+          const affixTags = this.visitedViews.filter(tag => tag.meta.affix)
+          this.visitedViews = affixTags
+          this.iframeViews = []
+          resolve([...this.visitedViews])
+        })
+      },
+      delAllCachedViews(view) {
+        return new Promise(resolve => {
+          this.cachedViews = []
+          resolve([...this.cachedViews])
+        })
+      },
+      updateVisitedView(view) {
+        for (let v of this.visitedViews) {
+          if (v.path === view.path) {
+            v = Object.assign(v, view)
+            break
+          }
+        }
+      },
+      delRightTags(view) {
+        return new Promise(resolve => {
+          const index = this.visitedViews.findIndex(v => v.path === view.path)
+          if (index === -1) {
+            return
+          }
+          this.visitedViews = this.visitedViews.filter((item, idx) => {
+            if (idx <= index || (item.meta && item.meta.affix)) {
+              return true
+            }
+            const i = this.cachedViews.indexOf(item.name)
+            if (i > -1) {
+              this.cachedViews.splice(i, 1)
+            }
+            if(item.meta.link) {
+              const fi = this.iframeViews.findIndex(v => v.path === item.path)
+              this.iframeViews.splice(fi, 1)
+            }
+            return false
+          })
+          resolve([...this.visitedViews])
+        })
+      },
+      delLeftTags(view) {
+        return new Promise(resolve => {
+          const index = this.visitedViews.findIndex(v => v.path === view.path)
+          if (index === -1) {
+            return
+          }
+          this.visitedViews = this.visitedViews.filter((item, idx) => {
+            if (idx >= index || (item.meta && item.meta.affix)) {
+              return true
+            }
+            const i = this.cachedViews.indexOf(item.name)
+            if (i > -1) {
+              this.cachedViews.splice(i, 1)
+            }
+            if(item.meta.link) {
+              const fi = this.iframeViews.findIndex(v => v.path === item.path)
+              this.iframeViews.splice(fi, 1)
+            }
+            return false
+          })
+          resolve([...this.visitedViews])
+        })
+      }
+    }
+  })
+
+export default useTagsViewStore

+ 89 - 0
jd-logistics-ui-vue3/src/store/modules/user.js

@@ -0,0 +1,89 @@
+import router from '@/router'
+import { ElMessageBox, } from 'element-plus'
+import { login, logout, getInfo } from '@/api/login'
+import { getToken, setToken, removeToken } from '@/utils/auth'
+import { isEmpty } from "@/utils/validate"
+import defAva from '@/assets/images/profile.jpg'
+
+const useUserStore = defineStore(
+  'user',
+  {
+    state: () => ({
+      token: getToken(),
+      id: '',
+      name: '',
+      nickName: '',
+      avatar: '',
+      roles: [],
+      permissions: []
+    }),
+    actions: {
+      // 登录
+      login(userInfo) {
+        const username = userInfo.username.trim()
+        const password = userInfo.password
+        const code = userInfo.code
+        const uuid = userInfo.uuid
+        return new Promise((resolve, reject) => {
+          login(username, password, code, uuid).then(res => {
+            let data = res.data
+            setToken(data.access_token)
+            this.token = data.access_token
+            resolve()
+          }).catch(error => {
+            reject(error)
+          })
+        })
+      },
+      // 获取用户信息
+      getInfo() {
+        return new Promise((resolve, reject) => {
+          getInfo().then(res => {
+            const user = res.user
+            const avatar = (isEmpty(user.avatar)) ? defAva : user.avatar
+            if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组
+              this.roles = res.roles
+              this.permissions = res.permissions
+            } else {
+              this.roles = ['ROLE_DEFAULT']
+            }
+            this.id = user.userId
+            this.name = user.userName
+            this.nickName = user.nickName
+            this.avatar = avatar
+            /* 初始密码提示 */
+            if(res.isDefaultModifyPwd) {
+              ElMessageBox.confirm('您的密码还是初始密码,请修改密码!',  '安全提示', {  confirmButtonText: '确定',  cancelButtonText: '取消',  type: 'warning' }).then(() => {
+                router.push({ name: 'Profile', params: { activeTab: 'resetPwd' } })
+              }).catch(() => {})
+            }
+            /* 过期密码提示 */
+            if(!res.isDefaultModifyPwd && res.isPasswordExpired) {
+              ElMessageBox.confirm('您的密码已过期,请尽快修改密码!',  '安全提示', {  confirmButtonText: '确定',  cancelButtonText: '取消',  type: 'warning' }).then(() => {
+                router.push({ name: 'Profile', params: { activeTab: 'resetPwd' } })
+              }).catch(() => {})
+            }
+            resolve(res)
+          }).catch(error => {
+            reject(error)
+          })
+        })
+      },
+      // 退出系统
+      logOut() {
+        return new Promise((resolve, reject) => {
+          logout(this.token).then(() => {
+            this.token = ''
+            this.roles = []
+            this.permissions = []
+            removeToken()
+            resolve()
+          }).catch(error => {
+            reject(error)
+          })
+        })
+      }
+    }
+  })
+
+export default useUserStore

+ 29 - 0
jd-logistics-ui-vue3/src/utils/auth.js

@@ -0,0 +1,29 @@
+import Cookies from 'js-cookie'
+
+const TokenKey = 'Admin-Token'
+
+const ExpiresInKey = 'Admin-Expires-In'
+
+export function getToken() {
+  return Cookies.get(TokenKey)
+}
+
+export function setToken(token) {
+  return Cookies.set(TokenKey, token)
+}
+
+export function removeToken() {
+  return Cookies.remove(TokenKey)
+}
+
+export function getExpiresIn() {
+  return Cookies.get(ExpiresInKey) || -1
+}
+
+export function setExpiresIn(time) {
+  return Cookies.set(ExpiresInKey, time)
+}
+
+export function removeExpiresIn() {
+  return Cookies.remove(ExpiresInKey)
+}

+ 24 - 0
jd-logistics-ui-vue3/src/utils/dict.js

@@ -0,0 +1,24 @@
+import useDictStore from '@/store/modules/dict'
+import { getDicts } from '@/api/system/dict/data'
+
+/**
+ * 获取字典数据
+ */
+export function useDict(...args) {
+  const res = ref({})
+  return (() => {
+    args.forEach((dictType, index) => {
+      res.value[dictType] = []
+      const dicts = useDictStore().getDict(dictType)
+      if (dicts) {
+        res.value[dictType] = dicts
+      } else {
+        getDicts(dictType).then(resp => {
+          res.value[dictType] = resp.data.map(p => ({ label: p.dictLabel, value: p.dictValue, elTagType: p.listClass, elTagClass: p.cssClass }))
+          useDictStore().setDict(dictType, res.value[dictType])
+        })
+      }
+    })
+    return toRefs(res.value)
+  })()
+}

+ 14 - 0
jd-logistics-ui-vue3/src/utils/dynamicTitle.js

@@ -0,0 +1,14 @@
+import defaultSettings from '@/settings'
+import useSettingsStore from '@/store/modules/settings'
+
+/**
+ * 动态修改标题
+ */
+export function useDynamicTitle() {
+  const settingsStore = useSettingsStore()
+  if (settingsStore.dynamicTitle) {
+    document.title = settingsStore.title + ' - ' + defaultSettings.title
+  } else {
+    document.title = defaultSettings.title
+  }
+}

+ 6 - 0
jd-logistics-ui-vue3/src/utils/errorCode.js

@@ -0,0 +1,6 @@
+export default {
+  '401': '认证失败,无法访问系统资源',
+  '403': '当前操作没有权限',
+  '404': '访问资源不存在',
+  'default': '系统未知错误,请反馈给管理员'
+}

+ 452 - 0
jd-logistics-ui-vue3/src/utils/generator/config.js

@@ -0,0 +1,452 @@
+export const formConf = {
+  formRef: 'formRef',
+  formModel: 'formData',
+  size: 'default',
+  labelPosition: 'right',
+  labelWidth: 100,
+  formRules: 'rules',
+  gutter: 15,
+  disabled: false,
+  span: 24,
+  formBtns: true,
+}
+
+export const inputComponents = [
+  {
+    label: '单行文本',
+    tag: 'el-input',
+    tagIcon: 'input',
+    type: 'text',
+    placeholder: '请输入',
+    defaultValue: undefined,
+    span: 24,
+    labelWidth: null,
+    style: { width: '100%' },
+    clearable: true,
+    prepend: '',
+    append: '',
+    'prefix-icon': '',
+    'suffix-icon': '',
+    maxlength: null,
+    'show-word-limit': false,
+    readonly: false,
+    disabled: false,
+    required: true,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/input',
+  },
+  {
+    label: '多行文本',
+    tag: 'el-input',
+    tagIcon: 'textarea',
+    type: 'textarea',
+    placeholder: '请输入',
+    defaultValue: undefined,
+    span: 24,
+    labelWidth: null,
+    autosize: {
+      minRows: 4,
+      maxRows: 4,
+    },
+    style: { width: '100%' },
+    maxlength: null,
+    'show-word-limit': false,
+    readonly: false,
+    disabled: false,
+    required: true,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/input',
+  },
+  {
+    label: '密码',
+    tag: 'el-input',
+    tagIcon: 'password',
+    type: 'password',
+    placeholder: '请输入',
+    defaultValue: undefined,
+    span: 24,
+    'show-password': true,
+    labelWidth: null,
+    style: { width: '100%' },
+    clearable: true,
+    prepend: '',
+    append: '',
+    'prefix-icon': '',
+    'suffix-icon': '',
+    maxlength: null,
+    'show-word-limit': false,
+    readonly: false,
+    disabled: false,
+    required: true,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/input',
+  },
+  {
+    label: '计数器',
+    tag: 'el-input-number',
+    tagIcon: 'number',
+    placeholder: '',
+    defaultValue: undefined,
+    span: 24,
+    labelWidth: null,
+    min: undefined,
+    max: undefined,
+    step: undefined,
+    'step-strictly': false,
+    precision: undefined,
+    'controls-position': '',
+    disabled: false,
+    required: true,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/input-number',
+  },
+]
+
+export const selectComponents = [
+  {
+    label: '下拉选择',
+    tag: 'el-select',
+    tagIcon: 'select',
+    placeholder: '请选择',
+    defaultValue: undefined,
+    span: 24,
+    labelWidth: null,
+    style: { width: '100%' },
+    clearable: true,
+    disabled: false,
+    required: true,
+    filterable: false,
+    multiple: false,
+    options: [
+      {
+        label: '选项一',
+        value: 1,
+      },
+      {
+        label: '选项二',
+        value: 2,
+      },
+    ],
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/select',
+  },
+  {
+    label: '级联选择',
+    tag: 'el-cascader',
+    tagIcon: 'cascader',
+    placeholder: '请选择',
+    defaultValue: [],
+    span: 24,
+    labelWidth: null,
+    style: { width: '100%' },
+    props: {
+      props: {
+        multiple: false,
+      },
+    },
+    'show-all-levels': true,
+    disabled: false,
+    clearable: true,
+    filterable: false,
+    required: true,
+    options: [
+      {
+        id: 1,
+        value: 1,
+        label: '选项1',
+        children: [
+          {
+            id: 2,
+            value: 2,
+            label: '选项1-1',
+          },
+        ],
+      },
+    ],
+    dataType: 'dynamic',
+    labelKey: 'label',
+    valueKey: 'value',
+    childrenKey: 'children',
+    separator: '/',
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/cascader',
+  },
+  {
+    label: '单选框组',
+    tag: 'el-radio-group',
+    tagIcon: 'radio',
+    defaultValue: 0,
+    span: 24,
+    labelWidth: null,
+    style: {},
+    optionType: 'default',
+    border: false,
+    size: 'default',
+    disabled: false,
+    required: true,
+    options: [
+      {
+        label: '选项一',
+        value: 1,
+      },
+      {
+        label: '选项二',
+        value: 2,
+      },
+    ],
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/radio',
+  },
+  {
+    label: '多选框组',
+    tag: 'el-checkbox-group',
+    tagIcon: 'checkbox',
+    defaultValue: [],
+    span: 24,
+    labelWidth: null,
+    style: {},
+    optionType: 'default',
+    border: false,
+    size: 'default',
+    disabled: false,
+    required: true,
+    options: [
+      {
+        label: '选项一',
+        value: 1,
+      },
+      {
+        label: '选项二',
+        value: 2,
+      },
+    ],
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/checkbox',
+  },
+  {
+    label: '开关',
+    tag: 'el-switch',
+    tagIcon: 'switch',
+    defaultValue: false,
+    span: 24,
+    labelWidth: null,
+    style: {},
+    disabled: false,
+    required: true,
+    'active-text': '',
+    'inactive-text': '',
+    'active-color': null,
+    'inactive-color': null,
+    'active-value': true,
+    'inactive-value': false,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/switch',
+  },
+  {
+    label: '滑块',
+    tag: 'el-slider',
+    tagIcon: 'slider',
+    defaultValue: null,
+    span: 24,
+    labelWidth: null,
+    disabled: false,
+    required: true,
+    min: 0,
+    max: 100,
+    step: 1,
+    'show-stops': false,
+    range: false,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/slider',
+  },
+  {
+    label: '时间选择',
+    tag: 'el-time-picker',
+    tagIcon: 'time',
+    placeholder: '请选择',
+    defaultValue: '',
+    span: 24,
+    labelWidth: null,
+    style: { width: '100%' },
+    disabled: false,
+    clearable: true,
+    required: true,
+    format: 'HH:mm:ss',
+    'value-format': 'HH:mm:ss',
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/time-picker',
+  },
+  {
+    label: '时间范围',
+    tag: 'el-time-picker',
+    tagIcon: 'time-range',
+    defaultValue: null,
+    span: 24,
+    labelWidth: null,
+    style: { width: '100%' },
+    disabled: false,
+    clearable: true,
+    required: true,
+    'is-range': true,
+    'range-separator': '至',
+    'start-placeholder': '开始时间',
+    'end-placeholder': '结束时间',
+    format: 'HH:mm:ss',
+    'value-format': 'HH:mm:ss',
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/time-picker',
+  },
+  {
+    label: '日期选择',
+    tag: 'el-date-picker',
+    tagIcon: 'date',
+    placeholder: '请选择',
+    defaultValue: null,
+    type: 'date',
+    span: 24,
+    labelWidth: null,
+    style: { width: '100%' },
+    disabled: false,
+    clearable: true,
+    required: true,
+    format: 'YYYY-MM-DD',
+    'value-format': 'YYYY-MM-DD',
+    readonly: false,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/date-picker',
+  },
+  {
+    label: '日期范围',
+    tag: 'el-date-picker',
+    tagIcon: 'date-range',
+    defaultValue: null,
+    span: 24,
+    labelWidth: null,
+    style: { width: '100%' },
+    type: 'daterange',
+    'range-separator': '至',
+    'start-placeholder': '开始日期',
+    'end-placeholder': '结束日期',
+    disabled: false,
+    clearable: true,
+    required: true,
+    format: 'YYYY-MM-DD',
+    'value-format': 'YYYY-MM-DD',
+    readonly: false,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/date-picker',
+  },
+  {
+    label: '评分',
+    tag: 'el-rate',
+    tagIcon: 'rate',
+    defaultValue: 0,
+    span: 24,
+    labelWidth: null,
+    style: {},
+    max: 5,
+    'allow-half': false,
+    'show-text': false,
+    'show-score': false,
+    disabled: false,
+    required: true,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/rate',
+  },
+  {
+    label: '颜色选择',
+    tag: 'el-color-picker',
+    tagIcon: 'color',
+    defaultValue: null,
+    labelWidth: null,
+    'show-alpha': false,
+    'color-format': '',
+    disabled: false,
+    required: true,
+    size: 'default',
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/color-picker',
+  },
+  {
+    label: '上传',
+    tag: 'el-upload',
+    tagIcon: 'upload',
+    action: 'https://jsonplaceholder.typicode.com/posts/',
+    defaultValue: null,
+    labelWidth: null,
+    disabled: false,
+    required: true,
+    accept: '',
+    name: 'file',
+    'auto-upload': true,
+    showTip: false,
+    buttonText: '点击上传',
+    fileSize: 2,
+    sizeUnit: 'MB',
+    'list-type': 'text',
+    multiple: false,
+    regList: [],
+    changeTag: true,
+    document: 'https://element-plus.org/zh-CN/component/upload',
+    tip: '只能上传不超过 2MB 的文件',
+    style: { width: '100%' },
+  },
+]
+
+export const layoutComponents = [
+  {
+    layout: 'rowFormItem',
+    tagIcon: 'row',
+    type: 'default',
+    justify: 'start',
+    align: 'top',
+    label: '行容器',
+    layoutTree: true,
+    children: [],
+    document: 'https://element-plus.org/zh-CN/component/layout',
+  },
+  {
+    layout: 'colFormItem',
+    label: '按钮',
+    changeTag: true,
+    labelWidth: null,
+    tag: 'el-button',
+    tagIcon: 'button',
+    span: 24,
+    default: '主要按钮',
+    type: 'primary',
+    icon: 'Search',
+    size: 'default',
+    disabled: false,
+    document: 'https://element-plus.org/zh-CN/component/button',
+  },
+]
+
+// 组件rule的触发方式,无触发方式的组件不生成rule
+export const trigger = {
+  'el-input': 'blur',
+  'el-input-number': 'blur',
+  'el-select': 'change',
+  'el-radio-group': 'change',
+  'el-checkbox-group': 'change',
+  'el-cascader': 'change',
+  'el-time-picker': 'change',
+  'el-date-picker': 'change',
+  'el-rate': 'change',
+}

+ 18 - 0
jd-logistics-ui-vue3/src/utils/generator/css.js

@@ -0,0 +1,18 @@
+const styles = {
+  'el-rate': '.el-rate{display: inline-block; vertical-align: text-top;}',
+  'el-upload': '.el-upload__tip{line-height: 1.2;}'
+}
+
+function addCss(cssList, el) {
+  const css = styles[el.tag]
+  css && cssList.indexOf(css) === -1 && cssList.push(css)
+  if (el.children) {
+    el.children.forEach(el2 => addCss(cssList, el2))
+  }
+}
+
+export function makeUpCss(conf) {
+  const cssList = []
+  conf.fields.forEach(el => addCss(cssList, el))
+  return cssList.join('\n')
+}

+ 37 - 0
jd-logistics-ui-vue3/src/utils/generator/drawingDefault.js

@@ -0,0 +1,37 @@
+export const drawingDefaultValue = []
+
+export function initDrawingDefaultValue() {
+  if (drawingDefaultValue.length === 0) {
+    drawingDefaultValue.push({
+      layout: 'colFormItem',
+      tagIcon: 'input',
+      label: '手机号',
+      vModel: 'mobile',
+      formId: 6,
+      tag: 'el-input',
+      placeholder: '请输入手机号',
+      defaultValue: '',
+      span: 24,
+      style: {width: '100%'},
+      clearable: true,
+      prepend: '',
+      append: '',
+      'prefix-icon': 'Cellphone',
+      'suffix-icon': '',
+      maxlength: 11,
+      'show-word-limit': true,
+      readonly: false,
+      disabled: false,
+      required: true,
+      changeTag: true,
+      regList: [{
+        pattern: '/^1(3|4|5|7|8|9)\\d{9}$/',
+        message: '手机号格式错误'
+      }]
+    })
+  }
+}
+
+export function cleanDrawingDefaultValue() {
+  drawingDefaultValue.splice(0, drawingDefaultValue.length)
+}

+ 359 - 0
jd-logistics-ui-vue3/src/utils/generator/html.js

@@ -0,0 +1,359 @@
+/* eslint-disable max-len */
+import { trigger } from './config'
+
+let confGlobal
+let someSpanIsNot24
+
+export function dialogWrapper(str) {
+  return `<el-dialog v-model="dialogVisible"  @open="onOpen" @close="onClose" title="Dialog Titile">
+    ${str}
+    <template #footer>
+      <el-button @click="close">取消</el-button>
+	  <el-button type="primary" @click="handelConfirm">确定</el-button>
+    </template>
+  </el-dialog>`
+}
+
+export function vueTemplate(str) {
+  return `<template>
+    <div class="app-container">
+      ${str}
+    </div>
+  </template>`
+}
+
+export function vueScript(str) {
+  return `<script setup>
+    ${str}
+  </script>`
+}
+
+export function cssStyle(cssStr) {
+  return `<style>
+    ${cssStr}
+  </style>`
+}
+
+function buildFormTemplate(conf, child, type) {
+  let labelPosition = ''
+  if (conf.labelPosition !== 'right') {
+    labelPosition = `label-position="${conf.labelPosition}"`
+  }
+  const disabled = conf.disabled ? `:disabled="${conf.disabled}"` : ''
+  let str = `<el-form ref="${conf.formRef}" :model="${conf.formModel}" :rules="${conf.formRules}" size="${conf.size}" ${disabled} label-width="${conf.labelWidth}px" ${labelPosition}>
+      ${child}
+      ${buildFromBtns(conf, type)}
+    </el-form>`
+  if (someSpanIsNot24) {
+    str = `<el-row :gutter="${conf.gutter}">
+        ${str}
+      </el-row>`
+  }
+  return str
+}
+
+function buildFromBtns(conf, type) {
+  let str = ''
+  if (conf.formBtns && type === 'file') {
+    str = `<el-form-item>
+          <el-button type="primary" @click="submitForm">提交</el-button>
+          <el-button @click="resetForm">重置</el-button>
+        </el-form-item>`
+    if (someSpanIsNot24) {
+      str = `<el-col :span="24">
+          ${str}
+        </el-col>`
+    }
+  }
+  return str
+}
+
+// span不为24的用el-col包裹
+function colWrapper(element, str) {
+  if (someSpanIsNot24 || element.span !== 24) {
+    return `<el-col :span="${element.span}">
+      ${str}
+    </el-col>`
+  }
+  return str
+}
+
+const layouts = {
+  colFormItem(element) {
+    let labelWidth = ''
+    if (element.labelWidth && element.labelWidth !== confGlobal.labelWidth) {
+      labelWidth = `label-width="${element.labelWidth}px"`
+    }
+    const required = !trigger[element.tag] && element.required ? 'required' : ''
+    const tagDom = tags[element.tag] ? tags[element.tag](element) : null
+    let str = `<el-form-item ${labelWidth} label="${element.label}" prop="${element.vModel}" ${required}>
+        ${tagDom}
+      </el-form-item>`
+    str = colWrapper(element, str)
+    return str
+  },
+  rowFormItem(element) {
+    const type = element.type === 'default' ? '' : `type="${element.type}"`
+    const justify = element.type === 'default' ? '' : `justify="${element.justify}"`
+    const align = element.type === 'default' ? '' : `align="${element.align}"`
+    const gutter = element.gutter ? `gutter="${element.gutter}"` : ''
+    const children = element.children.map(el => layouts[el.layout](el))
+    let str = `<el-row ${type} ${justify} ${align} ${gutter}>
+      ${children.join('\n')}
+    </el-row>`
+    str = colWrapper(element, str)
+    return str
+  }
+}
+
+const tags = {
+  'el-button': el => {
+    const {
+      tag, disabled
+    } = attrBuilder(el)
+    const type = el.type ? `type="${el.type}"` : ''
+    const icon = el.icon ? `icon="${el.icon}"` : ''
+    const size = el.size ? `size="${el.size}"` : ''
+    let child = buildElButtonChild(el)
+
+    if (child) child = `\n${child}\n` // 换行
+    return `<${el.tag} ${type} ${icon} ${size} ${disabled}>${child}</${el.tag}>`
+  },
+  'el-input': el => {
+    const {
+      disabled, vModel, clearable, placeholder, width
+    } = attrBuilder(el)
+    const maxlength = el.maxlength ? `:maxlength="${el.maxlength}"` : ''
+    const showWordLimit = el['show-word-limit'] ? 'show-word-limit' : ''
+    const readonly = el.readonly ? 'readonly' : ''
+    const prefixIcon = el['prefix-icon'] ? `prefix-icon='${el['prefix-icon']}'` : ''
+    const suffixIcon = el['suffix-icon'] ? `suffix-icon='${el['suffix-icon']}'` : ''
+    const showPassword = el['show-password'] ? 'show-password' : ''
+    const type = el.type ? `type="${el.type}"` : ''
+    const autosize = el.autosize && el.autosize.minRows
+      ? `:autosize="{minRows: ${el.autosize.minRows}, maxRows: ${el.autosize.maxRows}}"`
+      : ''
+    let child = buildElInputChild(el)
+
+    if (child) child = `\n${child}\n` // 换行
+    return `<${el.tag} ${vModel} ${type} ${placeholder} ${maxlength} ${showWordLimit} ${readonly} ${disabled} ${clearable} ${prefixIcon} ${suffixIcon} ${showPassword} ${autosize} ${width}>${child}</${el.tag}>`
+  },
+  'el-input-number': el => {
+    const { disabled, vModel, placeholder } = attrBuilder(el)
+    const controlsPosition = el['controls-position'] ? `controls-position=${el['controls-position']}` : ''
+    const min = el.min ? `:min='${el.min}'` : ''
+    const max = el.max ? `:max='${el.max}'` : ''
+    const step = el.step ? `:step='${el.step}'` : ''
+    const stepStrictly = el['step-strictly'] ? 'step-strictly' : ''
+    const precision = el.precision ? `:precision='${el.precision}'` : ''
+
+    return `<${el.tag} ${vModel} ${placeholder} ${step} ${stepStrictly} ${precision} ${controlsPosition} ${min} ${max} ${disabled}></${el.tag}>`
+  },
+  'el-select': el => {
+    const {
+      disabled, vModel, clearable, placeholder, width
+    } = attrBuilder(el)
+    const filterable = el.filterable ? 'filterable' : ''
+    const multiple = el.multiple ? 'multiple' : ''
+    let child = buildElSelectChild(el)
+
+    if (child) child = `\n${child}\n` // 换行
+    return `<${el.tag} ${vModel} ${placeholder} ${disabled} ${multiple} ${filterable} ${clearable} ${width}>${child}</${el.tag}>`
+  },
+  'el-radio-group': el => {
+    const { disabled, vModel } = attrBuilder(el)
+    const size = `size="${el.size}"`
+    let child = buildElRadioGroupChild(el)
+
+    if (child) child = `\n${child}\n` // 换行
+    return `<${el.tag} ${vModel} ${size} ${disabled}>${child}</${el.tag}>`
+  },
+  'el-checkbox-group': el => {
+    const { disabled, vModel } = attrBuilder(el)
+    const size = `size="${el.size}"`
+    const min = el.min ? `:min="${el.min}"` : ''
+    const max = el.max ? `:max="${el.max}"` : ''
+    let child = buildElCheckboxGroupChild(el)
+
+    if (child) child = `\n${child}\n` // 换行
+    return `<${el.tag} ${vModel} ${min} ${max} ${size} ${disabled}>${child}</${el.tag}>`
+  },
+  'el-switch': el => {
+    const { disabled, vModel } = attrBuilder(el)
+    const activeText = el['active-text'] ? `active-text="${el['active-text']}"` : ''
+    const inactiveText = el['inactive-text'] ? `inactive-text="${el['inactive-text']}"` : ''
+    const activeColor = el['active-color'] ? `active-color="${el['active-color']}"` : ''
+    const inactiveColor = el['inactive-color'] ? `inactive-color="${el['inactive-color']}"` : ''
+    const activeValue = el['active-value'] !== true ? `:active-value='${JSON.stringify(el['active-value'])}'` : ''
+    const inactiveValue = el['inactive-value'] !== false ? `:inactive-value='${JSON.stringify(el['inactive-value'])}'` : ''
+
+    return `<${el.tag} ${vModel} ${activeText} ${inactiveText} ${activeColor} ${inactiveColor} ${activeValue} ${inactiveValue} ${disabled}></${el.tag}>`
+  },
+  'el-cascader': el => {
+    const {
+      disabled, vModel, clearable, placeholder, width
+    } = attrBuilder(el)
+    const options = el.options ? `:options="${el.vModel}Options"` : ''
+    const props = el.props ? `:props="${el.vModel}Props"` : ''
+    const showAllLevels = el['show-all-levels'] ? '' : ':show-all-levels="false"'
+    const filterable = el.filterable ? 'filterable' : ''
+    const separator = el.separator === '/' ? '' : `separator="${el.separator}"`
+
+    return `<${el.tag} ${vModel} ${options} ${props} ${width} ${showAllLevels} ${placeholder} ${separator} ${filterable} ${clearable} ${disabled}></${el.tag}>`
+  },
+  'el-slider': el => {
+    const { disabled, vModel } = attrBuilder(el)
+    const min = el.min ? `:min='${el.min}'` : ''
+    const max = el.max ? `:max='${el.max}'` : ''
+    const step = el.step ? `:step='${el.step}'` : ''
+    const range = el.range ? 'range' : ''
+    const showStops = el['show-stops'] ? `:show-stops="${el['show-stops']}"` : ''
+
+    return `<${el.tag} ${min} ${max} ${step} ${vModel} ${range} ${showStops} ${disabled}></${el.tag}>`
+  },
+  'el-time-picker': el => {
+    const {
+      disabled, vModel, clearable, placeholder, width
+    } = attrBuilder(el)
+    const startPlaceholder = el['start-placeholder'] ? `start-placeholder="${el['start-placeholder']}"` : ''
+    const endPlaceholder = el['end-placeholder'] ? `end-placeholder="${el['end-placeholder']}"` : ''
+    const rangeSeparator = el['range-separator'] ? `range-separator="${el['range-separator']}"` : ''
+    const isRange = el['is-range'] ? 'is-range' : ''
+    const format = el.format ? `format="${el.format}"` : ''
+    const valueFormat = el['value-format'] ? `value-format="${el['value-format']}"` : ''
+    const pickerOptions = el['picker-options'] ? `:picker-options='${JSON.stringify(el['picker-options'])}'` : ''
+
+    return `<${el.tag} ${vModel} ${isRange} ${format} ${valueFormat} ${pickerOptions} ${width} ${placeholder} ${startPlaceholder} ${endPlaceholder} ${rangeSeparator} ${clearable} ${disabled}></${el.tag}>`
+  },
+  'el-date-picker': el => {
+    const {
+      disabled, vModel, clearable, placeholder, width
+    } = attrBuilder(el)
+    const startPlaceholder = el['start-placeholder'] ? `start-placeholder="${el['start-placeholder']}"` : ''
+    const endPlaceholder = el['end-placeholder'] ? `end-placeholder="${el['end-placeholder']}"` : ''
+    const rangeSeparator = el['range-separator'] ? `range-separator="${el['range-separator']}"` : ''
+    const format = el.format ? `format="${el.format}"` : ''
+    const valueFormat = el['value-format'] ? `value-format="${el['value-format']}"` : ''
+    const type = el.type === 'date' ? '' : `type="${el.type}"`
+    const readonly = el.readonly ? 'readonly' : ''
+
+    return `<${el.tag} ${type} ${vModel} ${format} ${valueFormat} ${width} ${placeholder} ${startPlaceholder} ${endPlaceholder} ${rangeSeparator} ${clearable} ${readonly} ${disabled}></${el.tag}>`
+  },
+  'el-rate': el => {
+    const { disabled, vModel } = attrBuilder(el)
+    const max = el.max ? `:max='${el.max}'` : ''
+    const allowHalf = el['allow-half'] ? 'allow-half' : ''
+    const showText = el['show-text'] ? 'show-text' : ''
+    const showScore = el['show-score'] ? 'show-score' : ''
+
+    return `<${el.tag} ${vModel} ${allowHalf} ${showText} ${showScore} ${disabled}></${el.tag}>`
+  },
+  'el-color-picker': el => {
+    const { disabled, vModel } = attrBuilder(el)
+    const size = `size="${el.size}"`
+    const showAlpha = el['show-alpha'] ? 'show-alpha' : ''
+    const colorFormat = el['color-format'] ? `color-format="${el['color-format']}"` : ''
+
+    return `<${el.tag} ${vModel} ${size} ${showAlpha} ${colorFormat} ${disabled}></${el.tag}>`
+  },
+  'el-upload': el => {
+    const disabled = el.disabled ? ':disabled=\'true\'' : ''
+    const action = el.action ? `:action="${el.vModel}Action"` : ''
+    const multiple = el.multiple ? 'multiple' : ''
+    const listType = el['list-type'] !== 'text' ? `list-type="${el['list-type']}"` : ''
+    const accept = el.accept ? `accept="${el.accept}"` : ''
+    const name = el.name !== 'file' ? `name="${el.name}"` : ''
+    const autoUpload = el['auto-upload'] === false ? ':auto-upload="false"' : ''
+    const beforeUpload = `:before-upload="${el.vModel}BeforeUpload"`
+    const fileList = `:file-list="${el.vModel}fileList"`
+    const ref = `ref="${el.vModel}"`
+    let child = buildElUploadChild(el)
+
+    if (child) child = `\n${child}\n` // 换行
+    return `<${el.tag} ${ref} ${fileList} ${action} ${autoUpload} ${multiple} ${beforeUpload} ${listType} ${accept} ${name} ${disabled}>${child}</${el.tag}>`
+  }
+}
+
+function attrBuilder(el) {
+  return {
+    vModel: `v-model="${confGlobal.formModel}.${el.vModel}"`,
+    clearable: el.clearable ? 'clearable' : '',
+    placeholder: el.placeholder ? `placeholder="${el.placeholder}"` : '',
+    width: el.style && el.style.width ? ':style="{width: \'100%\'}"' : '',
+    disabled: el.disabled ? ':disabled=\'true\'' : ''
+  }
+}
+
+// el-buttin 子级
+function buildElButtonChild(conf) {
+  const children = []
+  if (conf.default) {
+    children.push(conf.default)
+  }
+  return children.join('\n')
+}
+
+// el-input innerHTML
+function buildElInputChild(conf) {
+  const children = []
+  if (conf.prepend) {
+    children.push(`<template slot="prepend">${conf.prepend}</template>`)
+  }
+  if (conf.append) {
+    children.push(`<template slot="append">${conf.append}</template>`)
+  }
+  return children.join('\n')
+}
+
+function buildElSelectChild(conf) {
+  const children = []
+  if (conf.options && conf.options.length) {
+    children.push(`<el-option v-for="(item, index) in ${conf.vModel}Options" :key="index" :label="item.label" :value="item.value" :disabled="item.disabled"></el-option>`)
+  }
+  return children.join('\n')
+}
+
+function buildElRadioGroupChild(conf) {
+  const children = []
+  if (conf.options && conf.options.length) {
+    const tag = conf.optionType === 'button' ? 'el-radio-button' : 'el-radio'
+    const border = conf.border ? 'border' : ''
+    children.push(`<${tag} v-for="(item, index) in ${conf.vModel}Options" :key="index" :value="item.value" :disabled="item.disabled" ${border}>{{item.label}}</${tag}>`)
+  }
+  return children.join('\n')
+}
+
+function buildElCheckboxGroupChild(conf) {
+  const children = []
+  if (conf.options && conf.options.length) {
+    const tag = conf.optionType === 'button' ? 'el-checkbox-button' : 'el-checkbox'
+    const border = conf.border ? 'border' : ''
+    children.push(`<${tag} v-for="(item, index) in ${conf.vModel}Options" :key="index" :label="item.value" :value="item.label" :disabled="item.disabled" ${border} />`)
+  }
+  return children.join('\n')
+}
+
+function buildElUploadChild(conf) {
+  const list = []
+  if (conf['list-type'] === 'picture-card') list.push('<i class="el-icon-plus"></i>')
+  else list.push(`<el-button size="small" type="primary" icon="el-icon-upload">${conf.buttonText}</el-button>`)
+  if (conf.showTip) list.push(`<div slot="tip" class="el-upload__tip">只能上传不超过 ${conf.fileSize}${conf.sizeUnit} 的${conf.accept}文件</div>`)
+  return list.join('\n')
+}
+
+export function makeUpHtml(conf, type) {
+  const htmlList = []
+  confGlobal = conf
+  someSpanIsNot24 = conf.fields.some(item => item.span !== 24)
+  conf.fields.forEach(el => {
+    htmlList.push(layouts[el.layout](el))
+  })
+  const htmlStr = htmlList.join('\n')
+
+  let temp = buildFormTemplate(conf, htmlStr, type)
+  if (type === 'dialog') {
+    temp = dialogWrapper(temp)
+  }
+  confGlobal = null
+  return temp
+}

File diff suppressed because it is too large
+ 1 - 0
jd-logistics-ui-vue3/src/utils/generator/icon.json


+ 370 - 0
jd-logistics-ui-vue3/src/utils/generator/js.js

@@ -0,0 +1,370 @@
+import { titleCase } from '@/utils/index'
+import { trigger } from './config'
+// 文件大小设置
+const units = {
+  KB: '1024',
+  MB: '1024 / 1024',
+  GB: '1024 / 1024 / 1024',
+}
+/**
+ * @name: 生成js需要的数据
+ * @description: 生成js需要的数据
+ * @param {*} conf
+ * @param {*} type 弹窗或表单
+ * @return {*}
+ */
+export function makeUpJs(conf, type) {
+  conf = JSON.parse(JSON.stringify(conf))
+  const dataList = []
+  const ruleList = []
+  const optionsList = []
+  const propsList = []
+  const methodList = []
+  const uploadVarList = []
+
+  conf.fields.forEach((el) => {
+    buildAttributes(
+      el,
+      dataList,
+      ruleList,
+      optionsList,
+      methodList,
+      propsList,
+      uploadVarList
+    )
+  })
+
+  const script = buildexport(
+    conf,
+    type,
+    dataList.join('\n'),
+    ruleList.join('\n'),
+    optionsList.join('\n'),
+    uploadVarList.join('\n'),
+    propsList.join('\n'),
+    methodList.join('\n')
+  )
+  
+  return script
+}
+/**
+ * @name: 生成参数
+ * @description: 生成参数,包括表单数据表单验证数据,多选选项数据,上传数据等
+ * @return {*}
+ */
+function buildAttributes(
+  el,
+  dataList,
+  ruleList,
+  optionsList,
+  methodList,
+  propsList,
+  uploadVarList
+){
+  buildData(el, dataList)
+  buildRules(el, ruleList)
+
+  if (el.options && el.options.length) {
+    buildOptions(el, optionsList)
+    if (el.dataType === 'dynamic') {
+      const model = `${el.vModel}Options`
+      const options = titleCase(model)
+      buildOptionMethod(`get${options}`, model, methodList)
+    }
+  }
+
+  if (el.props && el.props.props) {
+    buildProps(el, propsList)
+  }
+
+  if (el.action && el.tag === 'el-upload') {
+    uploadVarList.push(
+      `
+      // 上传请求路径
+      const ${el.vModel}Action = ref('${el.action}')
+      // 上传文件列表
+      const ${el.vModel}fileList =  ref([])`
+    )
+    methodList.push(buildBeforeUpload(el))
+    if (!el['auto-upload']) {
+      methodList.push(buildSubmitUpload(el))
+    }
+  }
+
+  if (el.children) {
+    el.children.forEach((el2) => {
+      buildAttributes(
+        el2,
+        dataList,
+        ruleList,
+        optionsList,
+        methodList,
+        propsList,
+        uploadVarList
+      )
+    })
+  }
+}
+/**
+ * @name: 生成表单数据formData
+ * @description: 生成表单数据formData
+ * @param {*} conf
+ * @param {*} dataList 数据列表
+ * @return {*}
+ */
+function buildData(conf, dataList) {
+  if (conf.vModel === undefined) return
+  let defaultValue
+  if (typeof conf.defaultValue === 'string' && !conf.multiple) {
+    defaultValue = `'${conf.defaultValue}'`
+  } else {
+    defaultValue = `${JSON.stringify(conf.defaultValue)}`
+  }
+  dataList.push(`${conf.vModel}: ${defaultValue},`)
+}
+/**
+ * @name: 生成表单验证数据rule
+ * @description: 生成表单验证数据rule
+ * @param {*} conf
+ * @param {*} ruleList 验证数据列表
+ * @return {*}
+ */
+function buildRules(conf, ruleList) {
+  if (conf.vModel === undefined) return
+  const rules = []
+  if (trigger[conf.tag]) {
+    if (conf.required) {
+      const type = Array.isArray(conf.defaultValue) ? "type: 'array'," : ''
+      let message = Array.isArray(conf.defaultValue)
+        ? `请至少选择一个${conf.vModel}`
+        : conf.placeholder
+      if (message === undefined) message = `${conf.label}不能为空`
+      rules.push(
+        `{ required: true, ${type} message: '${message}', trigger: '${
+          trigger[conf.tag]
+        }' }`
+      )
+    }
+    if (conf.regList && Array.isArray(conf.regList)) {
+      conf.regList.forEach((item) => {
+        if (item.pattern) {
+          rules.push(
+            `{ pattern: new RegExp(${item.pattern}), message: '${
+              item.message
+            }', trigger: '${trigger[conf.tag]}' }`
+          )
+        }
+      })
+    }
+    ruleList.push(`${conf.vModel}: [${rules.join(',')}],`)
+  }
+}
+/**
+ * @name: 生成选项数据
+ * @description: 生成选项数据,单选多选下拉等
+ * @param {*} conf
+ * @param {*} optionsList 选项数据列表
+ * @return {*}
+ */
+function buildOptions(conf, optionsList) {
+  if (conf.vModel === undefined) return
+  if (conf.dataType === 'dynamic') {
+    conf.options = []
+  }
+  const str = `const ${conf.vModel}Options = ref(${JSON.stringify(conf.options)})`
+  optionsList.push(str)
+}
+/**
+ * @name: 生成方法
+ * @description: 生成方法
+ * @param {*} methodName 方法名
+ * @param {*} model
+ * @param {*} methodList 方法列表
+ * @return {*}
+ */
+function buildOptionMethod(methodName, model, methodList) {
+  const str = `function ${methodName}() {
+    // TODO 发起请求获取数据
+    ${model}.value
+  }`
+  methodList.push(str)
+}
+/**
+ * @name: 生成表单组件需要的props设置
+ * @description: 生成表单组件需要的props设置,如;级联组件
+ * @param {*} conf
+ * @param {*} propsList
+ * @return {*}
+ */
+function buildProps(conf, propsList) {
+  if (conf.dataType === 'dynamic') {
+    conf.valueKey !== 'value' && (conf.props.props.value = conf.valueKey)
+    conf.labelKey !== 'label' && (conf.props.props.label = conf.labelKey)
+    conf.childrenKey !== 'children' &&
+      (conf.props.props.children = conf.childrenKey)
+  }
+  const str = `
+  // props设置
+  const ${conf.vModel}Props = ref(${JSON.stringify(conf.props.props)})`
+  propsList.push(str)
+}
+/**
+ * @name: 生成上传组件的相关内容
+ * @description: 生成上传组件的相关内容
+ * @param {*} conf
+ * @return {*}
+ */
+function buildBeforeUpload(conf) {
+  const unitNum = units[conf.sizeUnit]
+  let rightSizeCode = ''
+  let acceptCode = ''
+  const returnList = []
+  if (conf.fileSize) {
+    rightSizeCode = `let isRightSize = file.size / ${unitNum} < ${conf.fileSize}
+    if(!isRightSize){
+      proxy.$modal.msgError('文件大小超过 ${conf.fileSize}${conf.sizeUnit}')
+    }`
+    returnList.push('isRightSize')
+  }
+  if (conf.accept) {
+    acceptCode = `let isAccept = new RegExp('${conf.accept}').test(file.type)
+    if(!isAccept){
+      proxy.$modal.msgError('应该选择${conf.accept}类型的文件')
+    }`
+    returnList.push('isAccept')
+  }
+  const str = `
+  /**
+   * @name: 上传之前的文件判断
+   * @description: 上传之前的文件判断,判断文件大小文件类型等
+   * @param {*} file
+   * @return {*}
+   */  
+  function ${conf.vModel}BeforeUpload(file) {
+    ${rightSizeCode}
+    ${acceptCode}
+    return ${returnList.join('&&')}
+  }`
+  return returnList.length ? str : ''
+}
+/**
+ * @name: 生成提交表单方法
+ * @description: 生成提交表单方法
+ * @param {Object} conf vModel 表单ref
+ * @return {*}
+ */
+function buildSubmitUpload(conf) {
+  const str = `function submitUpload() {
+    this.$refs['${conf.vModel}'].submit()
+  }`
+  return str
+}
+/**
+ * @name: 组装js代码
+ * @description: 组装js代码方法
+ * @return {*}
+ */
+function buildexport(
+  conf,
+  type,
+  data,
+  rules,
+  selectOptions,
+  uploadVar,
+  props,
+  methods
+) {
+  let str = `
+    const { proxy } = getCurrentInstance()
+    const ${conf.formRef} = ref()
+    const data = reactive({
+      ${conf.formModel}: {
+        ${data}
+      },
+      ${conf.formRules}: {
+        ${rules}
+      }
+    })
+
+    const {${conf.formModel}, ${conf.formRules}} = toRefs(data)
+
+    ${selectOptions}
+
+    ${uploadVar}
+
+    ${props}
+
+    ${methods}
+  `
+  
+  if(type === 'dialog') {
+    str += `
+      // 弹窗设置
+      const dialogVisible = defineModel()
+      // 弹窗确认回调
+      const emit = defineEmits(['confirm'])
+      /**
+       * @name: 弹窗打开后执行
+       * @description: 弹窗打开后执行方法
+       * @return {*}
+       */
+      function onOpen(){
+
+      }
+      /**
+       * @name: 弹窗关闭时执行
+       * @description: 弹窗关闭方法,重置表单
+       * @return {*}
+       */
+      function onClose(){
+        ${conf.formRef}.value.resetFields()
+      }
+      /**
+       * @name: 弹窗取消
+       * @description: 弹窗取消方法
+       * @return {*}
+       */
+      function close(){
+        dialogVisible.value = false
+      }
+      /**
+       * @name: 弹窗表单提交
+       * @description: 弹窗表单提交方法
+       * @return {*}
+       */
+      function handelConfirm(){
+        ${conf.formRef}.value.validate((valid) => {
+          if (!valid) return
+          // TODO 提交表单
+
+          close()
+          // 回调父级组件
+          emit('confirm')
+        })
+      }
+    `
+  } else {
+    str += `
+    /**
+     * @name: 表单提交
+     * @description: 表单提交方法
+     * @return {*}
+     */
+    function submitForm() {
+      ${conf.formRef}.value.validate((valid) => {
+        if (!valid) return
+        // TODO 提交表单
+      })
+    }
+    /**
+     * @name: 表单重置
+     * @description: 表单重置方法
+     * @return {*}
+     */
+    function resetForm() {
+      ${conf.formRef}.value.resetFields()
+    }
+    `
+  }
+  return str
+}

+ 156 - 0
jd-logistics-ui-vue3/src/utils/generator/render.js

@@ -0,0 +1,156 @@
+import { defineComponent, h } from 'vue'
+import { makeMap } from '@/utils/index'
+
+const isAttr = makeMap(
+  'accept,accept-charset,accesskey,action,align,alt,async,autocomplete,' +
+  'autofocus,autoplay,autosave,bgcolor,border,buffered,challenge,charset,' +
+  'checked,cite,class,code,codebase,color,cols,colspan,content,http-equiv,' +
+  'name,contenteditable,contextmenu,controls,coords,data,datetime,default,' +
+  'defer,dir,dirname,disabled,download,draggable,dropzone,enctype,method,for,' +
+  'form,formaction,headers,height,hidden,high,href,hreflang,http-equiv,' +
+  'icon,id,ismap,itemprop,keytype,kind,label,lang,language,list,loop,low,' +
+  'manifest,max,maxlength,media,method,GET,POST,min,multiple,email,file,' +
+  'muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,' +
+  'preload,radiogroup,readonly,rel,required,reversed,rows,rowspan,sandbox,' +
+  'scope,scoped,seamless,selected,shape,size,type,text,password,sizes,span,' +
+  'spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,' +
+  'target,title,type,usemap,value,width,wrap' + 'prefix-icon'
+)
+const isNotProps = makeMap(
+  'layout,prepend,regList,tag,document,changeTag,defaultValue'
+)
+
+function useVModel(props, emit) {
+  return {
+    modelValue: props.defaultValue,
+    'onUpdate:modelValue': (val) => emit('update:modelValue', val),
+  }
+}
+const componentChild = {
+  'el-button': {
+    default(h, conf, key) {
+      return conf[key]
+    },
+  },
+  'el-select': {
+    options(h, conf, key) {
+      return conf.options.map(item => h(resolveComponent('el-option'), {
+        label: item.label,
+        value: item.value,
+      }))
+    }
+  },
+  'el-radio-group': {
+    options(h, conf, key) {
+      return conf.optionType === 'button' ? conf.options.map(item => h(resolveComponent('el-checkbox-button'), {
+        label: item.value,
+      }, () => item.label)) : conf.options.map(item => h(resolveComponent('el-radio'), {
+        label: item.value,
+        border: conf.border,
+      }, () => item.label))
+    }
+  },
+  'el-checkbox-group': {
+    options(h, conf, key) {
+      return conf.optionType === 'button' ? conf.options.map(item => h(resolveComponent('el-checkbox-button'), {
+        label: item.value,
+      }, () => item.label)) : conf.options.map(item => h(resolveComponent('el-checkbox'), {
+        label: item.value,
+        border: conf.border,
+      }, () => item.label))
+    }
+  },
+  'el-upload': {
+    'list-type': (h, conf, key) => {
+      const option = {}
+      // if (conf.showTip) {
+      //   tip = h('div', {
+      //     class: "el-upload__tip"
+      //   }, () => '只能上传不超过' + conf.fileSize + conf.sizeUnit + '的' + conf.accept + '文件')
+      // }
+      if (conf['list-type'] === 'picture-card') {
+        return h(resolveComponent('el-icon'), option, () => h(resolveComponent('Plus')))
+      } else {
+        // option.size = "small"
+        option.type = "primary"
+        option.icon = "Upload"
+        return h(resolveComponent('el-button'), option, () => conf.buttonText)
+      }
+    },
+
+  }
+}
+const componentSlot = {
+  'el-upload': {
+    'tip': (h, conf, key) => {
+      if (conf.showTip) {
+        return () => h('div', {
+          class: "el-upload__tip"
+        }, '只能上传不超过' + conf.fileSize + conf.sizeUnit + '的' + conf.accept + '文件')
+      }
+    },
+  }
+}
+export default defineComponent({
+
+  // 使用 render 函数
+  render() {
+    const dataObject = {
+      attrs: {},
+      props: {},
+      on: {},
+      style: {}
+    }
+    const confClone = JSON.parse(JSON.stringify(this.conf))
+    const children = []
+    const slot = {}
+    const childObjs = componentChild[confClone.tag]
+    if (childObjs) {
+      Object.keys(childObjs).forEach(key => {
+        const childFunc = childObjs[key]
+        if (confClone[key]) {
+          children.push(childFunc(h, confClone, key))
+        }
+      })
+    }
+    const slotObjs = componentSlot[confClone.tag]
+    if (slotObjs) {
+      Object.keys(slotObjs).forEach(key => {
+        const childFunc = slotObjs[key]
+        if (confClone[key]) {
+          slot[key] = childFunc(h, confClone, key)
+        }
+      })
+    }
+    Object.keys(confClone).forEach(key => {
+      const val = confClone[key]
+      if (dataObject[key]) {
+        dataObject[key] = val
+      } else if (isAttr(key)) {
+        dataObject.attrs[key] = val
+      } else if (!isNotProps(key)) {
+        dataObject.props[key] = val
+      }
+    })
+    if(children.length > 0){
+      slot.default = () => children
+    }
+    
+    return h(resolveComponent(this.conf.tag),
+      {
+        modelValue: this.$attrs.modelValue,
+        ...dataObject.props,
+        ...dataObject.attrs,
+        style: {
+          ...dataObject.style
+        },
+      }
+      , slot ?? null)
+  },
+  props: {
+    conf: {
+      type: Object,
+      required: true,
+    },
+  }
+})

+ 390 - 0
jd-logistics-ui-vue3/src/utils/index.js

@@ -0,0 +1,390 @@
+import { parseTime } from './ruoyi'
+
+/**
+ * 表格时间格式化
+ */
+export function formatDate(cellValue) {
+  if (cellValue == null || cellValue == "") return ""
+  var date = new Date(cellValue) 
+  var year = date.getFullYear()
+  var month = date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1
+  var day = date.getDate() < 10 ? '0' + date.getDate() : date.getDate() 
+  var hours = date.getHours() < 10 ? '0' + date.getHours() : date.getHours() 
+  var minutes = date.getMinutes() < 10 ? '0' + date.getMinutes() : date.getMinutes() 
+  var seconds = date.getSeconds() < 10 ? '0' + date.getSeconds() : date.getSeconds()
+  return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
+}
+
+/**
+ * @param {number} time
+ * @param {string} option
+ * @returns {string}
+ */
+export function formatTime(time, option) {
+  if (('' + time).length === 10) {
+    time = parseInt(time) * 1000
+  } else {
+    time = +time
+  }
+  const d = new Date(time)
+  const now = Date.now()
+
+  const diff = (now - d) / 1000
+
+  if (diff < 30) {
+    return '刚刚'
+  } else if (diff < 3600) {
+    // less 1 hour
+    return Math.ceil(diff / 60) + '分钟前'
+  } else if (diff < 3600 * 24) {
+    return Math.ceil(diff / 3600) + '小时前'
+  } else if (diff < 3600 * 24 * 2) {
+    return '1天前'
+  }
+  if (option) {
+    return parseTime(time, option)
+  } else {
+    return (
+      d.getMonth() +
+      1 +
+      '月' +
+      d.getDate() +
+      '日' +
+      d.getHours() +
+      '时' +
+      d.getMinutes() +
+      '分'
+    )
+  }
+}
+
+/**
+ * @param {string} url
+ * @returns {Object}
+ */
+export function getQueryObject(url) {
+  url = url == null ? window.location.href : url
+  const search = url.substring(url.lastIndexOf('?') + 1)
+  const obj = {}
+  const reg = /([^?&=]+)=([^?&=]*)/g
+  search.replace(reg, (rs, $1, $2) => {
+    const name = decodeURIComponent($1)
+    let val = decodeURIComponent($2)
+    val = String(val)
+    obj[name] = val
+    return rs
+  })
+  return obj
+}
+
+/**
+ * @param {string} input value
+ * @returns {number} output value
+ */
+export function byteLength(str) {
+  // returns the byte length of an utf8 string
+  let s = str.length
+  for (var i = str.length - 1; i >= 0; i--) {
+    const code = str.charCodeAt(i)
+    if (code > 0x7f && code <= 0x7ff) s++
+    else if (code > 0x7ff && code <= 0xffff) s += 2
+    if (code >= 0xDC00 && code <= 0xDFFF) i--
+  }
+  return s
+}
+
+/**
+ * @param {Array} actual
+ * @returns {Array}
+ */
+export function cleanArray(actual) {
+  const newArray = []
+  for (let i = 0; i < actual.length; i++) {
+    if (actual[i]) {
+      newArray.push(actual[i])
+    }
+  }
+  return newArray
+}
+
+/**
+ * @param {Object} json
+ * @returns {Array}
+ */
+export function param(json) {
+  if (!json) return ''
+  return cleanArray(
+    Object.keys(json).map(key => {
+      if (json[key] === undefined) return ''
+      return encodeURIComponent(key) + '=' + encodeURIComponent(json[key])
+    })
+  ).join('&')
+}
+
+/**
+ * @param {string} url
+ * @returns {Object}
+ */
+export function param2Obj(url) {
+  const search = decodeURIComponent(url.split('?')[1]).replace(/\+/g, ' ')
+  if (!search) {
+    return {}
+  }
+  const obj = {}
+  const searchArr = search.split('&')
+  searchArr.forEach(v => {
+    const index = v.indexOf('=')
+    if (index !== -1) {
+      const name = v.substring(0, index)
+      const val = v.substring(index + 1, v.length)
+      obj[name] = val
+    }
+  })
+  return obj
+}
+
+/**
+ * @param {string} val
+ * @returns {string}
+ */
+export function html2Text(val) {
+  const div = document.createElement('div')
+  div.innerHTML = val
+  return div.textContent || div.innerText
+}
+
+/**
+ * Merges two objects, giving the last one precedence
+ * @param {Object} target
+ * @param {(Object|Array)} source
+ * @returns {Object}
+ */
+export function objectMerge(target, source) {
+  if (typeof target !== 'object') {
+    target = {}
+  }
+  if (Array.isArray(source)) {
+    return source.slice()
+  }
+  Object.keys(source).forEach(property => {
+    const sourceProperty = source[property]
+    if (typeof sourceProperty === 'object') {
+      target[property] = objectMerge(target[property], sourceProperty)
+    } else {
+      target[property] = sourceProperty
+    }
+  })
+  return target
+}
+
+/**
+ * @param {HTMLElement} element
+ * @param {string} className
+ */
+export function toggleClass(element, className) {
+  if (!element || !className) {
+    return
+  }
+  let classString = element.className
+  const nameIndex = classString.indexOf(className)
+  if (nameIndex === -1) {
+    classString += '' + className
+  } else {
+    classString =
+      classString.substr(0, nameIndex) +
+      classString.substr(nameIndex + className.length)
+  }
+  element.className = classString
+}
+
+/**
+ * @param {string} type
+ * @returns {Date}
+ */
+export function getTime(type) {
+  if (type === 'start') {
+    return new Date().getTime() - 3600 * 1000 * 24 * 90
+  } else {
+    return new Date(new Date().toDateString())
+  }
+}
+
+/**
+ * @param {Function} func
+ * @param {number} wait
+ * @param {boolean} immediate
+ * @return {*}
+ */
+export function debounce(func, wait, immediate) {
+  let timeout, args, context, timestamp, result
+
+  const later = function() {
+    // 据上一次触发时间间隔
+    const last = +new Date() - timestamp
+
+    // 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait
+    if (last < wait && last > 0) {
+      timeout = setTimeout(later, wait - last)
+    } else {
+      timeout = null
+      // 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
+      if (!immediate) {
+        result = func.apply(context, args)
+        if (!timeout) context = args = null
+      }
+    }
+  }
+
+  return function(...args) {
+    context = this
+    timestamp = +new Date()
+    const callNow = immediate && !timeout
+    // 如果延时不存在,重新设定延时
+    if (!timeout) timeout = setTimeout(later, wait)
+    if (callNow) {
+      result = func.apply(context, args)
+      context = args = null
+    }
+
+    return result
+  }
+}
+
+/**
+ * This is just a simple version of deep copy
+ * Has a lot of edge cases bug
+ * If you want to use a perfect deep copy, use lodash's _.cloneDeep
+ * @param {Object} source
+ * @returns {Object}
+ */
+export function deepClone(source) {
+  if (!source && typeof source !== 'object') {
+    throw new Error('error arguments', 'deepClone')
+  }
+  const targetObj = source.constructor === Array ? [] : {}
+  Object.keys(source).forEach(keys => {
+    if (source[keys] && typeof source[keys] === 'object') {
+      targetObj[keys] = deepClone(source[keys])
+    } else {
+      targetObj[keys] = source[keys]
+    }
+  })
+  return targetObj
+}
+
+/**
+ * @param {Array} arr
+ * @returns {Array}
+ */
+export function uniqueArr(arr) {
+  return Array.from(new Set(arr))
+}
+
+/**
+ * @returns {string}
+ */
+export function createUniqueString() {
+  const timestamp = +new Date() + ''
+  const randomNum = parseInt((1 + Math.random()) * 65536) + ''
+  return (+(randomNum + timestamp)).toString(32)
+}
+
+/**
+ * Check if an element has a class
+ * @param {HTMLElement} elm
+ * @param {string} cls
+ * @returns {boolean}
+ */
+export function hasClass(ele, cls) {
+  return !!ele.className.match(new RegExp('(\\s|^)' + cls + '(\\s|$)'))
+}
+
+/**
+ * Add class to element
+ * @param {HTMLElement} elm
+ * @param {string} cls
+ */
+export function addClass(ele, cls) {
+  if (!hasClass(ele, cls)) ele.className += ' ' + cls
+}
+
+/**
+ * Remove class from element
+ * @param {HTMLElement} elm
+ * @param {string} cls
+ */
+export function removeClass(ele, cls) {
+  if (hasClass(ele, cls)) {
+    const reg = new RegExp('(\\s|^)' + cls + '(\\s|$)')
+    ele.className = ele.className.replace(reg, ' ')
+  }
+}
+
+export function makeMap(str, expectsLowerCase) {
+  const map = Object.create(null)
+  const list = str.split(',')
+  for (let i = 0; i < list.length; i++) {
+    map[list[i]] = true
+  }
+  return expectsLowerCase
+    ? val => map[val.toLowerCase()]
+    : val => map[val]
+}
+ 
+export const exportDefault = 'export default '
+
+export const beautifierConf = {
+  html: {
+    indent_size: '2',
+    indent_char: ' ',
+    max_preserve_newlines: '-1',
+    preserve_newlines: false,
+    keep_array_indentation: false,
+    break_chained_methods: false,
+    indent_scripts: 'separate',
+    brace_style: 'end-expand',
+    space_before_conditional: true,
+    unescape_strings: false,
+    jslint_happy: false,
+    end_with_newline: true,
+    wrap_line_length: '110',
+    indent_inner_html: true,
+    comma_first: false,
+    e4x: true,
+    indent_empty_lines: true
+  },
+  js: {
+    indent_size: '2',
+    indent_char: ' ',
+    max_preserve_newlines: '-1',
+    preserve_newlines: false,
+    keep_array_indentation: false,
+    break_chained_methods: false,
+    indent_scripts: 'normal',
+    brace_style: 'end-expand',
+    space_before_conditional: true,
+    unescape_strings: false,
+    jslint_happy: true,
+    end_with_newline: true,
+    wrap_line_length: '110',
+    indent_inner_html: true,
+    comma_first: false,
+    e4x: true,
+    indent_empty_lines: true
+  }
+}
+
+// 首字母大小
+export function titleCase(str) {
+  return str.replace(/( |^)[a-z]/g, L => L.toUpperCase())
+}
+
+// 下划转驼峰
+export function camelCase(str) {
+  return str.replace(/_[a-z]/g, str1 => str1.substr(-1).toUpperCase())
+}
+
+export function isNumberStr(str) {
+  return /^[+-]?(0|([1-9]\d*))(\.\d+)?$/g.test(str)
+}
+ 

+ 30 - 0
jd-logistics-ui-vue3/src/utils/jsencrypt.js

@@ -0,0 +1,30 @@
+import JSEncrypt from 'jsencrypt/bin/jsencrypt.min'
+
+// 密钥对生成 http://web.chacuo.net/netrsakeypair
+
+const publicKey = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdH\n' +
+  'nzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ=='
+
+const privateKey = 'MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAqhHyZfSsYourNxaY\n' +
+  '7Nt+PrgrxkiA50efORdI5U5lsW79MmFnusUA355oaSXcLhu5xxB38SMSyP2KvuKN\n' +
+  'PuH3owIDAQABAkAfoiLyL+Z4lf4Myxk6xUDgLaWGximj20CUf+5BKKnlrK+Ed8gA\n' +
+  'kM0HqoTt2UZwA5E2MzS4EI2gjfQhz5X28uqxAiEA3wNFxfrCZlSZHb0gn2zDpWow\n' +
+  'cSxQAgiCstxGUoOqlW8CIQDDOerGKH5OmCJ4Z21v+F25WaHYPxCFMvwxpcw99Ecv\n' +
+  'DQIgIdhDTIqD2jfYjPTY8Jj3EDGPbH2HHuffvflECt3Ek60CIQCFRlCkHpi7hthh\n' +
+  'YhovyloRYsM+IS9h/0BzlEAuO0ktMQIgSPT3aFAgJYwKpqRYKlLDVcflZFCKY7u3\n' +
+  'UP8iWi1Qw0Y='
+
+// 加密
+export function encrypt(txt) {
+  const encryptor = new JSEncrypt()
+  encryptor.setPublicKey(publicKey) // 设置公钥
+  return encryptor.encrypt(txt) // 对数据进行加密
+}
+
+// 解密
+export function decrypt(txt) {
+  const encryptor = new JSEncrypt()
+  encryptor.setPrivateKey(privateKey) // 设置私钥
+  return encryptor.decrypt(txt) // 对数据进行解密
+}
+

+ 51 - 0
jd-logistics-ui-vue3/src/utils/permission.js

@@ -0,0 +1,51 @@
+import useUserStore from '@/store/modules/user'
+
+/**
+ * 字符权限校验
+ * @param {Array} value 校验值
+ * @returns {Boolean}
+ */
+export function checkPermi(value) {
+  if (value && value instanceof Array && value.length > 0) {
+    const permissions = useUserStore().permissions
+    const permissionDatas = value
+    const all_permission = "*:*:*"
+
+    const hasPermission = permissions.some(permission => {
+      return all_permission === permission || permissionDatas.includes(permission)
+    })
+
+    if (!hasPermission) {
+      return false
+    }
+    return true
+  } else {
+    console.error(`need roles! Like checkPermi="['system:user:add','system:user:edit']"`)
+    return false
+  }
+}
+
+/**
+ * 角色权限校验
+ * @param {Array} value 校验值
+ * @returns {Boolean}
+ */
+export function checkRole(value) {
+  if (value && value instanceof Array && value.length > 0) {
+    const roles = useUserStore().roles
+    const permissionRoles = value
+    const super_admin = "admin"
+
+    const hasRole = roles.some(role => {
+      return super_admin === role || permissionRoles.includes(role)
+    })
+
+    if (!hasRole) {
+      return false
+    }
+    return true
+  } else {
+    console.error(`need roles! Like checkRole="['admin','editor']"`)
+    return false
+  }
+}

+ 153 - 0
jd-logistics-ui-vue3/src/utils/request.js

@@ -0,0 +1,153 @@
+import axios from 'axios'
+import { ElNotification , ElMessageBox, ElMessage, ElLoading } from 'element-plus'
+import { getToken } from '@/utils/auth'
+import errorCode from '@/utils/errorCode'
+import { tansParams, blobValidate } from '@/utils/ruoyi'
+import cache from '@/plugins/cache'
+import { saveAs } from 'file-saver'
+import useUserStore from '@/store/modules/user'
+
+let downloadLoadingInstance
+// 是否显示重新登录
+export let isRelogin = { show: false }
+
+axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
+// 创建axios实例
+const service = axios.create({
+  // axios中请求配置有baseURL选项,表示请求URL公共部分
+  baseURL: import.meta.env.VITE_APP_BASE_API,
+  // 超时
+  timeout: 10000
+})
+
+// request拦截器
+service.interceptors.request.use(config => {
+  // 是否需要设置 token
+  const isToken = (config.headers || {}).isToken === false
+  // 是否需要防止数据重复提交
+  const isRepeatSubmit = (config.headers || {}).repeatSubmit === false
+  // 间隔时间(ms),小于此时间视为重复提交
+  const interval = (config.headers || {}).interval || 1000
+  if (getToken() && !isToken) {
+    config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
+  }
+  // get请求映射params参数
+  if (config.method === 'get' && config.params) {
+    let url = config.url + '?' + tansParams(config.params)
+    url = url.slice(0, -1)
+    config.params = {}
+    config.url = url
+  }
+  if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
+    const requestObj = {
+      url: config.url,
+      data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,
+      time: new Date().getTime()
+    }
+    const requestSize = Object.keys(JSON.stringify(requestObj)).length // 请求数据大小
+    const limitSize = 5 * 1024 * 1024 // 限制存放数据5M
+    if (requestSize >= limitSize) {
+      console.warn(`[${config.url}]: ` + '请求数据大小超出允许的5M限制,无法进行防重复提交验证。')
+      return config
+    }
+    const sessionObj = cache.session.getJSON('sessionObj')
+    if (sessionObj === undefined || sessionObj === null || sessionObj === '') {
+      cache.session.setJSON('sessionObj', requestObj)
+    } else {
+      const s_url = sessionObj.url                // 请求地址
+      const s_data = sessionObj.data              // 请求数据
+      const s_time = sessionObj.time              // 请求时间
+      if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {
+        const message = '数据正在处理,请勿重复提交'
+        console.warn(`[${s_url}]: ` + message)
+        return Promise.reject(new Error(message))
+      } else {
+        cache.session.setJSON('sessionObj', requestObj)
+      }
+    }
+  }
+  return config
+}, error => {
+    console.log(error)
+    Promise.reject(error)
+})
+
+// 响应拦截器
+service.interceptors.response.use(res => {
+    // 未设置状态码则默认成功状态
+    const code = res.data.code || 200
+    // 获取错误信息
+    const msg = errorCode[code] || res.data.msg || errorCode['default']
+    // 二进制数据则直接返回
+    if (res.request.responseType ===  'blob' || res.request.responseType ===  'arraybuffer') {
+      return res.data
+    }
+    if (code === 401) {
+      if (!isRelogin.show) {
+        isRelogin.show = true
+        ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
+          isRelogin.show = false
+          useUserStore().logOut().then(() => {
+            location.href = '/index'
+          })
+      }).catch(() => {
+        isRelogin.show = false
+      })
+    }
+      return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
+    } else if (code === 500) {
+      ElMessage({ message: msg, type: 'error' })
+      return Promise.reject(new Error(msg))
+    } else if (code === 601) {
+      ElMessage({ message: msg, type: 'warning' })
+      return Promise.reject(new Error(msg))
+    } else if (code !== 200) {
+      ElNotification.error({ title: msg })
+      return Promise.reject('error')
+    } else {
+      return  Promise.resolve(res.data)
+    }
+  },
+  error => {
+    console.log('err' + error)
+    let { message } = error
+    if (message == "Network Error") {
+      message = "后端接口连接异常"
+    } else if (message.includes("timeout")) {
+      message = "系统接口请求超时"
+    } else if (message.includes("Request failed with status code")) {
+      message = "系统接口" + message.slice(-3) + "异常"
+    }
+    ElMessage({ message: message, type: 'error', duration: 5 * 1000 })
+    return Promise.reject(error)
+  }
+)
+
+// 通用下载方法
+export function download(url, params, filename, config) {
+  downloadLoadingInstance = ElLoading.service({ text: "正在下载数据,请稍候", background: "rgba(0, 0, 0, 0.7)", })
+  return service.post(url, params, {
+    transformRequest: [(params) => { return tansParams(params) }],
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    responseType: 'blob',
+    ...config
+  }).then(async (data) => {
+    const isBlob = blobValidate(data)
+    if (isBlob) {
+      const blob = new Blob([data])
+      saveAs(blob, filename)
+    } else {
+      const resText = await data.text()
+      const rspObj = JSON.parse(resText)
+      const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default']
+      ElMessage.error(errMsg)
+    }
+    downloadLoadingInstance.close()
+  }).catch((r) => {
+    console.error(r)
+    ElMessage.error('下载文件出现错误,请联系管理员!')
+    downloadLoadingInstance.close()
+  })
+}
+
+export default service

+ 228 - 0
jd-logistics-ui-vue3/src/utils/ruoyi.js

@@ -0,0 +1,228 @@
+/**
+ * 通用js方法封装处理
+ * Copyright (c) 2019 ruoyi
+ */
+
+// 日期格式化
+export function parseTime(time, pattern) {
+  if (arguments.length === 0 || !time) {
+    return null
+  }
+  const format = pattern || '{y}-{m}-{d} {h}:{i}:{s}'
+  let date
+  if (typeof time === 'object') {
+    date = time
+  } else {
+    if ((typeof time === 'string') && (/^[0-9]+$/.test(time))) {
+      time = parseInt(time)
+    } else if (typeof time === 'string') {
+      time = time.replace(new RegExp(/-/gm), '/').replace('T', ' ').replace(new RegExp(/\.[\d]{3}/gm), '')
+    }
+    if ((typeof time === 'number') && (time.toString().length === 10)) {
+      time = time * 1000
+    }
+    date = new Date(time)
+  }
+  const formatObj = {
+    y: date.getFullYear(),
+    m: date.getMonth() + 1,
+    d: date.getDate(),
+    h: date.getHours(),
+    i: date.getMinutes(),
+    s: date.getSeconds(),
+    a: date.getDay()
+  }
+  const time_str = format.replace(/{(y|m|d|h|i|s|a)+}/g, (result, key) => {
+    let value = formatObj[key]
+    // Note: getDay() returns 0 on Sunday
+    if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value] }
+    if (result.length > 0 && value < 10) {
+      value = '0' + value
+    }
+    return value || 0
+  })
+  return time_str
+}
+
+// 表单重置
+export function resetForm(refName) {
+  if (this.$refs[refName]) {
+    this.$refs[refName].resetFields()
+  }
+}
+
+// 添加日期范围
+export function addDateRange(params, dateRange, propName) {
+  let search = params
+  search.params = typeof (search.params) === 'object' && search.params !== null && !Array.isArray(search.params) ? search.params : {}
+  dateRange = Array.isArray(dateRange) ? dateRange : []
+  if (typeof (propName) === 'undefined') {
+    search.params['beginTime'] = dateRange[0]
+    search.params['endTime'] = dateRange[1]
+  } else {
+    search.params['begin' + propName] = dateRange[0]
+    search.params['end' + propName] = dateRange[1]
+  }
+  return search
+}
+
+// 回显数据字典
+export function selectDictLabel(datas, value) {
+  if (value === undefined) {
+    return ""
+  }
+  var actions = []
+  Object.keys(datas).some((key) => {
+    if (datas[key].value == ('' + value)) {
+      actions.push(datas[key].label)
+      return true
+    }
+  })
+  if (actions.length === 0) {
+    actions.push(value)
+  }
+  return actions.join('')
+}
+
+// 回显数据字典(字符串、数组)
+export function selectDictLabels(datas, value, separator) {
+  if (value === undefined || value.length ===0) {
+    return ""
+  }
+  if (Array.isArray(value)) {
+    value = value.join(",")
+  }
+  var actions = []
+  var currentSeparator = undefined === separator ? "," : separator
+  var temp = value.split(currentSeparator)
+  Object.keys(value.split(currentSeparator)).some((val) => {
+    var match = false
+    Object.keys(datas).some((key) => {
+      if (datas[key].value == ('' + temp[val])) {
+        actions.push(datas[key].label + currentSeparator)
+        match = true
+      }
+    })
+    if (!match) {
+      actions.push(temp[val] + currentSeparator)
+    }
+  })
+  return actions.join('').substring(0, actions.join('').length - 1)
+}
+
+// 字符串格式化(%s )
+export function sprintf(str) {
+  var args = arguments, flag = true, i = 1
+  str = str.replace(/%s/g, function () {
+    var arg = args[i++]
+    if (typeof arg === 'undefined') {
+      flag = false
+      return ''
+    }
+    return arg
+  })
+  return flag ? str : ''
+}
+
+// 转换字符串,undefined,null等转化为""
+export function parseStrEmpty(str) {
+  if (!str || str == "undefined" || str == "null") {
+    return ""
+  }
+  return str
+}
+
+// 数据合并
+export function mergeRecursive(source, target) {
+  for (var p in target) {
+    try {
+      if (target[p].constructor == Object) {
+        source[p] = mergeRecursive(source[p], target[p])
+      } else {
+        source[p] = target[p]
+      }
+    } catch (e) {
+      source[p] = target[p]
+    }
+  }
+  return source
+}
+
+/**
+ * 构造树型结构数据
+ * @param {*} data 数据源
+ * @param {*} id id字段 默认 'id'
+ * @param {*} parentId 父节点字段 默认 'parentId'
+ * @param {*} children 孩子节点字段 默认 'children'
+ */
+export function handleTree(data, id, parentId, children) {
+  let config = {
+    id: id || 'id',
+    parentId: parentId || 'parentId',
+    childrenList: children || 'children'
+  }
+
+  var childrenListMap = {}
+  var tree = []
+  for (let d of data) {
+    let id = d[config.id]
+    childrenListMap[id] = d
+    if (!d[config.childrenList]) {
+      d[config.childrenList] = []
+    }
+  }
+
+  for (let d of data) {
+    let parentId = d[config.parentId]
+    let parentObj = childrenListMap[parentId]
+    if (!parentObj) {
+      tree.push(d)
+    } else {
+      parentObj[config.childrenList].push(d)
+    }
+  }
+  return tree
+}
+
+/**
+* 参数处理
+* @param {*} params  参数
+*/
+export function tansParams(params) {
+  let result = ''
+  for (const propName of Object.keys(params)) {
+    const value = params[propName]
+    var part = encodeURIComponent(propName) + "="
+    if (value !== null && value !== "" && typeof (value) !== "undefined") {
+      if (typeof value === 'object') {
+        for (const key of Object.keys(value)) {
+          if (value[key] !== null && value[key] !== "" && typeof (value[key]) !== 'undefined') {
+            let params = propName + '[' + key + ']'
+            var subPart = encodeURIComponent(params) + "="
+            result += subPart + encodeURIComponent(value[key]) + "&"
+          }
+        }
+      } else {
+        result += part + encodeURIComponent(value) + "&"
+      }
+    }
+  }
+  return result
+}
+
+// 返回项目路径
+export function getNormalPath(p) {
+  if (p.length === 0 || !p || p == 'undefined') {
+    return p
+  }
+  let res = p.replace('//', '/')
+  if (res[res.length - 1] === '/') {
+    return res.slice(0, res.length - 1)
+  }
+  return res
+}
+
+// 验证是否为blob格式
+export function blobValidate(data) {
+  return data.type !== 'application/json'
+}

+ 58 - 0
jd-logistics-ui-vue3/src/utils/scroll-to.js

@@ -0,0 +1,58 @@
+Math.easeInOutQuad = function(t, b, c, d) {
+  t /= d / 2
+  if (t < 1) {
+    return c / 2 * t * t + b
+  }
+  t--
+  return -c / 2 * (t * (t - 2) - 1) + b
+}
+
+// requestAnimationFrame for Smart Animating http://goo.gl/sx5sts
+var requestAnimFrame = (function() {
+  return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) }
+})()
+
+/**
+ * Because it's so fucking difficult to detect the scrolling element, just move them all
+ * @param {number} amount
+ */
+function move(amount) {
+  document.documentElement.scrollTop = amount
+  document.body.parentNode.scrollTop = amount
+  document.body.scrollTop = amount
+}
+
+function position() {
+  return document.documentElement.scrollTop || document.body.parentNode.scrollTop || document.body.scrollTop
+}
+
+/**
+ * @param {number} to
+ * @param {number} duration
+ * @param {Function} callback
+ */
+export function scrollTo(to, duration, callback) {
+  const start = position()
+  const change = to - start
+  const increment = 20
+  let currentTime = 0
+  duration = (typeof (duration) === 'undefined') ? 500 : duration
+  var animateScroll = function() {
+    // increment the time
+    currentTime += increment
+    // find the value with the quadratic in-out easing function
+    var val = Math.easeInOutQuad(currentTime, start, change, duration)
+    // move the document.body
+    move(val)
+    // do the animation unless its over
+    if (currentTime < duration) {
+      requestAnimFrame(animateScroll)
+    } else {
+      if (callback && typeof (callback) === 'function') {
+        // the animation is done so lets callback
+        callback()
+      }
+    }
+  }
+  animateScroll()
+}

+ 49 - 0
jd-logistics-ui-vue3/src/utils/theme.js

@@ -0,0 +1,49 @@
+// 处理主题样式
+export function handleThemeStyle(theme) {
+	document.documentElement.style.setProperty('--el-color-primary', theme)
+	for (let i = 1; i <= 9; i++) {
+		document.documentElement.style.setProperty(`--el-color-primary-light-${i}`, `${getLightColor(theme, i / 10)}`)
+	}
+	for (let i = 1; i <= 9; i++) {
+		document.documentElement.style.setProperty(`--el-color-primary-dark-${i}`, `${getDarkColor(theme, i / 10)}`)
+	}
+}
+
+// hex颜色转rgb颜色
+export function hexToRgb(str) {
+	str = str.replace('#', '')
+	let hexs = str.match(/../g)
+	for (let i = 0; i < 3; i++) {
+		hexs[i] = parseInt(hexs[i], 16)
+	}
+	return hexs
+}
+
+// rgb颜色转Hex颜色
+export function rgbToHex(r, g, b) {
+	let hexs = [r.toString(16), g.toString(16), b.toString(16)]
+	for (let i = 0; i < 3; i++) {
+		if (hexs[i].length == 1) {
+			hexs[i] = `0${hexs[i]}`
+		}
+	}
+	return `#${hexs.join('')}`
+}
+
+// 变浅颜色值
+export function getLightColor(color, level) {
+	let rgb = hexToRgb(color)
+	for (let i = 0; i < 3; i++) {
+		rgb[i] = Math.floor((255 - rgb[i]) * level + rgb[i])
+	}
+	return rgbToHex(rgb[0], rgb[1], rgb[2])
+}
+
+// 变深颜色值
+export function getDarkColor(color, level) {
+	let rgb = hexToRgb(color)
+	for (let i = 0; i < 3; i++) {
+		rgb[i] = Math.floor(rgb[i] * (1 - level))
+	}
+	return rgbToHex(rgb[0], rgb[1], rgb[2])
+}

+ 0 - 0
jd-logistics-ui-vue3/src/utils/validate.js


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