Просмотр исходного кода

Merge branch 'dev' of https://git.citupro.com/zhengnaiwen_citu/menduner into dev

lifanagju_citu 5 месяцев назад
Родитель
Сommit
7acf498dc2
34 измененных файлов с 1432 добавлено и 167 удалено
  1. 1 1
      components.d.ts
  2. 93 0
      src/api/recruit/enterprise/system/role/index.js
  3. 75 48
      src/components/CtTable/index.vue
  4. 21 0
      src/components/CtVuetify/CtSelect/index.vue
  5. 3 2
      src/components/Enterprise/hotPromoted.vue
  6. 7 13
      src/components/Position/item.vue
  7. 1 1
      src/components/PreviewImg/previewImage.vue
  8. 6 10
      src/components/headSearch/index.vue
  9. 40 2
      src/router/modules/common.js
  10. 0 45
      src/router/modules/components/headhunting.js
  11. 10 0
      src/router/modules/components/recruit/enterprise.js
  12. 1 0
      src/styles/index.css
  13. 0 0
      src/styles/index.min.css
  14. 4 6
      src/styles/recruit/company.css
  15. 1 1
      src/styles/recruit/company.min.css
  16. 6 6
      src/styles/recruit/company.scss
  17. 1 1
      src/utils/position.js
  18. 403 0
      src/utils/tree.ts
  19. 1 1
      src/version.js
  20. 75 0
      src/views/recruit/enterprise/systemManagement/groupAccount/index.vue
  21. 175 0
      src/views/recruit/enterprise/systemManagement/roleManagement/components/DataPermission.vue
  22. 108 0
      src/views/recruit/enterprise/systemManagement/roleManagement/components/MenuPermission.vue
  23. 103 0
      src/views/recruit/enterprise/systemManagement/roleManagement/components/RoleManagementEdit.vue
  24. 240 0
      src/views/recruit/enterprise/systemManagement/roleManagement/index.vue
  25. 3 4
      src/views/recruit/personal/company/components/companyItem.vue
  26. 1 1
      src/views/recruit/personal/company/index.vue
  27. 1 1
      src/views/recruit/personal/companyDetail/components/positions.vue
  28. 4 4
      src/views/recruit/personal/home/components/hotJobs.vue
  29. 5 3
      src/views/recruit/personal/home/components/hotPromotedPositions.vue
  30. 3 1
      src/views/recruit/personal/home/components/popularEnterprises.vue
  31. 17 7
      src/views/recruit/personal/position/components/details.vue
  32. 1 1
      src/views/recruit/personal/position/components/poster.vue
  33. 7 8
      src/views/recruit/personal/position/index.vue
  34. 15 0
      src/views/recruit/personal/recommend/index.vue

+ 1 - 1
components.d.ts

@@ -26,11 +26,11 @@ declare module 'vue' {
     CtMenu: typeof import('./src/components/CtVuetify/CtMenu/index.vue')['default']
     CtPagination: typeof import('./src/components/CtPagination/index.vue')['default']
     CtSearch: typeof import('./src/components/CtSearch/index.vue')['default']
+    CtSelect: typeof import('./src/components/CtVuetify/CtSelect/index.vue')['default']
     CtTable: typeof import('./src/components/CtTable/index.vue')['default']
     CtTextField: typeof import('./src/components/CtVuetify/CtTextField/index.vue')['default']
     DatePicker: typeof import('./src/components/DatePicker/index.vue')['default']
     Echarts: typeof import('./src/components/Echarts/index.vue')['default']
-    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     Empty: typeof import('./src/components/Empty/index.vue')['default']
     File: typeof import('./src/components/Upload/file.vue')['default']

+ 93 - 0
src/api/recruit/enterprise/system/role/index.js

@@ -0,0 +1,93 @@
+import request from '@/config/axios'
+// 角色管理-获得角色分页
+export const getRolePage = async (params) => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/role/page',
+    params
+  })
+}
+
+// 角色管理-获得角色精简列表
+export const getRolePageSimple = async () => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/role/list-all-simple'
+  })
+}
+
+// 角色管理-添加角色
+export const addRole = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/recruit/role/create',
+    data
+  })
+}
+
+// 角色管理-更新角色
+export const updateRole = async (data) => {
+  return await request.put({
+    url: '/app-api/menduner/system/recruit/role/update',
+    data
+  })
+}
+
+// 角色管理-删除角色
+export const deleteRole = async (params) => {
+  return await request.delete({
+    url: '/app-api/menduner/system/recruit/role/delete',
+    params
+  })
+}
+
+// 获取菜单精简信息列表
+export const getMenu = async () => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/menu/simple-list'
+  })
+}
+
+// 获取菜单精简信息列表
+export const getRoleMenu = async (params) => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/permission/list-role-menus',
+    params
+  })
+}
+
+// 角色管理-赋予角色菜单
+export const saveRoleMenu = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/recruit/permission/assign-role-menu',
+    data
+  })
+}
+
+// 获取数据权限树
+export const getDataMenuTree = async () => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/enterprise/get/tree'
+  })
+}
+
+// 角色管理-赋予角色数据权限
+export const saveDataPermission = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/recruit/permission/assign-role-data-scope',
+    data
+  })
+}
+
+// 角色管理-获取用户角色编号列表
+export const getRoleList = async (params) => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/permission/list-user-roles',
+    params
+  })
+}
+
+// 角色管理-赋予用户角色
+export const saveUserRole = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/recruit/permission/assign-user-role',
+    data
+  })
+}

+ 75 - 48
src/components/CtTable/index.vue

@@ -3,7 +3,8 @@
     <div v-if="isTools" class="text-end mb-3">
       <v-btn class="ml-2" color="primary" @click="emit('add')">
         <v-icon left>mdi-plus</v-icon>
-        新增
+        {{ t('common.add') }} 
+        <!-- 新增 -->
       </v-btn>
       <slot name="addToTools"></slot>
     </div>
@@ -26,7 +27,7 @@
       fixed-header
       :disable-sort="disableSort"
       :items-per-page="itemsPerPage"
-      :no-data-text="noDataText"
+      :no-data-text="noDataText || t('common.noData')"
       :hide-default-header="hideDefaultHeader"
       @update:modelValue="handleSelect"
     >
@@ -38,8 +39,8 @@
       </template>
       <template v-if="!Object.keys(slot).includes('actions')" v-slot:[`item.actions`]="{ item }">
         <td>
-          <v-btn variant="text" color="primary" @click="edit(item)">编辑</v-btn>
-          <v-btn variant="text" color="error" @click="del(item)">删除</v-btn>
+          <v-btn variant="text" color="primary" @click="edit(item)">{{ t('common.edit') }}</v-btn>
+          <v-btn variant="text" color="error" @click="del(item)">{{ t('common.delete') }}</v-btn>
         </td>
       </template>
       <template #bottom>
@@ -55,7 +56,9 @@
 <script setup>
 defineOptions({ name: 'CtTable'})
 import { ref, computed, useSlots, watch, onMounted } from 'vue'
+import { useI18n } from '@/hooks/web/useI18n'
 
+const { t } = useI18n()
 const selected = ref([])
 const emit = defineEmits(['pageHandleChange', 'del', 'edit', 'add', 'selected'])
 const props = defineProps({
@@ -117,7 +120,7 @@ const props = defineProps({
   },
   noDataText: {
     type: String,
-    default: '暂无数据'
+    default: ''
   },
   showSelect: {
     type: Boolean,
@@ -201,52 +204,76 @@ const handleSelect = (e) => {
   :deep(.v-table.v-table--hover > .v-table__wrapper > table > tbody > tr > td) {
     white-space: nowrap !important;
   }
-  :deep {
-    table > thead > tr > th:last-child {
-      border-bottom: 1px solid #e0e0e0 !important;
-    }
+  :deep(table > thead > tr > th:last-child) {
+    border-bottom: 1px solid #e0e0e0 !important;
   }
 
   .fixed-last-item {
-    :deep {
-      .v-table__wrapper {
-        position: relative;
-        &::-webkit-scrollbar:horizontal {
-          height: 8px;
-        }
-        &:not(:hover)::-webkit-scrollbar:horizontal {
-          display: none;
-        }
-      }
-
-      .v-table__wrapper:not(:hover), 
-      .v-table__wrapper::-webkit-scrollbar-thumb:horizontal {
-        background: transparent;
-      }
-
-      table > tbody > tr > td:last-child,
-      table > thead > tr > th:last-child {
-        position: sticky !important;
-        position: -webkit-sticky !important;
-        right: 0;
-        z-index: 1;
-        background: white !important;
-        box-shadow: none;
-      }
-
-      .v-table__wrapper.hasScroll {
-        table > tbody > tr > td:last-child,
-        table > thead > tr > th:last-child {
-          border-left: 1px solid #e0e0e0 !important;
-          // box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, .35) !important;
-        }
-
-        table > thead > tr > th:last-child {
-          z-index: 10 !important;
-          // box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, .35) !important;
-          border-bottom: 1px solid #e0e0e0 !important;
-        }
-      }
+    :deep(.v-table__wrapper) { position: relative; }
+    :deep(.v-table__wrapper::-webkit-scrollbar:horizontal) { height: 8px; }
+    :deep(.v-table__wrapper:not(:hover)::-webkit-scrollbar:horizontal) { display: none; }
+    :deep(.v-table__wrapper:not(:hover), .v-table__wrapper::-webkit-scrollbar-thumb:horizontal) { background: transparent; }
+    :deep(table > tbody > tr > td:last-child) { 
+      position: sticky !important;
+      position: -webkit-sticky !important;
+      right: 0;
+      z-index: 1;
+      background: white !important;
+      box-shadow: none;
+    }
+    :deep(table > thead > tr > th:last-child) { 
+      position: sticky !important;
+      position: -webkit-sticky !important;
+      right: 0;
+      z-index: 1;
+      background: white !important;
+      box-shadow: none;
+    }
+    :deep(.v-table__wrapper.hasScroll table > tbody > tr > td:last-child) {
+      border-left: 1px solid #e0e0e0 !important;
+    }
+    :deep(.v-table__wrapper.hasScroll table > thead > tr > th:last-child) {
+      border-left: 1px solid #e0e0e0 !important;
     }
+    // :deep {
+    //   .v-table__wrapper {
+    //     position: relative;
+    //     &::-webkit-scrollbar:horizontal {
+    //       height: 8px;
+    //     }
+    //     &:not(:hover)::-webkit-scrollbar:horizontal {
+    //       display: none;
+    //     }
+    //   }
+
+    //   .v-table__wrapper:not(:hover), 
+    //   .v-table__wrapper::-webkit-scrollbar-thumb:horizontal {
+    //     background: transparent;
+    //   }
+
+    //   table > tbody > tr > td:last-child,
+    //   table > thead > tr > th:last-child {
+    //     position: sticky !important;
+    //     position: -webkit-sticky !important;
+    //     right: 0;
+    //     z-index: 1;
+    //     background: white !important;
+    //     box-shadow: none;
+    //   }
+
+    //   .v-table__wrapper.hasScroll {
+    //     table > tbody > tr > td:last-child,
+    //     table > thead > tr > th:last-child {
+    //       border-left: 1px solid #e0e0e0 !important;
+    //       // box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, .35) !important;
+    //     }
+
+    //     // table > thead > tr > th:last-child {
+    //     //   z-index: 10 !important;
+    //     //   // box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, .35) !important;
+    //     //   border-bottom: 1px solid #e0e0e0 !important;
+    //     // }
+    //   }
+    // }
   }
 </style>

+ 21 - 0
src/components/CtVuetify/CtSelect/index.vue

@@ -0,0 +1,21 @@
+<template>
+  <v-select v-bind="attr">
+    <template v-for="(item, key, i) in slots" :key="i" v-slot:[key]="data">
+      <slot v-if="key" :name="key" v-bind="data"></slot>
+    </template>
+  </v-select>
+</template>
+
+<script setup>
+import { useAttrs, useSlots } from 'vue'
+defineOptions({name: 'ct-select'})
+const attr = useAttrs()
+const slots = useSlots()
+// slots.forEach(e => {
+//   console.log(e)
+// })
+</script>
+
+<style lang="scss" scoped>
+
+</style>

+ 3 - 2
src/components/Enterprise/hotPromoted.vue

@@ -105,9 +105,10 @@ const handleClickPosition = (k) => {
 
 // 查看更多职位
 const handleMoreEnterprise = (item) => {
-  // if (!item.enterprise.id) return
+  if (!item.enterprise.id) return
   // window.open(`/recruit/personal/company/details/${item.enterprise.id}?key=recruitmentPositions`)
-  window.open(`/recruit/personal/position?content=${item.enterprise.anotherName || item.enterprise.name}`)
+  const name = formatName(item.enterprise.anotherName || item.enterprise.name)
+  window.open(`/recruit/personal/position?content=${name.includes('&') ? encodeURIComponent(name) : name}`)
 }
 </script>
 

+ 7 - 13
src/components/Position/item.vue

@@ -1,8 +1,11 @@
 <template>
   <div class="d-flex">
     <div class="position-box">
-      <div class="sub-li" v-for="(item, index) in props.items" :key="index" :style="{'height': tab === 3 && item.hire ? '180px' : '149px'}">
-        <div class="job-info" @click.stop="handlePosition(item)" @mouseenter="item.active = true" @mouseleave="item.active = false">
+      <div class="sub-li" v-for="(item, index) in props.items" :key="index" 
+        :style="{'height': tab === 3 && item.hire ? '180px' : '149px'}"
+        :class="item.active ? 'elevation-6' : 'elevation-3'"
+        @click.stop="handlePosition(item)" @mouseenter="item.active = true" @mouseleave="item.active = false">
+        <div class="job-info">
           <div class="sub-li-top">
             <div class="sub-li-info">
               <p :class="['name', {'default-active': item.active }]">{{ formatName(item.name) }}</p>
@@ -40,13 +43,13 @@
           </div>
           <div v-if="tab === 2" class="font-size-14 mb-3 text-end" style="color: #345768;">发布时间:{{ timesTampChange(item.createTime, 'Y-M-D h:m') }}</div>
         </div>
-        <div class="sub-li-bottom" @click.stop="jumpToEnterpriseDetail(item.enterpriseId, isOpenWindow)">
+        <div class="sub-li-bottom">
           <div class="user-info">
             <div class="d-flex align-center">
               <v-avatar size="35">
                 <v-img :src="item.logoUrl || 'https://minio.citupro.com/dev/menduner/company-avatar.png'" />
               </v-avatar>
-              <span v-ellipse-tooltip.top class="names ml-2 font-size-14 ellipsis" style="max-width: 88%;">
+              <span v-ellipse-tooltip.top class="names ml-2 font-size-14 text-truncate" style="max-width: 88%;">
                 {{ formatName(item.anotherName || item.enterpriseName) }}
                 <span class="color-999 font-size-13 ml-3">
                   <span>{{ item.industryName }}</span>
@@ -127,9 +130,6 @@ const handlePosition = (item) => {
   &:nth-child(3n) {
     margin-right: 0;
   }
-  &:hover {
-    box-shadow: 0 2px 12px 0 rgba(153, 153, 153, 1);
-  }
 }
 .job-info {
   position: relative;
@@ -200,15 +200,9 @@ const handlePosition = (item) => {
 .user-info {
   // display: flex;
   padding: 10px 20px 12px;
-  // align-items: center;
-  // justify-content: space-between;
-  background-color: #b2dfdb2b;
 }
 .names {
   font-weight: 500;
   color: #404040;
-  &:hover {
-    color: var(--v-error-base);
-  }
 }
 </style>

+ 1 - 1
src/components/PreviewImg/previewImage.vue

@@ -19,7 +19,7 @@
         </i>
       </span>
       <!-- ARROW -->
-      <template v-if="!isSingle">
+      <template v-if="urlList.length > 1">
         <span
           class="el-image-viewer__btn el-image-viewer__prev"
           :class="{ 'is-disabled': !infinite && isFirst }"

+ 6 - 10
src/components/headSearch/index.vue

@@ -37,8 +37,9 @@
         @click:clear="handleSearch"
         @keyup.enter="handleSearch"
       ></v-text-field>
-      <!-- <div class="searchBtn" @click="handleSearch">搜索</div> -->
-      <v-btn class="searchBtn" @click="handleSearch">搜索</v-btn>
+      <v-hover v-slot="{ isHovering, props }">
+        <v-btn v-bind="props" v-ripple.center class="searchBtn" @click="handleSearch" :class="isHovering ? 'elevation-10' : 'elevation-5'">搜索</v-btn>
+      </v-hover>
     </div>
   </div>
 </template>
@@ -66,12 +67,11 @@ const defineProps = defineProps({
     default: ''
   }
 })
-
 // const value = ref('')
 const value = ref(defineProps.modelValue)
 let drawer = ref(false)
 
-if (route.query && route.query?.content) value.value = route.query.content
+if (route.query && route.query?.content) value.value = route.query.content.includes('%') ? decodeURIComponent(route.query.content) : route.query.content
 
 // 点击外部关闭职位下拉
 const sharedState = useSharedState()
@@ -81,12 +81,8 @@ watch(() => sharedState.layoutClickCount, () => {
 });
 
 const handleSearch = () => {
-  // // 职位搜索页传参,其它的跳转到职位搜索页
-  // if (route.path !== '/recruit/personal/position') {
-  //   if (value.value) router.push(`/recruit/personal/position?content=${value.value}`)
-  //   else router.push('/recruit/personal/position')
-  // } else emits('handleSearch', value.value)
-  emits('handleSearch', value.value)
+  const name = value.value ? value.value.includes('&') ? encodeURIComponent(value.value) : value.value : ''
+  emits('handleSearch', name)
 }
 
 const handleClickJob = (val) => {

+ 40 - 2
src/router/modules/common.js

@@ -1,6 +1,6 @@
 // 公共路由(任何身份都可以访问的路由 如:商城)
 
-import headhunting from './components/headhunting'
+import Layout from '@/layout'
 
 const common = [
   {
@@ -102,7 +102,45 @@ const common = [
       hideSide: true
     }
   },
-  ...headhunting
+  {
+    path: '/headhunting',
+    component: Layout,
+    meta: {
+      title: '门墩儿猎寻服务'
+    },
+    children: [
+      {
+        path:'/headhunting',
+        component: () => import('@/views/headhunting/index.vue')
+      }
+    ]
+  },
+  {
+    path: '/headhunting/service',
+    component: Layout,
+    meta: {
+      title: '我们的服务'
+    },
+    children: [
+      {
+        path: '/headhunting/service',
+        component: () => import('@/views/headhunting/service.vue')
+      }
+    ]
+  },
+  {
+    path: '/headhunting/service/details',
+    component: Layout,
+    meta: {
+      title: '门墩儿猎寻服务'
+    },
+    children: [
+      {
+        path: '/headhunting/service/details',
+        component: () => import('@/views/headhunting/details.vue')
+      }
+    ]
+  }
 ]
 
 export default common

+ 0 - 45
src/router/modules/components/headhunting.js

@@ -1,45 +0,0 @@
-import Layout from '@/layout'
-
-const headhunting = [
-  {
-    path: '/headhunting',
-    component: Layout,
-    meta: {
-      title: '门墩儿猎寻服务'
-    },
-    children: [
-      {
-        path:'/headhunting',
-        component: () => import('@/views/headhunting/index.vue')
-      }
-    ]
-  },
-  {
-    path: '/headhunting/service',
-    component: Layout,
-    meta: {
-      title: '我们的服务'
-    },
-    children: [
-      {
-        path: '/headhunting/service',
-        component: () => import('@/views/headhunting/service.vue')
-      }
-    ]
-  },
-  {
-    path: '/headhunting/service/details',
-    component: Layout,
-    meta: {
-      title: '门墩儿猎寻服务'
-    },
-    children: [
-      {
-        path: '/headhunting/service/details',
-        component: () => import('@/views/headhunting/details.vue')
-      }
-    ]
-  }
-]
-
-export default headhunting

+ 10 - 0
src/router/modules/components/recruit/enterprise.js

@@ -273,6 +273,16 @@ const enterprise = [
       icon: 'mdi-cog-outline'
     },
     children: [
+      // 角色管理
+      {
+        path: '/recruit/enterprise/systemManagement/roleManagement',
+        meta: {
+          title: '角色管理',
+          enName: 'Role Management',
+          isAdmin: true // 企业管理员菜单
+        },
+        component: () => import('@/views/recruit/enterprise/systemManagement/roleManagement/index.vue')
+      },
       // 集团
       {
         path: '/recruit/enterprise/systemManagement/groupAccount',

+ 1 - 0
src/styles/index.css

@@ -8,6 +8,7 @@
   --v-primary-lighten2: #4DB6AC;
   --v-primary-lighten3: #80CBC4;
   --v-primary-lighten4: #B2DFDB;
+  --v-primary-lighten5: #b2dfdb9c;
   --color-222: #222;
   --color-333: #333;
   --color-666: #666;

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/styles/index.min.css


+ 4 - 6
src/styles/recruit/company.css

@@ -33,9 +33,9 @@
 
 .sub-li {
   position: relative;
-  width: calc((100% - 36px) / 4);
-  min-width: calc((100% - 36px) / 4);
-  max-width: calc((100% - 36px) / 4);
+  width: calc((100% - 24px) / 3);
+  min-width: calc((100% - 24px) / 3);
+  max-width: calc((100% - 24px) / 3);
   margin: 0 12px 12px 0;
   height: 160px;
   border-radius: 12px;
@@ -46,7 +46,7 @@
   cursor: pointer;
 }
 
-.sub-li:nth-child(4n) {
+.sub-li:nth-child(3n) {
   margin-right: 0;
 }
 
@@ -92,9 +92,7 @@
 }
 
 .company-info-bottom {
-  width: 100%;
   height: 70px;
-  padding: 10px 15px;
 }
 
 .name {

+ 1 - 1
src/styles/recruit/company.min.css

@@ -1 +1 @@
-.label-title{width:64px;font-weight:500;margin-right:24px;color:var(--color-222)}.label-content{flex:1}.label-color{color:var(--color-222);font-size:14px;margin-right:24px;display:inline-block;cursor:pointer}.label-color:hover{color:var(--v-primary-base)}.actives{color:var(--v-primary-base);font-weight:600}.company-box{display:flex;flex-wrap:wrap}.sub-li{position:relative;width:calc((100% - 36px) / 4);min-width:calc((100% - 36px) / 4);max-width:calc((100% - 36px) / 4);margin:0 12px 12px 0;height:160px;border-radius:12px;padding:0;overflow:hidden;transition:all .2s linear;background-color:#fff;cursor:pointer}.sub-li:nth-child(4n){margin-right:0}.sub-li:hover{box-shadow:0 16px 40px 0 rgba(153,153,153,0.3)}.company-info{float:left;margin-left:16px;width:282px}.company-info-top{display:flex;height:90px;line-height:90px;padding:0 20px;align-items:center;overflow:hidden}.company-info h3{height:22px;font-size:18px;font-weight:700;color:var(--color-333);line-height:22px;margin:0 0 4px 0;padding:0;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.company-info p{height:18px;font-size:13px;font-weight:400;color:var(--color-999);line-height:18px}.company-info-bottom{width:100%;height:70px;padding:10px 15px}.name{position:relative;line-height:22px;color:#404040;margin-right:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:all linear .2s}.salary{font-size:16px;float:right;color:var(--v-error-base);line-height:22px;max-width:none;text-align:right;flex:1}.job-hover:hover{color:var(--v-primary-base);background-color:#f2f4f7}
+.label-title{width:64px;font-weight:500;margin-right:24px;color:var(--color-222)}.label-content{flex:1}.label-color{color:var(--color-222);font-size:14px;margin-right:24px;display:inline-block;cursor:pointer}.label-color:hover{color:var(--v-primary-base)}.actives{color:var(--v-primary-base);font-weight:600}.company-box{display:flex;flex-wrap:wrap}.sub-li{position:relative;width:calc((100% - 24px) / 3);min-width:calc((100% - 24px) / 3);max-width:calc((100% - 24px) / 3);margin:0 12px 12px 0;height:160px;border-radius:12px;padding:0;overflow:hidden;transition:all .2s linear;background-color:#fff;cursor:pointer}.sub-li:nth-child(3n){margin-right:0}.sub-li:hover{box-shadow:0 16px 40px 0 rgba(153,153,153,0.3)}.company-info{float:left;margin-left:16px;width:282px}.company-info-top{display:flex;height:90px;line-height:90px;padding:0 20px;align-items:center;overflow:hidden}.company-info h3{height:22px;font-size:18px;font-weight:700;color:var(--color-333);line-height:22px;margin:0 0 4px 0;padding:0;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.company-info p{height:18px;font-size:13px;font-weight:400;color:var(--color-999);line-height:18px}.company-info-bottom{height:70px}.name{position:relative;line-height:22px;color:#404040;margin-right:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:all linear .2s}.salary{font-size:16px;float:right;color:var(--v-error-base);line-height:22px;max-width:none;text-align:right;flex:1}.job-hover:hover{color:var(--v-primary-base);background-color:#f2f4f7}

+ 6 - 6
src/styles/recruit/company.scss

@@ -32,9 +32,9 @@
 
 .sub-li {
   position: relative;
-  width: calc((100% - 36px) / 4);
-  min-width: calc((100% - 36px) / 4);
-  max-width: calc((100% - 36px) / 4);
+  width: calc((100% - 24px) / 3);
+  min-width: calc((100% - 24px) / 3);
+  max-width: calc((100% - 24px) / 3);
   margin: 0 12px 12px 0;
   height: 160px;
   border-radius: 12px;
@@ -43,7 +43,7 @@
   transition: all .2s linear;
   background-color: #fff;
   cursor: pointer;
-  &:nth-child(4n) {
+  &:nth-child(3n) {
     margin-right: 0;
   }
   &:hover {
@@ -84,9 +84,9 @@
   line-height: 18px;
 }
 .company-info-bottom {
-  width: 100%;
+  // width: 100%;
   height: 70px;
-  padding: 10px 15px;
+  // margin: 10px 15px;
 }
 .name {
   position: relative;

+ 1 - 1
src/utils/position.js

@@ -124,6 +124,6 @@ export const jumpToEnterpriseDetail = async (id, isOpenWindow = false, tabKey =
   if (isOpenWindow) {
     window.open(url)
   } else {
-    router.push({ path: url})
+    router.push({ path: url })
   }
 }

+ 403 - 0
src/utils/tree.ts

@@ -0,0 +1,403 @@
+interface TreeHelperConfig {
+  id: string
+  children: string
+  pid: string
+}
+
+type Fn = Function
+
+const DEFAULT_CONFIG: TreeHelperConfig = {
+  id: 'id',
+  children: 'children',
+  pid: 'pid'
+}
+export const defaultProps = {
+  children: 'children',
+  label: 'name',
+  value: 'id',
+  isLeaf: 'leaf',
+  emitPath: false // 用于 cascader 组件:在选中节点改变时,是否返回由该节点所在的各级菜单的值所组成的数组,若设置 false,则只返回该节点的值
+}
+
+const getConfig = (config: Partial<TreeHelperConfig>) => Object.assign({}, DEFAULT_CONFIG, config)
+
+// tree from list
+export const listToTree = <T = any>(list: any[], config: Partial<TreeHelperConfig> = {}): T[] => {
+  const conf = getConfig(config) as TreeHelperConfig
+  const nodeMap = new Map()
+  const result: T[] = []
+  const { id, children, pid } = conf
+
+  for (const node of list) {
+    node[children] = node[children] || []
+    nodeMap.set(node[id], node)
+  }
+  for (const node of list) {
+    const parent = nodeMap.get(node[pid])
+    ;(parent ? parent.children : result).push(node)
+  }
+  return result
+}
+
+export const treeToList = <T = any>(tree: any, config: Partial<TreeHelperConfig> = {}): T => {
+  config = getConfig(config)
+  const { children } = config
+  const result: any = [...tree]
+  for (let i = 0; i < result.length; i++) {
+    if (!result[i][children!]) continue
+    result.splice(i + 1, 0, ...result[i][children!])
+  }
+  return result
+}
+
+export const findNode = <T = any>(
+  tree: any,
+  func: Fn,
+  config: Partial<TreeHelperConfig> = {}
+): T | null => {
+  config = getConfig(config)
+  const { children } = config
+  const list = [...tree]
+  for (const node of list) {
+    if (func(node)) return node
+    node[children!] && list.push(...node[children!])
+  }
+  return null
+}
+
+export const findNodeAll = <T = any>(
+  tree: any,
+  func: Fn,
+  config: Partial<TreeHelperConfig> = {}
+): T[] => {
+  config = getConfig(config)
+  const { children } = config
+  const list = [...tree]
+  const result: T[] = []
+  for (const node of list) {
+    func(node) && result.push(node)
+    node[children!] && list.push(...node[children!])
+  }
+  return result
+}
+
+export const findPath = <T = any>(
+  tree: any,
+  func: Fn,
+  config: Partial<TreeHelperConfig> = {}
+): T | T[] | null => {
+  config = getConfig(config)
+  const path: T[] = []
+  const list = [...tree]
+  const visitedSet = new Set()
+  const { children } = config
+  while (list.length) {
+    const node = list[0]
+    if (visitedSet.has(node)) {
+      path.pop()
+      list.shift()
+    } else {
+      visitedSet.add(node)
+      node[children!] && list.unshift(...node[children!])
+      path.push(node)
+      if (func(node)) {
+        return path
+      }
+    }
+  }
+  return null
+}
+
+export const findPathAll = (tree: any, func: Fn, config: Partial<TreeHelperConfig> = {}) => {
+  config = getConfig(config)
+  const path: any[] = []
+  const list = [...tree]
+  const result: any[] = []
+  const visitedSet = new Set(),
+    { children } = config
+  while (list.length) {
+    const node = list[0]
+    if (visitedSet.has(node)) {
+      path.pop()
+      list.shift()
+    } else {
+      visitedSet.add(node)
+      node[children!] && list.unshift(...node[children!])
+      path.push(node)
+      func(node) && result.push([...path])
+    }
+  }
+  return result
+}
+
+export const filter = <T = any>(
+  tree: T[],
+  func: (n: T) => boolean,
+  config: Partial<TreeHelperConfig> = {}
+): T[] => {
+  config = getConfig(config)
+  const children = config.children as string
+
+  function listFilter(list: T[]) {
+    return list
+      .map((node: any) => ({ ...node }))
+      .filter((node) => {
+        node[children] = node[children] && listFilter(node[children])
+        return func(node) || (node[children] && node[children].length)
+      })
+  }
+
+  return listFilter(tree)
+}
+
+export const forEach = <T = any>(
+  tree: T[],
+  func: (n: T) => any,
+  config: Partial<TreeHelperConfig> = {}
+): void => {
+  config = getConfig(config)
+  const list: any[] = [...tree]
+  const { children } = config
+  for (let i = 0; i < list.length; i++) {
+    // func 返回true就终止遍历,避免大量节点场景下无意义循环,引起浏览器卡顿
+    if (func(list[i])) {
+      return
+    }
+    children && list[i][children] && list.splice(i + 1, 0, ...list[i][children])
+  }
+}
+
+/**
+ * @description: Extract tree specified structure
+ */
+export const treeMap = <T = any>(
+  treeData: T[],
+  opt: { children?: string; conversion: Fn }
+): T[] => {
+  return treeData.map((item) => treeMapEach(item, opt))
+}
+
+/**
+ * @description: Extract tree specified structure
+ */
+export const treeMapEach = (
+  data: any,
+  { children = 'children', conversion }: { children?: string; conversion: Fn }
+) => {
+  const haveChildren = Array.isArray(data[children]) && data[children].length > 0
+  const conversionData = conversion(data) || {}
+  if (haveChildren) {
+    return {
+      ...conversionData,
+      [children]: data[children].map((i: number) =>
+        treeMapEach(i, {
+          children,
+          conversion
+        })
+      )
+    }
+  } else {
+    return {
+      ...conversionData
+    }
+  }
+}
+
+/**
+ * 递归遍历树结构
+ * @param treeDatas 树
+ * @param callBack 回调
+ * @param parentNode 父节点
+ */
+export const eachTree = (treeDatas: any[], callBack: Fn, parentNode = {}) => {
+  treeDatas.forEach((element) => {
+    const newNode = callBack(element, parentNode) || element
+    if (element.children) {
+      eachTree(element.children, callBack, newNode)
+    }
+  })
+}
+
+/**
+ * 构造树型结构数据
+ * @param {*} data 数据源
+ * @param {*} id id字段 默认 'id'
+ * @param {*} parentId 父节点字段 默认 'parentId'
+ * @param {*} children 孩子节点字段 默认 'children'
+ */
+export const handleTree = (data: any[], id?: string, parentId?: string, children?: string) => {
+  if (!Array.isArray(data)) {
+    console.warn('data must be an array')
+    return []
+  }
+  const config = {
+    id: id || 'id',
+    parentId: parentId || 'parentId',
+    childrenList: children || 'children'
+  }
+
+  const childrenListMap = {}
+  const nodeIds = {}
+  const tree: any[] = []
+
+  for (const d of data) {
+    const parentId = d[config.parentId]
+    if (childrenListMap[parentId] == null) {
+      childrenListMap[parentId] = []
+    }
+    nodeIds[d[config.id]] = d
+    childrenListMap[parentId].push(d)
+  }
+
+  for (const d of data) {
+    const parentId = d[config.parentId]
+    if (nodeIds[parentId] == null) {
+      const { id, name, children } = d
+      tree.push({ id, name, children })
+    }
+  }
+
+  for (const t of tree) {
+    adaptToChildrenList(t)
+  }
+
+  function adaptToChildrenList(o) {
+    if (childrenListMap[o[config.id]] !== null) {
+      o[config.childrenList] = childrenListMap[o[config.id]]
+    }
+    if (o[config.childrenList]) {
+      for (const c of o[config.childrenList]) {
+        adaptToChildrenList(c)
+      }
+    }
+  }
+
+  return tree
+}
+
+/**
+ * 构造树型结构数据
+ * @param {*} data 数据源
+ * @param {*} id id字段 默认 'id'
+ * @param {*} parentId 父节点字段 默认 'parentId'
+ * @param {*} children 孩子节点字段 默认 'children'
+ * @param {*} rootId 根Id 默认 0
+ */
+// @ts-ignore
+export const handleTree2 = (data, id, parentId, children, rootId) => {
+  id = id || 'id'
+  parentId = parentId || 'parentId'
+  // children = children || 'children'
+  rootId =
+    rootId ||
+    Math.min(
+      ...data.map((item) => {
+        return item[parentId]
+      })
+    ) ||
+    0
+  // 对源数据深度克隆
+  const cloneData = JSON.parse(JSON.stringify(data))
+  // 循环所有项
+  const treeData = cloneData.filter((father) => {
+    const branchArr = cloneData.filter((child) => {
+      // 返回每一项的子级数组
+      return father[id] === child[parentId]
+    })
+    branchArr.length > 0 ? (father.children = branchArr) : ''
+    // 返回第一层
+    return father[parentId] === rootId
+  })
+  return treeData !== '' ? treeData : data
+}
+
+/**
+ * 校验选中的节点,是否为指定 level
+ *
+ * @param tree 要操作的树结构数据
+ * @param nodeId 需要判断在什么层级的数据
+ * @param level 检查的级别, 默认检查到二级
+ * @return true 是;false 否
+ */
+export const checkSelectedNode = (tree: any[], nodeId: any, level = 2): boolean => {
+  if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) {
+    console.warn('tree must be an array')
+    return false
+  }
+
+  // 校验是否是一级节点
+  if (tree.some((item) => item.id === nodeId)) {
+    return false
+  }
+
+  // 递归计数
+  let count = 1
+
+  // 深层次校验
+  function performAThoroughValidation(arr: any[]): boolean {
+    count += 1
+    for (const item of arr) {
+      if (item.id === nodeId) {
+        return true
+      } else if (typeof item.children !== 'undefined' && item.children.length !== 0) {
+        if (performAThoroughValidation(item.children)) {
+          return true
+        }
+      }
+    }
+    return false
+  }
+
+  for (const item of tree) {
+    count = 1
+    if (performAThoroughValidation(item.children)) {
+      // 找到后对比是否是期望的层级
+      if (count >= level) {
+        return true
+      }
+    }
+  }
+
+  return false
+}
+
+/**
+ * 获取节点的完整结构
+ * @param tree 树数据
+ * @param nodeId 节点 id
+ */
+export const treeToString = (tree: any[], nodeId) => {
+  if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) {
+    console.warn('tree must be an array')
+    return ''
+  }
+  // 校验是否是一级节点
+  const node = tree.find((item) => item.id === nodeId)
+  if (typeof node !== 'undefined') {
+    return node.name
+  }
+  let str = ''
+
+  function performAThoroughValidation(arr) {
+    for (const item of arr) {
+      if (item.id === nodeId) {
+        str += ` / ${item.name}`
+        return true
+      } else if (typeof item.children !== 'undefined' && item.children.length !== 0) {
+        str += ` / ${item.name}`
+        if (performAThoroughValidation(item.children)) {
+          return true
+        }
+      }
+    }
+    return false
+  }
+
+  for (const item of tree) {
+    str = `${item.name}`
+    if (performAThoroughValidation(item.children)) {
+      break
+    }
+  }
+  return str
+}

+ 1 - 1
src/version.js

@@ -1,2 +1,2 @@
 // 版本号
-export const vue_version = 'v25.01.17.1827'
+export const vue_version = 'v25.01.17.1849'

+ 75 - 0
src/views/recruit/enterprise/systemManagement/groupAccount/index.vue

@@ -62,6 +62,7 @@
             <v-btn v-if="item.userType === '0'" color="primary" variant="text" @click="handleEdit(item)">编辑</v-btn>
             <v-btn v-if="item.status === '1' && item.userType !== '1'" color="primary" variant="text" @click="handleAction('', 0, item)">{{ $t('enterprise.userManagement.enable') }}</v-btn>
             <v-btn v-if="item.status === '0' && item.userType !== '1'" color="primary" variant="text" @click="handleAction('', 1, item)">{{ $t('enterprise.userManagement.disable') }}</v-btn>
+            <v-btn color="primary" variant="text" @click="handleRole(item)">分配角色</v-btn>
           </template>
         </CtTable>
       </v-col>
@@ -93,6 +94,11 @@
     </CtForm>
   </CtDialog>
 
+
+  <CtDialog :visible="showRole" :widthType="2" titleClass="text-h6" title="分配角色" @close="showRole = false; roleItem = null" @submit="handleSubmitRole">
+    <CtForm v-loading="roleLoading" ref="CtFormRef" class="mt-3" :items="roleForm"></CtForm>
+  </CtDialog>
+
   <ImgCropper :visible="isShowCopper" :image="selectPic" :cropBoxResizable="true" @submit="handleHideCopper" :aspectRatio="1 / 1" @close="isShowCopper = false"></ImgCropper>
 </template>
 
@@ -112,6 +118,12 @@ import { uploadFile } from '@/api/common'
 import { getToken } from '@/utils/auth'
 import { formatName } from '@/utils/getText';
 
+import {
+  getRoleList,
+  getRolePageSimple,
+  saveUserRole
+} from '@/api/recruit/enterprise/system/role'
+
 const { t } = useI18n()
 const showBadge = ref(false) // 性别设置甲方要求已去掉
 const total = ref(0)
@@ -149,6 +161,42 @@ const textItem = ref({
 //   return (item && item.sex) ? (item.sex === '1' ? 'mdi-gender-male' : 'mdi-gender-female') : 'mdi-gender-female'
 // })
 
+
+// 角色分配
+const showRole = ref(false)
+const roleLoading = ref(false)
+const roleItem = ref(null)
+const roleForm = ref({
+  options: [
+    {
+      type: 'text',
+      key: 'name',
+      value: null,
+      label: t('login.username'),
+      disabled: true
+    },
+    {
+      type: 'text',
+      key: 'enterpriseAnotherName',
+      value: null,
+      label: t('enterprise.userManagement.affiliatedEnterprise'),
+      disabled: true
+    },
+    {
+      type: 'autocomplete',
+      key: 'roleIds',
+      value: [],
+      label: '角色选择',
+      multiple: true,
+      itemText: 'name',
+      itemValue: 'id',
+      items: []
+    }
+  ]
+})
+
+
+
 // 获取用户列表
 const getUserList = async () => {
   loading.value = true
@@ -329,6 +377,33 @@ const handleHideCopper = (data) => {
   }
 }
 
+const handleRole = async (item) => {
+  roleItem.value = item
+  showRole.value = true
+  const list = await getRolePageSimple()
+  const data = await getRoleList({ bindId: item.id })
+  roleForm.value.options.forEach(e => {
+    if (e.key === 'roleIds') {
+      e.items = list
+      e.value = data
+      return
+    }
+    e.value = item[e.key]
+  })
+} 
+
+const handleSubmitRole = async () => {
+  roleLoading.value = true
+  try {
+    await saveUserRole({ bindId: roleItem.value.id, roleIds: roleForm.value.options.find(e => e.key === 'roleIds').value })
+    Snackbar.success('保存成功')
+    showRole.value = false
+  } finally {
+    roleLoading.value = false
+  }
+}
+
+
 // 提交
 const handleSubmit = async () => {
   const { valid } = await CtFormRef.value.formRef.validate()

+ 175 - 0
src/views/recruit/enterprise/systemManagement/roleManagement/components/DataPermission.vue

@@ -0,0 +1,175 @@
+<template>
+  <div>
+    <div class="mb-3">角色名称:<v-chip color="primary" label>{{ props.editItem.name }}</v-chip></div>
+    <div class="mb-3">角色标识:<v-chip color="primary" label>{{ props.editItem.code }}</v-chip></div>
+    <div class="mb-3 d-flex align-center">权限范围:
+      <v-select
+        :items="items"
+        variant="outlined"
+        density="compact"
+        placeholder="请选择权限范围"
+        label="权限范围"
+        color="primary"
+        item-title="label"
+        item-value="value"
+        hide-details
+        v-model="selected"
+        @update:modelValue="handleChangeSelect"
+      ></v-select>
+    </div>
+    <div v-if="show">
+      <v-card style="overflow: auto;" class="pa-3" elevation="5"  max-height="500" :loading="deptLoading">
+        <div class="pa-3 d-flex">
+          <div class="d-flex align-center mr-5">
+            <div class="mr-3">全选 / 全不选</div>
+            <v-switch
+              v-model="choose"
+              color="primary"
+              hide-details
+              @update:modelValue="handleChoose"
+            ></v-switch>
+          </div>
+          <div class="d-flex align-center mr-5">
+            <div class="mr-3">全部展开 / 折叠</div>
+            <v-switch
+              v-model="deptExpand"
+              color="primary"
+              hide-details
+              @update:modelValue="handleExpand"
+            ></v-switch>
+          </div>
+          <div class="d-flex align-center">
+            <div class="mr-3">父子联动</div>
+            <v-switch
+              v-model="checkStrictly"
+              color="primary"
+              hide-details
+            ></v-switch>
+          </div>
+        </div>
+        <v-divider></v-divider>
+        <div class="pa-3">
+          <el-tree
+            ref="treeRef"
+            :check-strictly="!checkStrictly"
+            :data="deptOptions"
+            :props="defaultProps"
+            default-expand-all
+            empty-text="没有数据"
+            node-key="id"
+            show-checkbox
+          />
+        </div>
+      </v-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, nextTick } from 'vue'
+import { getDict } from '@/hooks/web/useDictionaries'
+import { getDataMenuTree } from '@/api/recruit/enterprise/system/role'
+// import { handleTree } from '@/utils/tree.ts'
+const props = defineProps({
+  editItem: {
+    type: Object,
+    default: null
+  }
+})
+
+const items = ref([])
+const selected = ref(null)
+const show = ref(false)
+const treeRef = ref()
+
+const deptOptions = ref([])
+const deptLoading = ref(false)
+const defaultProps = ref({
+  children: 'children',
+  label: 'name',
+  value: 'id',
+  isLeaf: 'leaf',
+  class: 'nodes',
+  emitPath: false // 用于 cascader 组件:在选中节点改变时,是否返回由该节点所在的各级菜单的值所组成的数组,若设置 false,则只返回该节点的值
+})
+
+const choose = ref(false)
+const deptExpand = ref(true)
+const checkStrictly = ref(true)
+
+/** 全选 */
+const handleChoose = (val) => {
+  treeRef.value.setCheckedNodes(choose.value ? deptOptions.value : [])
+}
+/** 展开/折叠全部 */
+const handleExpand = (val) => {
+  const nodes = treeRef.value?.store.nodesMap
+  for (let node in nodes) {
+    if (nodes[node].expanded === deptExpand.value) {
+      continue
+    }
+    nodes[node].expanded = deptExpand.value
+  }
+}
+
+
+const getTypeDict = async () => {
+  const { data } = await getDict('mde_data_scope', null)
+  items.value = data
+  init()
+}
+
+const init = async () => {
+
+  // 重置
+  deptExpand.value = true
+  checkStrictly.value = true
+  deptLoading.value = true
+  deptOptions.value = []
+  selected.value = props.editItem.dataScope + ''
+  handleChangeSelect()
+}
+
+// 选中
+const handleChangeSelect = async () => {
+  show.value = selected.value === '2'
+  if (show.value) {
+    choose.value = false
+    try {
+      const data = await getDataMenuTree()
+      deptOptions.value = [data]
+      await nextTick()
+      const dataScopeEnterpriseIds = props.editItem.dataScopeEnterpriseIds || []
+      dataScopeEnterpriseIds.forEach((deptId) => {
+        treeRef.value.setChecked(deptId, true, false)
+      })
+    } finally {
+      deptLoading.value = false
+    }
+  }
+}
+
+
+
+
+getTypeDict()
+
+const submit = () => {
+  return {
+    roleId: props.editItem.id,
+    dataScope: selected.value,
+    dataScopeEnterpriseIds: selected.value !== '2' ? [] : treeRef.value.getCheckedKeys(false)
+  }
+}
+
+defineExpose({
+  submit
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.nodes) {
+  font-size: 16px;
+  padding: 5px 0;
+}
+</style>

+ 108 - 0
src/views/recruit/enterprise/systemManagement/roleManagement/components/MenuPermission.vue

@@ -0,0 +1,108 @@
+<template>
+  <div>
+    <div class="mb-3">角色名称:<v-chip color="primary" label>{{ props.editItem.name }}</v-chip></div>
+    <div class="mb-3">角色标识:<v-chip color="primary" label>{{ props.editItem.code }}</v-chip></div>
+    <div class="d-flex">
+      <div class="mr-3">菜单权限: </div>
+      <v-card style="flex: 1; overflow: auto;" class="pa-3" elevation="5" max-height="500">
+        <div class="pa-3 d-flex">
+          <div class="d-flex align-center mr-5">
+            <div class="mr-3">全选 / 全不选</div>
+            <v-switch
+              v-model="choose"
+              color="primary"
+              hide-details
+              @change="handleChoose"
+            ></v-switch>
+          </div>
+          <div class="d-flex align-center">
+            <div class="mr-3">全部展开 / 折叠</div>
+            <v-switch
+              v-model="all"
+              color="primary"
+              hide-details
+              @change="handleOpen"
+            ></v-switch>
+          </div>
+        </div>
+        <v-divider></v-divider>
+        <div class="pa-3">
+          <el-tree
+            ref="treeRef"
+            :data="items"
+            :props="defaultProps"
+            empty-text="加载中,请稍候"
+            node-key="id"
+            show-checkbox
+          />
+        </div>
+      </v-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { getMenu, getRoleMenu } from '@/api/recruit/enterprise/system/role'
+import { handleTree } from '@/utils/tree.ts'
+const props = defineProps({
+  editItem: {
+    type: Object,
+    default: () => ({})
+  }
+})
+const defaultProps = {
+  children: 'children',
+  label: 'name',
+  value: 'id',
+  isLeaf: 'leaf',
+  class: 'nodes',
+  emitPath: false // 用于 cascader 组件:在选中节点改变时,是否返回由该节点所在的各级菜单的值所组成的数组,若设置 false,则只返回该节点的值
+}
+
+const items = ref([])
+const all = ref(false)
+const choose = ref(false)
+const itemsOrigin = ref([])
+
+const treeRef = ref()
+
+
+const handleChoose = () => {
+  treeRef.value.setCheckedNodes(choose.value ? items.value : [])
+}
+
+const handleOpen = () => {
+  const nodes = treeRef.value?.store.nodesMap
+  for (let node in nodes) {
+    if (nodes[node].expanded === all.value) {
+      continue
+    }
+    nodes[node].expanded = all.value
+  }
+}
+
+getMenu().then(res => {
+  items.value.push(...handleTree(res))
+  itemsOrigin.value = res
+  getRoleMenu({ roleId: props.editItem.id }).then(data => {
+    data.forEach(menuId => {
+      treeRef.value.setChecked(menuId, true, false)
+    })
+  })
+})
+
+defineExpose({
+  treeRef
+})
+
+
+</script>
+
+<style lang="scss" scoped>
+
+:deep(.nodes) {
+  font-size: 16px;
+  padding: 5px 0;
+}
+</style>

+ 103 - 0
src/views/recruit/enterprise/systemManagement/roleManagement/components/RoleManagementEdit.vue

@@ -0,0 +1,103 @@
+<template>
+  <CtForm ref="CtFormRef" :items="formItems"></CtForm>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { getDict } from '@/hooks/web/useDictionaries'
+import {
+  addRole,
+  updateRole
+} from '@/api/recruit/enterprise/system/role'
+import Snackbar from '@/plugins/snackbar'
+
+const props = defineProps({
+  editItem: {
+    type: Object,
+    default: null
+  }
+})
+
+const formItems = ref({
+  options: [
+    {
+      type: 'text',
+      key: 'name',
+      value: null,
+      label: '角色名称 *',
+      rules: [v => !!v || '请输入角色名称']
+    },
+    {
+      type: 'text',
+      key: 'code',
+      value: null,
+      label: '角色标识 *',
+      rules: [v => !!v || '请输入角色标识']
+    },
+    {
+      type: 'text',
+      key: 'sort',
+      value: null,
+      label: '角色顺序 *',
+      rules: [v => !!v || '请输入角色顺序']
+    },
+    {
+      type: 'text',
+      key: 'remark',
+      value: null,
+      label: '备注'
+    },
+    {
+      type: 'ifRadio',
+      key: 'status',
+      value: '0',
+      label: '角色状态 *',
+      items: [],
+      rules: [v => !!v || '请选择角色状态']
+    }
+  ]
+})
+
+if (props.editItem) {
+  formItems.value.options.forEach(e => {
+    e.value = props.editItem[e.key]
+  })
+}
+
+const getStatusDict = async () => {
+  const { data } = await getDict('menduner_status', null)
+  formItems.value.options.find(e => e.key === 'status').items = data || []
+}
+
+const submit = async () => {
+  const obj = formItems.value.options.reduce((res, cur) => {
+    res[cur.key] = cur.value
+    return res
+  }, {})
+
+  if (props.editItem) {
+    obj.id = props.editItem.id
+  }
+
+  const subApi = props.editItem ? updateRole : addRole
+  return new Promise((resolve, reject) => {
+    subApi(obj).then(data => {
+      resolve(data)
+      Snackbar.success(props.editItem ? '保存成功' : '提交成功' )
+    }).catch(err => {
+      Snackbar.error(err.msg)
+    })
+  })
+}
+
+
+getStatusDict()
+
+defineExpose({
+  submit
+})
+</script>
+
+<style lang="scss" scoped>
+
+</style>

+ 240 - 0
src/views/recruit/enterprise/systemManagement/roleManagement/index.vue

@@ -0,0 +1,240 @@
+<template>
+  <v-card class="pa-5 card-box">
+    <div class="d-flex justify-space-between">
+      <div class="d-flex mb-3">
+        <CtTextField class="mr-3" v-model="query.name" v-bind="nameItem"></CtTextField>
+        <CtTextField class="mr-3" v-model="query.code" v-bind="codeItem"></CtTextField>
+        <Autocomplete v-model="query.status" :item="statusItem"></Autocomplete>
+        <v-btn color="primary" class="half-button ml-3" @click="handleSearch">查 询</v-btn>
+        <v-btn class="half-button mx-3" variant="outlined" color="primary" @click="handleReset">重 置</v-btn>
+      </div>
+    </div>
+    <CtTable
+      class="mt-3"
+      :items="items"
+      :headers="headers"
+      :loading="loading"
+      :showPage="true"
+      :total="total"
+      :page-info="pageInfo"
+      itemKey="id"
+      @add="handleAdd"
+      @pageHandleChange="handleChangePage"
+    >
+      <template #status="{ item }">
+        <v-chip :color="item.status === '0' ? 'success' : 'error'" size="small" label>{{ item.statusName }}</v-chip>
+      </template>
+      <template #actions="{ item }">
+        <v-btn variant="text" color="primary" @click="handleEdit(item)">编辑</v-btn>
+        <v-btn variant="text" color="info" @click="handleMenu(item)">菜单权限</v-btn>
+        <v-btn variant="text" color="info" @click="handleData(item)">数据权限</v-btn>
+        <v-btn variant="text" color="error" @click="handleDel(item)">删除</v-btn>
+      </template>
+    </CtTable>
+  </v-card>
+  <CtDialog :visible="show" :widthType="3" :title="editItem ? '编辑角色' : '新增角色'" submitText="确认" @close="show = false" @submit="handleSubmit">
+    <RoleManagementEdit v-if="show" ref="roleManagementEdit" :edit-item="editItem"></RoleManagementEdit>
+  </CtDialog>
+  <CtDialog :visible="showMenu" :widthType="3" title="菜单权限" submitText="确认" @close="showMenu = false" @submit="handleSubmitMenu">
+    <MenuPermission v-if="showMenu" ref="menuPermissionRef" :edit-item="menuItem" v-loading="loadingMenu"></MenuPermission>
+  </CtDialog>
+  <CtDialog :visible="showData" :widthType="3" title="菜单权限" submitText="确认" @close="showData = false" @submit="handleSubmitData">
+    <DataPermission v-if="showData" ref="dataPermissionRef" :edit-item="dataItem" v-loading="loadingData"></DataPermission>
+  </CtDialog>
+</template>
+
+<script setup>
+defineOptions({ name: 'system-management-role'})
+import { ref } from 'vue'
+
+import { useI18n } from '@/hooks/web/useI18n'
+import Confirm from '@/plugins/confirm'
+import Snackbar from '@/plugins/snackbar'
+import RoleManagementEdit from './components/RoleManagementEdit'
+import MenuPermission from './components/MenuPermission'
+import DataPermission from './components/DataPermission'
+
+import {
+  getRolePage,
+  deleteRole,
+  saveRoleMenu,
+  saveDataPermission
+} from '@/api/recruit/enterprise/system/role'
+import { getDict } from '@/hooks/web/useDictionaries'
+import { formatDate } from '@/utils/date'
+
+
+const { t } = useI18n()
+const total = ref(10)
+const items = ref([])
+const show = ref(false)
+const loading = ref(false)
+const editItem = ref(null)
+const roleManagementEdit = ref()
+
+// 菜单权限
+const showMenu = ref(false)
+const loadingMenu = ref(false)
+const menuItem = ref(null)
+const menuPermissionRef = ref()
+
+// 数据权限
+const showData = ref(false)
+const dataItem = ref(null)
+const loadingData = ref(false)
+const dataPermissionRef = ref()
+
+const nameItem = ref({
+  width: 300,
+  clearable: true,
+  hireDetails: true,
+  label: '角色名称',
+  variant: 'outlined',
+  density: 'compact',
+  color: 'primary',
+  placeholder: '请输入角色名称'
+})
+
+const codeItem = ref({
+  width: 300,
+  clearable: true,
+  hireDetails: true,
+  label: '角色标识',
+  variant: 'outlined',
+  density: 'compact',
+  color: 'primary',
+  placeholder: '请输入角色标识'
+})
+const statusItem = ref({ width: 300, items: [], clearable: true, hireDetails: true, label: '状态', placeholder: '请选择状态' })
+
+const query = ref({
+  name: null,
+  status: null,
+  code: null
+})
+const pageInfo = ref({
+  pageNo: 1,
+  pageSize: 10
+})
+
+const headers = [
+  { title: '角色编号', key: 'id', align: 'left' },
+  { title: '角色名称', key: 'name' },
+  { title: '角色标识', key: 'code' },
+  { title: '显示顺序', key: 'sort', align: 'center' },
+  { title: '备注', key: 'remark' },
+  { title: '状态', key: 'status', align: 'center' },
+  { title: '创建时间', key: 'createTime', value: (e) => formatDate(e.createTime, 'YYYY-MM-DD HH:mm:ss') },
+  { title: t('common.actions'), key: 'actions' }
+]
+// 字典
+const getStatusDict = async () => {
+  const { data } = await getDict('menduner_status', null)
+  statusItem.value.items = data || []
+  getPage()
+}
+// 列表
+const getPage = async () => {
+  loading.value = true
+  try {
+    const res = await getRolePage({ ...pageInfo.value, ...query.value })
+    items.value = res.list.map(e => {
+      e.statusName = statusItem.value.items.find(_e => _e.value === e.status)?.label ?? ''
+      return e
+    })
+    total.value = res.total
+  } finally {
+    loading.value = false
+  }
+}
+// 检索
+const handleSearch = () => {
+  pageInfo.value.pageNo = 1
+  getPage()
+}
+// 重置
+const handleReset = () => {
+  query.value = {
+    name: null,
+    status: null,
+    code: null
+  }
+  handleSearch()
+}
+// 新增
+const handleAdd = () => {
+  editItem.value = null
+  show.value = true
+}
+// 编辑
+const handleEdit = (item) => {
+  editItem.value = item
+  show.value = true
+}
+// 删除
+const handleDel = async (item) => {
+  Confirm(t('common.confirmTitle'), '是否确定删除?').then(async () => {
+    await deleteRole({ id: item.id })
+    Snackbar.success('删除成功')
+    getPage()
+  })
+}
+
+// 菜单权限
+const handleMenu = (item) => {
+  menuItem.value = item
+  showMenu.value = true
+}
+// 数据权限
+const handleData = (item) => {
+  dataItem.value = item
+  showData.value = true
+}
+// 分页
+const handleChangePage = (index) => {
+  pageInfo.value.pageNo = index
+  getPage()
+}
+
+// 保存角色
+const handleSubmit = async () => {
+  await roleManagementEdit.value.submit()
+  show.value = false
+  getPage()
+}
+
+// 保存菜单权限
+const handleSubmitMenu = async () => {
+  loadingMenu.value = true
+  try {
+    await saveRoleMenu({roleId: menuItem.value.id, menuIds: [
+      ...(menuPermissionRef.value.treeRef.getCheckedKeys(false)), // 获得当前选中节点
+      ...(menuPermissionRef.value.treeRef.getHalfCheckedKeys()) // 获得半选中的父节点
+    ]})
+    Snackbar.success('保存成功')
+    showMenu.value = false
+    getPage()
+  } finally {
+    loadingMenu.value = false
+  }
+}
+// 保存数据权限
+const handleSubmitData = async () => {
+  loadingData.value = true
+  try {
+    await saveDataPermission(dataPermissionRef.value.submit())
+    Snackbar.success('保存成功')
+    showData.value = false
+    getPage()
+  } finally {
+    loadingData.value = false
+  }
+}
+
+getStatusDict()
+
+</script>
+
+<style scoped lang="scss">
+
+</style>

+ 3 - 4
src/views/recruit/personal/company/components/companyItem.vue

@@ -6,15 +6,15 @@
           <v-img :src="item.enterprise.logoUrl || 'https://minio.citupro.com/dev/menduner/company-avatar.png'" :alt="item.enterprise.anotherName" :width="40" style="height: 40px;border-radius: 4px;"/>
         </div>
         <div class="company-info">
-          <h3 :class="{'default-active': item.active }" style="width: 200px;">{{ formatName(item.enterprise.anotherName || item.enterprise.name) }}</h3>
+          <h3 :class="{'default-active': item.active }">{{ formatName(item.enterprise.anotherName || item.enterprise.name) }}</h3>
           <p>{{ item.enterprise.industryName }}</p>
         </div>
       </div>
       <v-divider class="mx-4"></v-divider>
-      <div class="company-info-bottom">
+      <div class="company-info-bottom mx-4 mt-2">
         <div v-if="item?.job && Object.keys(item.job).length" class="job-hover" @click.stop="handleClickPosition(item.job)">
           <div class="mb-1 d-flex">
-            <p :class="['mr-3', 'cursor-pointer', 'name']" :style="{'max-width': !item.job.payFrom && !item.job.payTo ? '200px' : '120px'}">{{ formatName(item.job.name) }}</p>
+            <p :class="['mr-3', 'cursor-pointer', 'name']" :style="{'max-width': !item.job.payFrom && !item.job.payTo ? '270px' : '220px'}">{{ formatName(item.job.name) }}</p>
             <span v-if="!item.job.payFrom && !item.job.payTo" class="salary">面议</span>
             <span v-else class="salary">{{ item.job.payFrom ? item.job.payFrom + '-' : '' }}{{ item.job.payTo }}{{ item.job.payName ? '/' + item.job.payName : '' }}</span>
           </div>
@@ -22,7 +22,6 @@
             <span v-for="(j, index) in desc" :key="index">
               <span v-if="item.job[j] || j === 'areaName'" class="mr-1 font-size-13">
                 {{ j === 'areaName' ? !item.job.areaId ? '全国' : item.job.area.str : item.job[j] }}
-                <!-- {{ (j === 'areaName' && !item.job.areaId) ? '全国' : item.job[j] }} -->
               </span>
               <span v-if="index !== desc.length - 1 && (item.job[desc[index + 1]] || j === 'areaName')" class="septal-line ml-1"></span>
             </span>

+ 1 - 1
src/views/recruit/personal/company/index.vue

@@ -48,7 +48,7 @@ const total = ref(0)
 const items = ref([])
 const pages = ref({
   pageNo: 1,
-  pageSize: 12
+  pageSize: 9
 })
 const query = ref({})
 

+ 1 - 1
src/views/recruit/personal/companyDetail/components/positions.vue

@@ -13,7 +13,7 @@
     </div>
     <div class="d-flex mt-1 justify-space-between">
       <conditionFilter v-if="show" ref="conditionFilterRef" :showFilterList="showFilterList" @reset="handleReset" @change="handleQueryChange"></conditionFilter>
-      <div style="width: 200px;" class="mt-2">
+      <div style="width: 220px;" class="mt-2">
         <v-text-field
           v-model="query.content"
           variant="outlined" 

+ 4 - 4
src/views/recruit/personal/home/components/hotJobs.vue

@@ -1,15 +1,15 @@
 <template>
   <div class="default-width mb-6 d-flex align-center justify-center">
-    <span class="mr-2 color-primary font-weight-bold" style="width: 80px; min-width: 80px;">{{ $t('position.popularPosition') }}:</span>
-    <div style="overflow: hidden; height: 40px; ">
+    <span class="color-primary font-weight-bold" style="width: 80px; min-width: 80px;">{{ $t('position.popularPosition') }}:</span>
+    <div class="overflow-hidden py-3 pl-2" style="height: 60px; ">
       <v-hover v-slot="{ isHovering, props }" v-for="(item, index) in jobs" :key="index">
         <span 
           v-bind="props"
           v-ripple.center
           label
           size="small"
-          class="mr-2 my-1 tag" 
-          :class="isHovering ? 'elevation-1' : ''"
+          class="mr-2 mb-4 tag" 
+          :class="isHovering ? 'elevation-5' : 'elevation-1'"
           @click.stop="handleClick(item)"
         >
           {{ item.nameCn }}

+ 5 - 3
src/views/recruit/personal/home/components/hotPromotedPositions.vue

@@ -11,14 +11,16 @@
       <v-tab :value="2">{{ $t('position.latest') }}</v-tab>
       <v-tab :value="3">{{ $t('position.hire') }}</v-tab>
     </v-tabs>
-    <v-window v-model="tab" class="mt-5">
+    <v-window v-model="tab" class="pt-5 px-2 pb-2">
       <v-window-item v-for="v in 3" :value="v" :key="v">
         <PositionCard v-if="items.filter(Boolean) && items.length" :isOpenWindow="false" :items="items" :tab="tab" @position="handlePosition"></PositionCard>
         <Empty v-else class="mb-3" :elevation="false"></Empty>
       </v-window-item>
     </v-window>
-    <div class="text-center mt-5" style="border-top: 1px solid #ccc; padding-top: 30px;">
-      <v-btn class="buttons btnColor" elevation="5" @click.stop="handleToMore">{{ $t('position.moreBtn') }}</v-btn>
+    <div class="text-center mt-4" style="border-top: 1px solid #ccc; padding-top: 30px;">
+      <v-hover v-slot="{ isHovering, props }">
+        <v-btn v-bind="props" v-ripple.center class="buttons btnColor" :class="isHovering ? 'elevation-10' : 'elevation-5'" @click.stop="handleToMore">{{ $t('position.moreBtn') }}</v-btn>
+      </v-hover>
     </div>
   </div>
 </template>

+ 3 - 1
src/views/recruit/personal/home/components/popularEnterprises.vue

@@ -9,7 +9,9 @@
     <HotPromoted v-if="items.length" class="mt-5" :items="items"></HotPromoted>
     <Empty v-else :elevation="false" class="mt-3" message="暂无精选企业"></Empty>
     <div v-if="items.length" class="text-center">
-      <v-btn class="buttons btnColor" elevation="5" color="primary" @click.stop="handleToMore">{{ $t('enterprise.moreBtn') }}</v-btn>
+      <v-hover v-slot="{ isHovering, props }">
+        <v-btn v-bind="props" v-ripple.center class="buttons btnColor" :class="isHovering ? 'elevation-10' : 'elevation-5'" @click.stop="handleToMore">{{ $t('enterprise.moreBtn') }}</v-btn>
+      </v-hover>
     </div>
   </div>
 </template>

+ 17 - 7
src/views/recruit/personal/position/components/details.vue

@@ -174,6 +174,7 @@ import { checkPersonBaseInfo } from '@/utils/check'
 import dialogExtend from '@/plugins/dialogExtend'
 import { formatName } from '@/utils/getText'
 
+const emit = defineEmits(['preview'])
 const props = defineProps({
   defaultWidth: {
     type: Boolean,
@@ -191,6 +192,11 @@ const props = defineProps({
     type: [String, Number],
     default: ''
   },
+  // 是否为推荐职位引用
+  isRecommend: {
+    type: Boolean,
+    default: false
+  }
 })
 
 const { t } = useI18n()
@@ -259,6 +265,13 @@ const cleanedHtml = (text) => {
   return cleaned
 }
 
+// 职位详情分享图片下载文件名
+const fileName = computed(() => {
+  const { name, areaName, payFrom, payTo } = info.value
+  const salary = payFrom && payTo ? `${payFrom ? '_' + payFrom + '-' : ''}${payTo}` : '-面议'
+  return `${name}${areaName ? '_' + areaName : ''}${salary}${positionInfo.value.payName ? '-' + positionInfo.value.payName : ''}`
+})
+
 const share = ref()
 // 生成图片
 const generateAndDownloadImage = async () => {
@@ -269,6 +282,10 @@ const generateAndDownloadImage = async () => {
     const image = canvas.toDataURL().replace(/^data:image\/(png|jpg);base64,/, '')
     previewSrc.value = `data:image/png;base64,${image}`
     loading.value = false
+    if (props.isRecommend) {
+      emit('preview', previewSrc.value, fileName.value)
+      return
+    }
     showPreview.value = true
   } catch (error) {
     console.error('Error generating image:', error)
@@ -276,13 +293,6 @@ const generateAndDownloadImage = async () => {
   }
 }
 
-// 职位详情分享图片下载文件名
-const fileName = computed(() => {
-  const { name, areaName, payFrom, payTo } = info.value
-  const salary = payFrom && payTo ? `${payFrom ? '_' + payFrom + '-' : ''}${payTo}` : '-面议'
-  return `${name}${areaName ? '_' + areaName : ''}${salary}${positionInfo.value.payName ? '-' + positionInfo.value.payName : ''}`
-})
-
 // 相似职位
 const similarList = ref([])
 const getSimilarPositionList = async () => {

+ 1 - 1
src/views/recruit/personal/position/components/poster.vue

@@ -29,7 +29,7 @@
           <v-avatar style="border: 2px solid #fff;" size="68">
             <img crossOrigin="anonymous" :src="info.enterprise.logoUrl" alt="" style="width: 68px; height: 68px;">
           </v-avatar>
-          <div class="enterprise-name ml-5 ellipsis" style="width: 65%;">{{ formatName(info.enterprise?.anotherName || info.enterprise?.name) }}</div>
+          <div class="enterprise-name ml-5" style="width: 65%;">{{ formatName(info.enterprise?.anotherName || info.enterprise?.name) }}</div>
           <div style="flex: 1;" class="text-right enterprise-name">{{ info.areaName }}</div>
         </div>
         <div class="mx-5 mt-3">

+ 7 - 8
src/views/recruit/personal/position/index.vue

@@ -45,7 +45,6 @@ import { dealDictObjData } from '@/utils/position'
 import { useRoute, useRouter } from 'vue-router'
 defineOptions({name: 'retrieval-position-page'})
 const route = useRoute(); const router = useRouter()
-// const cityFilterRef = ref()
 const conditionFilterRef = ref()
 const showFilterList = [
   { key: 'positionId', isSingle: true },
@@ -54,20 +53,16 @@ const showFilterList = [
   { key: 'eduType' },
   { key: 'jobType' },
   { key: 'scale' },
-  { key: 'industryIds' },
-  // { key: 'financingStatus' },
+  { key: 'industryIds' }
 ]
 
 const pageInfo = { pageNo: 1, pageSize: 20}
 const items = ref([])
 const total = ref(0)
 let routeQuery = (route?.query && route.query && Object.keys(route?.query).length) ? reactive(route.query) : reactive({})
-// routeQuery.date = new Date().getTime()
-// router.push({ path: route.path, routeQuery })
-// if (routeQuery?.length) router.replace({ path: route.path, routeQuery })
 
 const noParams = ref(true)
-const headSearchText = ref(routeQuery?.content || '')
+const headSearchText = ref(routeQuery?.content ? routeQuery?.content.includes('&') ? decodeURIComponent(routeQuery.content) : routeQuery.content : '')
 
 // 职位搜索
 const getData = async () => {
@@ -79,7 +74,10 @@ const getData = async () => {
     const passingOneId = ['positionId'] // 单选且传递整型
     Object.keys(routeQuery).forEach(key => {
       if (routeQuery[key] === '' || key === 'date') return
-      else if (passingStrings.includes(key)) routerParams[key] = routeQuery[key] // 传给后端字符串
+      else if (passingStrings.includes(key)) { // 传给后端字符串
+        if (key === 'content') routerParams[key] = decodeURIComponent(routeQuery[key])
+        else routerParams[key] = routeQuery[key]
+      }
       else if (passingOneId.includes(key)) routerParams[key] = +routeQuery[key] // 传给后端单选且传递整型
       else routerParams[key] = routeQuery[key].split('_') // 传给后端Arr
     })
@@ -117,6 +115,7 @@ const updateRouter = () => {
     }, {})
   }
   query.date = new Date().getTime() // 用于前端刷新路由参数
+  if (query?.content && query.content.includes('%')) query.content = decodeURIComponent(query.content)
   router.push({ path: route.path, query })
   pageInfo.pageNo = 1
   // getData()

+ 15 - 0
src/views/recruit/personal/recommend/index.vue

@@ -18,12 +18,16 @@
               :defaultWidth="false"
               :showContentRight="false"
               :propJobId="jobId"
+              :isRecommend="true"
+              @preview="handlePreview"
             ></positionItemDetail>
           </div>
         </div>
       </div>
     </template>
   </div>
+
+  <PreviewImage v-if="showPreview" :urlList="[previewSrc]" :fileName="fileName" @close="showPreview = !showPreview" />
 </template>
 
 <script setup>
@@ -77,6 +81,17 @@ const handleChangePage = () => {
   getList()
 }
 
+// 分享海报预览
+const showPreview = ref(false)
+const previewSrc = ref('')
+const fileName = ref('')
+const handlePreview = (url, filename) => {
+  if (!url) return Snackbar.warning('海报生成失败,请重新点击分享生成海报')
+  previewSrc.value = url
+  fileName.value = filename
+  showPreview.value = true
+}
+
 </script>
 
 <style scoped lang="scss">

Некоторые файлы не были показаны из-за большого количества измененных файлов