Browse Source

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

lifanagju_citu 9 months ago
parent
commit
a4e055372c
65 changed files with 1014 additions and 190 deletions
  1. 8 0
      src/api/common/index.js
  2. 8 0
      src/api/recruit/personal/resume/index.js
  3. 1 0
      src/components/CtForm/index.vue
  4. 1 0
      src/components/DatePicker/index.vue
  5. 6 1
      src/components/Enterprise/components/positions.vue
  6. 1 1
      src/components/Enterprise/details.vue
  7. 1 1
      src/components/FormUI/TextInput/index.vue
  8. 14 12
      src/components/Position/item.vue
  9. 28 5
      src/components/Position/longStrip.vue
  10. 2 6
      src/components/Upload/img.vue
  11. 24 13
      src/config/axios/service.js
  12. 10 10
      src/hooks/web/useIM.js
  13. 6 6
      src/layout/personal/footer.vue
  14. 1 1
      src/layout/personal/navBar.vue
  15. 9 3
      src/layout/personal/slider.vue
  16. 6 2
      src/router/modules/components/recruit/enterprise.js
  17. 2 1
      src/store/loginType.js
  18. 23 3
      src/store/user.js
  19. 11 0
      src/styles/index.css
  20. 0 0
      src/styles/index.min.css
  21. 12 0
      src/styles/index.scss
  22. 1 1
      src/utils/auth.js
  23. 260 1
      src/utils/headhuntingData.js
  24. 1 1
      src/utils/index.js
  25. 1 1
      src/utils/validate.js
  26. 12 10
      src/views/headhunting/components/content.vue
  27. 1 1
      src/views/headhunting/components/nav.vue
  28. 14 14
      src/views/headhunting/components/serviceContent.vue
  29. 5 1
      src/views/headhunting/details.vue
  30. 118 0
      src/views/headhunting/drill/article.vue
  31. 83 0
      src/views/headhunting/drill/consultant.vue
  32. 7 3
      src/views/headhunting/drill/service.vue
  33. 12 4
      src/views/login/components/editPassword.vue
  34. 11 2
      src/views/login/index.vue
  35. 7 2
      src/views/recruit/components/message/components/chatting.vue
  36. 9 5
      src/views/recruit/components/message/index.vue
  37. 28 10
      src/views/recruit/entRegister/register.vue
  38. 12 2
      src/views/recruit/enterprise/entInfoSetting/informationSettingsComponents/authentication.vue
  39. 1 1
      src/views/recruit/enterprise/entInfoSetting/informationSettingsComponents/welfareLabel.vue
  40. 5 1
      src/views/recruit/enterprise/hirePosition/components/add.vue
  41. 11 3
      src/views/recruit/enterprise/hirePosition/components/item.vue
  42. 9 3
      src/views/recruit/enterprise/hirePosition/index.vue
  43. 6 0
      src/views/recruit/enterprise/interviewManagement/components/invite.vue
  44. 5 1
      src/views/recruit/enterprise/positionManagement/components/add.vue
  45. 14 6
      src/views/recruit/enterprise/positionManagement/components/item.vue
  46. 11 5
      src/views/recruit/enterprise/positionManagement/index.vue
  47. 0 0
      src/views/recruit/enterprise/resume/components/commonStyle.vue
  48. 0 0
      src/views/recruit/enterprise/resume/components/invite.vue
  49. 0 0
      src/views/recruit/enterprise/resume/components/public.vue
  50. 0 0
      src/views/recruit/enterprise/resume/components/screen.vue
  51. 0 0
      src/views/recruit/enterprise/resume/components/table.vue
  52. 0 0
      src/views/recruit/enterprise/resume/index.vue
  53. 4 1
      src/views/recruit/enterprise/talentPool/components/details/baseInfo.vue
  54. 1 1
      src/views/recruit/personal/PersonalCenter/jobFeedback/components/companyCollection.vue
  55. 1 1
      src/views/recruit/personal/PersonalCenter/jobFeedback/components/delivery.vue
  56. 1 1
      src/views/recruit/personal/PersonalCenter/jobFeedback/components/interview/index.vue
  57. 21 15
      src/views/recruit/personal/PersonalCenter/jobFeedback/components/interview/item.vue
  58. 1 1
      src/views/recruit/personal/PersonalCenter/jobFeedback/components/positionCollection.vue
  59. 1 1
      src/views/recruit/personal/PersonalCenter/jobFeedback/components/seenMe.vue
  60. 3 2
      src/views/recruit/personal/PersonalCenter/resume/attachment/index.vue
  61. 20 15
      src/views/recruit/personal/PersonalCenter/resume/online/components/basicInfo.vue
  62. 0 2
      src/views/recruit/personal/PersonalCenter/resume/online/components/jobIntention.vue
  63. 130 0
      src/views/recruit/personal/PersonalCenter/resume/online/components/portrait.vue
  64. 4 2
      src/views/recruit/personal/PersonalCenter/resume/online/index.vue
  65. 9 5
      src/views/register/person.vue

+ 8 - 0
src/api/common/index.js

@@ -16,6 +16,14 @@ export const sendSmsCode = async (data) => {
   })
 }
 
+// 个人注册并登录
+export const userRegister = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/auth/register',
+    data
+  })
+}
+
 // 验证码登录
 export const smsLogin = async (data) => {
   return await request.post({

+ 8 - 0
src/api/recruit/personal/resume/index.js

@@ -213,4 +213,12 @@ export const updatePersonAvatar = async (url) => {
   return await request.post({
     url: `/app-api/menduner/system/person/resume/avatar/update?avatar=${url}`
   })
+}
+
+// 修改个人画像
+export const savePersonPortrait = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/person/resume/tag/update',
+    data
+  })
 }

+ 1 - 0
src/components/CtForm/index.vue

@@ -16,6 +16,7 @@
                 :item="item"
                 @blur="item.blur"
                 @change="handleChange(item)"
+                @appendInnerClick="item.appendInnerClick"
               ></textUI>
               <autocompleteUI
                 v-if="item.type === 'autocomplete'"

+ 1 - 0
src/components/DatePicker/index.vue

@@ -11,6 +11,7 @@
       :placeholder="options.placeholder ?? '请选择'"
       auto-apply
       text-input
+      :disabled-dates="options.disabledDates || false"
       :show-now-button="options.showToday"
       now-button-label="今天"
       :enable-time-picker="options.enableTimePicker ?? false"

+ 6 - 1
src/components/Enterprise/components/positions.vue

@@ -209,10 +209,15 @@ const desc = [
   { mdi: 'mdi-clock-time-ten-outline', value: 'expName' }
 ]
 
+// 立即沟通
 const toDetails = async (info) => {
   const userId = info.contact.userId
   const enterpriseId = info.contact.enterpriseId
-  await prologue({userId, enterpriseId, defaultText})
+  const textObj = {
+    text: defaultText,
+    positionInfo: { ...info.job, enterprise: info.enterprise, contact: info.contact },
+  }
+  await prologue({userId, enterpriseId, text: JSON.stringify(textObj)})
   let url = `/recruit/personal/message?id=${info.job.id}`
   if (info.contact.enterpriseId) {
     url += `&enterprise=${info.contact.enterpriseId}`

+ 1 - 1
src/components/Enterprise/details.vue

@@ -60,7 +60,7 @@
                 <div>{{ val.label }}</div>
                 <div class="business-value ellipsis">
                   {{ info?.business ? info.business[val.value] : '暂无' }}
-                  <span v-if="info?.business && val.value === 'registeredCapital' && info?.business[val.value]">万元</span>
+                  <span v-if="info?.business && val.value === 'registeredCapital' && info?.business[val.value] && info?.business[val.value].indexOf('万元') === -1">万元</span>
                 </div>
                 <div :class="['my-3']"></div>
               </div>

+ 1 - 1
src/components/FormUI/TextInput/index.vue

@@ -87,7 +87,7 @@ const appendClick = () => {
 }
 const appendInnerClick = () => {
   if (item.appendInnerClick) item.appendInnerClick(value.value)
-  emit('appendInnerClick', value.value)
+  else emit('appendInnerClick', value.value)
 }
 
 const handleClear = () => {

+ 14 - 12
src/components/Position/item.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="d-flex">
     <div class="position-box">
-      <div class="sub-li" v-for="(item, index) in list" :key="index" :style="{'height': tab === 3 && item.hire ? '180px' : '140px'}">
+      <div class="sub-li" v-for="(item, index) in list" :key="index" :style="{'height': tab === 3 && item.hire ? '180px' : '149px'}">
         <div class="job-info" @click="handlePosition(item)" @mouseenter="item.active = true" @mouseleave="item.active = false">
           <div class="sub-li-top">
             <div class="sub-li-info">
@@ -26,6 +26,7 @@
             <v-chip v-if="item.hirePrice" size="small" label color="primary">赏金:{{ commissionCalculation(item.hirePrice, 1) }}元</v-chip>
             <v-chip v-if="item.hirePoint" size="small" label class="ml-1" color="primary">积分:{{ commissionCalculation(item.hirePoint, 1) }}点</v-chip>
           </div>
+          <div v-if="tab === 2" class="font-size-14 mb-3 text-end" style="color: #345768;">发布时间:{{ timesTampChange(item.updateTime, 'Y-M-D h:m') }}</div>
         </div>
         <div class="sub-li-bottom" @click="handleEnterprise(item)">
           <div class="user-info">
@@ -33,13 +34,15 @@
               <v-avatar size="35">
                 <v-img :src="item.logoUrl || 'https://minio.citupro.com/dev/menduner/company-avatar.png'" />
               </v-avatar>
-              <span class="names ml-2 font-size-14 ellipsis" style="max-width: 78%;">{{ item.anotherName }}</span>
+              <span class="names ml-2 font-size-14 ellipsis" style="max-width: 88%;">
+                {{ item.anotherName }}
+                <span class="color-999 font-size-13 ml-3">
+                  <span>{{ item.industryName }}</span>
+                  <span class="septal-line" v-if="item.industryName && item.scaleName"></span>
+                  <span>{{ item.scaleName }}</span>
+                </span>
+              </span>
             </div>
-            <p class="float-right color-999 font-size-13">
-              <span>{{ item.industryName }}</span>
-              <span class="septal-line" v-if="item.industryName && item.scaleName"></span>
-              <span>{{ item.scaleName }}</span>
-            </p>
           </div>
         </div>
       </div>
@@ -51,6 +54,7 @@
 defineOptions({ name: 'position-card-item' })
 import { ref, watch } from 'vue'
 import { commissionCalculation } from '@/utils/position'
+import { timesTampChange } from '@/utils/date'
 
 const props = defineProps({
   items: {
@@ -84,8 +88,6 @@ const handlePosition = (item) => {
 const handleEnterprise = (item) => {
   emits('enterprise', item)
 }
-
-const height = ((210 * 2) + 12) + 'px'
 </script>
 
 <style lang="scss" scoped>
@@ -182,10 +184,10 @@ const height = ((210 * 2) + 12) + 'px'
   border: none;
 }
 .user-info {
-  display: flex;
+  // display: flex;
   padding: 12px 20px;
-  align-items: center;
-  justify-content: space-between;
+  // align-items: center;
+  // justify-content: space-between;
 }
 .names {
   font-weight: 500;

+ 28 - 5
src/components/Position/longStrip.vue

@@ -1,10 +1,14 @@
 <template>
   <div>
-    <div class="position-item mb-3 job-closed elevation-2" v-for="(val, i) in props.items" :key="i" @mouseenter="val.active = true" @mouseleave="val.active = false">
+    <div class="position-item mb-3 job-closed elevation-2" style="position: relative;" 
+      v-for="(val, i) in props.items" :key="i" @mouseenter="val.active = true" @mouseleave="val.active = false"
+    >
       <div class="info-header">
-        <div v-if="val.active" class="header-btn">
+        <div v-if="val.active && val.job.status === '0'" class="header-btn">
           <v-btn v-if="props.showCancelBtn" class="half-button ml-3" color="primary" size="small" @click="handleCancel(val)">取消收藏</v-btn>
+          <v-btn class="half-button ml-3" color="primary" size="small" @click="toDetails(val)">立即沟通</v-btn>
         </div>
+        <div v-if="val.job.status === '1'" class="font-size-14 header-btn color-error">职位已关闭</div>
         <div class="img-box">
           <v-avatar :image="getUserAvatar(val.contact.avatar, val.contact.sex)" size="x-small"></v-avatar>
           <span class="name">
@@ -13,10 +17,10 @@
           </span>
         </div>
       </div>
-      <div class="info-content">
+      <div class="info-content" >
         <div class="job-info">
-          <div class="job-name cursor-pointer">
-            <span class="mr-3 info-name" @click="handleToPositionDetails(val)">{{ val.job.name }}</span>
+          <div class="job-name" :class="{'cursor-pointer': val.job.status === '0'}">
+            <span class="mr-3" :class="{'info-name': val.job.status === '0'}" @click="handleToPositionDetails(val)">{{ val.job.name }}</span>
             <span v-if="val?.job?.areaName">[{{ val.job.areaName }}]</span>
           </div>
           <div class="job-other">
@@ -52,6 +56,7 @@ import { useI18n } from '@/hooks/web/useI18n'
 import Snackbar from '@/plugins/snackbar'
 import { getUserAvatar } from '@/utils/avatar'
 import { useRouter } from 'vue-router'
+import { prologue, defaultText } from '@/hooks/web/useIM'
 
 const emits = defineEmits(['refresh'])
 const { t } = useI18n()
@@ -79,6 +84,7 @@ const handleCancel = async (item) => {
 
 // 职位详情
 const handleToPositionDetails = (item) => {
+  if (item.job.status === '1') return
   router.push(`/recruit/personal/position/details/${item.job.id}`)
 }
 
@@ -86,6 +92,23 @@ const handleToPositionDetails = (item) => {
 const handleToEnterprise = (item) => {
   router.push(`/recruit/personal/company/details/${item.enterprise.id}?key=briefIntroduction`)
 }
+
+// 立即沟通
+const toDetails = async (info) => {
+  const userId = info.contact.userId
+  const enterpriseId = info.contact.enterpriseId
+  const textObj = {
+    text: defaultText,
+    positionInfo: { ...info.job, enterprise: info.enterprise, contact: info.contact },
+  }
+  await prologue({userId, enterpriseId, text: JSON.stringify(textObj)})
+  let url = `/recruit/personal/message?id=${info.job.id}`
+  if (info.contact.enterpriseId) {
+    url += `&enterprise=${info.contact.enterpriseId}`
+  }
+
+  router.push(url)
+}
 </script>
 
 <style scoped lang="scss">

+ 2 - 6
src/components/Upload/img.vue

@@ -12,8 +12,8 @@
   </div>
   <div v-else class="" style="position: relative;">
     <v-icon color="error" class="close" @click="handleClose">mdi-close-circle</v-icon>
-    <v-img :src="src" width="100" height="100" rounded class="imgBox" :class="{'cursor-pointer': showCursor}" @click="handleClickImage"></v-img>
-    <div @click="handleClickImage" class="color-primary cursor-pointer text-center text-decoration-underline">点击预览</div>
+    <v-img :src="src" width="100" height="100" rounded class="imgBox" :class="{'cursor-pointer': showCursor}" @click="emit('imgClick')"></v-img>
+    <div @click="emit('imgClick')" class="color-primary cursor-pointer text-center text-decoration-underline">点击预览</div>
   </div>
 </template>
 
@@ -67,10 +67,6 @@ const handleClose = () => {
   src.value = ''
   emit('delete')
 }
-
-const handleClickImage = () => {
-  emit('imgClick')
-}
 </script>
 
 <style scoped lang="scss">

+ 24 - 13
src/config/axios/service.js

@@ -7,7 +7,7 @@ import { useUserStore } from '@/store/user'
 import { getSuffixAfterPrefix, showNextAction } from '@/utils/prefixUrl'
 import { getCurrentLocaleLang } from '@/utils/lang'
 import { enterpriseRefreshToken, userRefreshToken } from '@/api/common'
-import { getToken, getRefreshToken, removeToken, setToken, setRefreshToken, getIsEnterprise } from '@/utils/auth'
+import { getToken, getRefreshToken, setToken, setRefreshToken, getIsEnterprise } from '@/utils/auth'
 import { rewardEventTrackClick } from '@/api/integral'
 import errorCode from './errorCode'
 
@@ -162,7 +162,10 @@ service.interceptors.response.use(
         // 2. 进行刷新访问令牌
         try {
           // 2.1 刷新成功,则回放队列的请求 + 当前请求
-          const data = tokenIndex === 1 ? await enterpriseRefreshToken(getRefreshToken()) : await userRefreshToken(getRefreshToken())
+          const refreshApi = tokenIndex === 1  ? enterpriseRefreshToken : userRefreshToken
+          const data = await refreshApi(getRefreshToken())
+          console.log(132, data)
+          // const data = tokenIndex === 1 ? await enterpriseRefreshToken(getRefreshToken()) : await userRefreshToken(getRefreshToken())
           setToken(data.accessToken)
           setRefreshToken(data.refreshToken)
 
@@ -173,11 +176,13 @@ service.interceptors.response.use(
           requestList = []
           return service(config)
         } catch (e) {
+          // console.log(e)
           // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。
           // 2.2 刷新失败,只回放队列的请求
-          requestList.forEach((cb) => {
-            cb()
-          })
+          // 刷新失败强制需要退回登录页面 不做队列执行
+          // requestList.forEach((cb) => {
+          //   cb()
+          // })
           // 提示是否要登出。即不回放当前请求!不然会形成递归
           return handleAuthorized()
         } finally {
@@ -200,11 +205,11 @@ service.interceptors.response.use(
       Snackbar.error(t('sys.api.errMsg901'))
       return Promise.reject(new Error(msg))
     } else if (code !== 200) {
-      if (msg === '无效的刷新令牌') {
-        // hard coding:忽略这个提示,直接登出
-        console.log(msg)
-      }
-      else {
+      if (code === 1100017019 || code === 1100016002) {
+        // 1100017019邮箱未注册、1100016002手机号未注册过
+        // 未注册过的手机号将code码返回
+        return Promise.reject(data)
+      } else {
         Snackbar.error(msg)
       }
       return Promise.reject(msg)
@@ -239,19 +244,25 @@ service.interceptors.response.use(
 
 const handleAuthorized = () => {
   const { t } = useI18n()
+  const user = useUserStore()
   if (!isReLogin.show) {
     // 如果已经到重新登录页面则不进行弹窗提示
     if (window.location.href.includes('login?redirect=')) {
       return
     }
     isReLogin.show = true
-    Confirm(t('common.confirmTitle'), t('sys.api.timeoutMessage')).then(() => {
+    Confirm(t('common.confirmTitle'), t('sys.api.timeoutMessage'), {
+      cancelCallback: true
+    }).then(() => {
       // resetRouter() // 重置静态路由表
       // deleteUserCache() // 删除用户缓存
-      removeToken()
+      user.handleClearStorage()
       isReLogin.show = false
       // 干掉token后再走一次路由让它过router.beforeEach的校验
-      location.reload()
+      // location.reload()
+      window.location.href = '/login'
+    }).catch(() => {
+      isReLogin.show = false
     })
   }
   return Promise.reject(t('sys.api.timeoutMessage'))

+ 10 - 10
src/hooks/web/useIM.js

@@ -6,7 +6,7 @@ import { getConversationSync, getMessageSync, getChatKey, setUnread, deleteConve
 import { Base64 } from 'js-base64'
 
 import { useUserStore } from '@/store/user'
-import { useLoginType } from '@/store/loginType'
+import { isEnterprise } from '@/utils/auth'
 import { useIMStore } from '@/store/im'
 
 
@@ -92,14 +92,13 @@ const ConnectStatus = {
 // api 接入
 export function useDataSource () {
   const userStore = useUserStore() 
-  const loginType = useLoginType()
   // 最近会话数据源
   WKSDK.shared().config.provider.syncConversationsCallback  = async () => {
     const query = {
       msg_count: 1
     }
-    if (loginType.loginType === 'enterprise') {
-      Object.assign(query, { enterpriseId: userStore.baseInfo.enterpriseId })
+    if (isEnterprise()) {
+      Object.assign(query, { enterpriseId: userStore.entBaseInfo.enterpriseId })
     }
     const resultConversations = []
     const resp = await getConversationSync(query)
@@ -132,8 +131,8 @@ export function useDataSource () {
       limit,
       pull_mode,
     }
-    if (loginType.loginType === 'enterprise') {
-      Object.assign(query, { enterpriseId: userStore.baseInfo.enterpriseId })
+    if (isEnterprise()) {
+      Object.assign(query, { enterpriseId: userStore.entBaseInfo.enterpriseId })
     }
     const resp = await getMessageSync(query)
     const messageList = resp && resp["messages"]
@@ -159,12 +158,13 @@ export function useDataSource () {
 
 async function getKey () {
   const userStore = useUserStore()
-  const loginType = useLoginType()
+
   const keyQuery = {
     userId: userStore.accountInfo.userId
   }
-  if (loginType.loginType === 'enterprise') {
-    Object.assign(keyQuery, { enterpriseId: userStore.baseInfo.enterpriseId })
+  if (isEnterprise()) {
+    Object.assign(keyQuery, { enterpriseId: userStore.entBaseInfo.enterpriseId })
+    console.log('企业模式', keyQuery)
   }
   const { uid, wsUrl, token } = await getChatKey(keyQuery)
   return {
@@ -203,7 +203,7 @@ export const useIM = () => {
   })
   
   async function messageListen (message) {
-    // console.log('收到消息', message)
+    console.log('收到消息', message)
     IM.setFromChannel(message.channel.channelID)
     setUnreadCount()
   }

+ 6 - 6
src/layout/personal/footer.vue

@@ -3,17 +3,17 @@
     <div class="top wid d-flex justify-space-between">
       <div class="left">
         <h4>联系我们</h4>
-        <div class="mt-5 size">
+        <div class="mt-4 size">
           <div>苏州识喜识谊信息科技有限公司</div>
-          <div class="my-3">公司地址&nbsp;苏州工业园区林泉街399号东南大学国家大学科技园(苏州)南工院(2#)304室</div>
+          <div class="my-2">公司地址&nbsp;苏州工业园区林泉街399号东南大学国家大学科技园(苏州)南工院(2#)304室</div>
           <div>服务热线/举报渠道&nbsp;4000xxxx</div>
         </div>
       </div>
       <div class="center">
         <h4>使用与帮助</h4>
-        <div class="mt-5 size">
+        <div class="mt-4 size">
           <a href="/userAgreement">用户协议</a>
-          <a class="my-3" href="/privacyPolicy">隐私协议</a>
+          <a class="my-2" href="/privacyPolicy">隐私协议</a>
           <a>使用与帮助</a>
         </div>
       </div>
@@ -28,7 +28,7 @@
         </div>
       </div>
     </div>
-    <div class="bottom wid mt-3">
+    <div class="bottom wid">
       <span class="size mr-7 second" v-for="(item, i) in list" :key="i">{{ item.label }}</span>
     </div>
   </div>
@@ -49,7 +49,7 @@ const list = [
 <style scoped lang="scss">
 .box {
   width: 100%;
-  height: 200px;
+  height: 180px;
   color: #fff;
   background-color: #313438;
   padding-top: 20px;

+ 1 - 1
src/layout/personal/navBar.vue

@@ -7,7 +7,7 @@
     >
       <div class="innerBox d-flex justify-space-between">
         <div>
-          <div class="nav-logo mr-5 mt-1 cursor-pointer" @click="router.push('/')">
+          <div class="nav-logo mr-5 mt-1 cursor-pointer" @click="router.push('/recruitHome')">
             <v-img src="../../assets/logo.png"  aspect-ratio="16/9" cover :width="90" style="height: 40px"></v-img>
           </div>
           <!-- <div class="nav-city">

+ 9 - 3
src/layout/personal/slider.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="slider-box">
+  <div class="slider-box" :style="{'height': getToken() ? '180px' : '92px'}">
     <div v-for="(item, index) in list" :key="index" class="slider-box-item" @click="handleClick(item, index)">
       <v-btn size="30" class="icons" icon variant="text">
         <v-icon class="icons" size="30">{{ item.mdi }}</v-icon>
@@ -18,14 +18,20 @@
 <script setup>
 defineOptions({ name: 'personalSlider' })
 import { useRouter } from 'vue-router'
+import { getToken } from '@/utils/auth'
 
 const router = useRouter()
-const list = [
+const defaultList = [
+  { mdi: 'mdi-arrow-up-bold', tips: '返回顶部' },
+  { mdi: 'mdi-qrcode', tips: '微信公众号', showImg: 'https://minio.citupro.com/dev/static/mendunerCode.jpg' }
+]
+const hasTokenList = [
   { mdi: 'mdi-arrow-up-bold', tips: '返回顶部' },
   { mdi: 'mdi-qrcode', tips: '微信公众号', showImg: 'https://minio.citupro.com/dev/static/mendunerCode.jpg' },
   { mdi: 'mdi-bell-outline', tips: '消息', path: '/recruit/personal/message' },
   { mdi: 'mdi-list-box-outline', tips: '在线简历', path: '/recruit/personal/personalCenter/resume/online' }
 ]
+const list = getToken() ? hasTokenList : defaultList
 
 const handleClick = (item, index) => {
   // 回到顶部
@@ -37,7 +43,7 @@ const handleClick = (item, index) => {
 <style lang="scss" scoped>
 .slider-box {
   width: 44px;
-  height: 180px;
+  // height: 180px;
   background-color: #fff;
   border-radius: 22px;
   box-shadow: 0 4px 20px 0 rgba(0,0,0,.06);

+ 6 - 2
src/router/modules/components/recruit/enterprise.js

@@ -21,6 +21,8 @@ const enterprise = [
     redirect: '/recruit/enterprise/talentRecruitment',
   },
   {
+    path: '/recruit/enterprise/talentRecruitment',
+    redirect: '/recruit/enterprise/resume',
     component: Layout,
     name: 'Talent Recruitment',
     meta: {
@@ -30,12 +32,12 @@ const enterprise = [
     },
     children: [
       {
-        path: '/recruit/enterprise/talentRecruitment',
+        path: '/recruit/enterprise/resume',
         meta: {
           title: '简历管理',
           enName: 'Resume Management'
         },
-        component: () => import('@/views/recruit/enterprise/talentRecruitment/index.vue')
+        component: () => import('@/views/recruit/enterprise/resume/index.vue')
       },
       {
         path: '/recruit/enterprise/chatTools',
@@ -285,6 +287,8 @@ const enterprise = [
   },
   {
     component: Layout,
+    path: '/recruit/enterprise/financialCenter',
+    redirect: '/recruit/enterprise/membershipPackage',
     name: 'enterpriseMemberCenter',
     meta: {
       title: '财务中心',

+ 2 - 1
src/store/loginType.js

@@ -1,9 +1,10 @@
 import { defineStore } from 'pinia'
+import { isEnterprise } from '@/utils/auth'
 
 export const useLoginType = defineStore('changeLoginType', {
   state: () => ({
     // loginType: 0
-    loginType: localStorage.getItem('loginType') || 'personal'
+    loginType: isEnterprise() ? 'enterprise' : 'personal'
   }),
   actions: {
     change(type) {

+ 23 - 3
src/store/user.js

@@ -10,7 +10,7 @@ import {
   logout 
 } from '@/api/common'
 import { getUserInfo } from '@/api/personal/user'
-import { getEnterpriseUserAccount, getAccountBalance, getUserAccount } from '@/api/common'
+import { getEnterpriseUserAccount, getAccountBalance, getUserAccount, userRegister } from '@/api/common'
 import { getEnterpriseBaseInfo } from '@/api/enterprise'
 import Snackbar from '@/plugins/snackbar'
 import { timesTampChange } from '@/utils/date'
@@ -34,6 +34,21 @@ export const useUserStore = defineStore('user',
       enterpriseUserAccount: {} // 企业账户信息
     }),
     actions: {
+      // 个人用户注册并登录
+      handleUserRegister (data) {
+        return new Promise((resolve, reject) => {
+          userRegister(data).then(async res => {
+            setToken(res.accessToken)
+            setRefreshToken(res.refreshToken)
+            this.accountInfo = res
+            localStorage.setItem('accountInfo', JSON.stringify(res))
+            localStorage.setItem('expiresTime', res.expiresTime) // token过期时间
+            await this.getUserInfos()
+            this.getUserBaseInfos()
+            resolve()
+          }).catch(err => { reject(err) })
+        })
+      },
       // 短信登录
       handleSmsLogin (data) {
         return new Promise((resolve, reject) => {
@@ -49,7 +64,6 @@ export const useUserStore = defineStore('user',
           }).catch(err => { reject(err) })
         })
       },
-
       // 密码登录
       async handlePasswordLogin(data) {
         return new Promise((resolve, reject) => {
@@ -69,7 +83,9 @@ export const useUserStore = defineStore('user',
               this.getUserBaseInfos()
             }
             resolve()
-          }).catch(err => { reject(err) })
+          }).catch(err => {
+            reject(err)
+          })
         })
       },
       // 获取当前登录账户信息
@@ -117,6 +133,10 @@ export const useUserStore = defineStore('user',
         if (type === 1) {
           await logout()
         } else await logoutToken(getToken(1))
+        this.handleClearStorage()
+      },
+      // 清除缓存
+      handleClearStorage () {
         removeToken()
         this.userInfo = {}
         this.baseInfo = {}

+ 11 - 0
src/styles/index.css

@@ -228,3 +228,14 @@
   color: #00887A;
   font-weight: 700;
 }
+
+.close-position::after {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  background-color: #fff;
+  opacity: .7;
+}

File diff suppressed because it is too large
+ 0 - 0
src/styles/index.min.css


+ 12 - 0
src/styles/index.scss

@@ -166,4 +166,16 @@
 .commonHover2:hover {
   color: #00887A;
   font-weight: 700;
+}
+
+// 已关闭职位遮罩层
+.close-position::after {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  background-color: #fff;
+  opacity: .7;
 }

+ 1 - 1
src/utils/auth.js

@@ -1,6 +1,6 @@
 import router from '@/router'
 
-const isEnterprise = () => {
+export const isEnterprise = () => {
   const currentRoute = router.currentRoute.value
   const substr = '/recruit/enterprise'
 

+ 260 - 1
src/utils/headhuntingData.js

@@ -1,4 +1,4 @@
-// 五大服务模块钻取
+// 服务模块钻取
 export const serviceData = [
   {
     title: '高级管理精英甄选',
@@ -69,5 +69,264 @@ export const serviceData = [
       '5. 综合报告:门墩儿猎头根据调查和审核结果,提供一份详尽的综合报告,为企业决策提供客观、全面的参考依据。',
       '6. 法律合规性:我们的服务严格遵守相关法律法规,确保调查过程的合法性、合规性,保护候选人的隐私权益。'
     ]
+  },
+  {
+    title: '酒店集团总部',
+    id: 'hotel-group-headquarters',
+    startDesc: '酒店集团总部致力于品牌建设和文化传承,通过打造独特的品牌形象和企业文化,提升集团的竞争力和影响力。',
+    endDesc: '总部作为整个酒店集团的核心管理机构,在战略规划、资源调配、协调控制、品牌管理等方面发挥着重要作用。通过不断优化组织架构、提升管理水平、加强品牌建设和文化传承等措施,酒店集团总部能够推动集团的持续发展和壮大。',
+    children: [
+      '1、在品牌建设方面,总部会注重品牌的宣传和推广,提升品牌的知名度和美誉度;在文化传承方面,总部会注重企业文化的建设和传播,营造积极向上的工作氛围和团队精神。',
+      '2、集团总部通过制定标准化的运营流程和管理制度,确保各分店在运营过程中能够遵循统一的标准和要求。',
+      '3、同时,总部还会对各分店进行定期的培训和指导,提升分店的管理水平和服务质量。',
+      '4、在运营管理方面,酒店集团总部注重数据分析和市场反馈,及时调整经营策略和资源配置,以适应市场变化和客户需求。',
+      '5、此外,总部还会加强与各分店之间的沟通和协作,确保信息的畅通和资源的共享。'
+    ]
+  },
+  {
+    title: '业主公司',
+    id: 'owner-company',
+    startDesc: '随着市场的不断变化,业主公司需要密切关注行业动态,如政策调整、市场需求变化等,以便及时调整经营策略。近年来服务行业逐渐从规模扩张转向高质量发展,业主公司可以注重提升服务质量、优化存量管理,以满足市场需求。',
+    endDesc: '因此针对不同岗位的需求,提供定期的专业技能培训课程,确保员工具备行业所需的专业知识和技能。随着公司业务的多元化发展,跨领域人才的需求也越来越大。鼓励员工跨部门学习和交流,培养具备多领域知识和技能的复合型人才;同时,也可以引进外部专业人才,为公司注入新的活力和创新思维。',
+    children: [
+      '1、为了降低单一业务带来的风险,业主公司可以考虑多元化发展策略,如涉足物业管理、商业运营、社区服务等多个领域。',
+      '2、随着科技的进步,数字化转型已成为企业发展的必然趋势。业主公司可以加强信息化建设,利用大数据、人工智能等技术提升管理效率和服务水平。',
+      '3、因此针对不同岗位的需求,提供定期的专业技能培训课程,确保员工具备行业所需的专业知识和技能。'
+    ]
+  },
+  {
+    title: '连锁酒店及公寓',
+    id: 'chain-hotels-and-apartments',
+    startDesc: '我国酒店业的连锁化率不断提升,预计未来将有更多单体酒店转向连锁经营,许多知名品牌积极扩张,市场竞争日益激烈,连锁酒店将更加注重服务创新,提供个性化的服务,以满足不同客户的需求,通过引入智能化技术和设备,提高服务效率和管理水平,提升客户体验,随着环保意识的提高,连锁酒店将更加注重环保和可持续发展,积极采用环保技术。',
+    startTitle: '公寓作为一种居住形态,通常提供独立的空间和相对完善的生活设施,满足不同人群的居住需求。',
+    children: [
+      '1、公寓行业可细分为长租公寓、短租公寓、主题公寓等多个领域。',
+      '2、租期灵活,可以满足不同租客的居住需求,通常提供舒适的居住环境和完善的生活设施,如家具、家电等,运营商提供统一的管理和服务,包括清洁、维修等,为租客提供便捷的生活体验,作为一种综合物业形态,既具有居住功能又具有投资价值,租金收益稳定可观。'
+    ]
+  },
+  {
+    title: '单体酒店',
+    id: 'single-hotel',
+    startDesc: '单体酒店这种酒店形式的特点是单独、分散地存在于各个城市和地区,独立地进行营销活动和管理活动,不属于任何酒店集团,也不以任何形式加入任何联盟。',
+    endDesc: '随着酒店市场的竞争加剧,单体酒店将更加注重差异化竞争,通过提供独特的服务、打造特色主题等方式来吸引客户,虽然单体酒店本身不属于任何品牌,但越来越多的单体酒店开始意识到品牌建设的重要性,通过提升服务质量、加强市场宣传等方式来塑造品牌形象,随着数字化技术的发展,单体酒店也将加快数字化转型步伐,利用互联网、大数据等技术手段来提升管理效率、优化客户体验。单体酒店作为传统酒店形式的一种,具有独特的经营特点和市场优势。在未来发展中,单体酒店需要不断创新和提升服务质量,以应对日益激烈的市场竞争和消费者需求的变化。',
+    children: [
+      '1、完全由业主自主经营,不受其他酒店集团或连锁品牌的约束,具有较高的经营自主权,遍布各个城市和地区,数量众多,分布广泛,为游客提供了多样化的住宿选择,由于不受统一标准限制,单体酒店更容易根据当地文化和市场需求提供个性化的服务,满足不同游客的住宿需求。',
+      '2、与连锁酒店相比,单体酒店在品牌建设和市场推广方面相对较弱,更多地依赖于地理位置、服务质量和口碑来吸引客户。'
+    ]
+  },
+  {
+    title: '新餐饮',
+    id: 'new-catering',
+    startDesc: '新餐饮业是指基于线上线下一体化、供应链垂直整合、餐饮零售化等理念,以新技术、新模式、新业态为主要特点的新型餐饮行业。',
+    endDesc: '随着消费者健康意识的提高,新餐饮业将更加注重提供健康、营养且美味的菜品,满足消费者对健康饮食的需求。人工智能技术将进一步渗透新餐饮业,智能化餐厅将成为未来的主流趋势,包括智能点餐、智能推荐、智能配送等全链条智能化服务。新餐饮业以其独特的理念和特点,正在不断改变着餐饮行业的面貌。未来,随着技术的不断进步和消费者需求的不断变化,新餐饮业将迎来更加广阔的发展前景。',
+    children: [
+      '1、它不仅是传统餐饮业的延伸和升级,更是对餐饮行业供给侧结构性改革的积极响应。',
+      '2、打破了传统餐饮业的物理空间限制,通过线上平台(如外卖平台、自建APP、小程序等)实现订单接收、支付、配送等全流程数字化管理,同时结合线下门店提供优质的用餐体验,实现线上线下无缝对接。'
+    ]
+  },
+  {
+    title: '高端民宿',
+    id: 'high-end-cottage',
+    startDesc: '以豪华、舒适和服务一流为特点的住宿场所。它们通常位于独特的地理位置,如度假胜地、高山、海滨等,为客人提供超越一般旅行住宿的奢华体验。',
+    endDesc: '随着旅游市场的不断发展和消费者需求的不断变化,高端民宿将继续保持其独特魅力,并呈现出多元化、品质化、数字化等发展趋势。',
+    children: [
+      '1、高端民宿不仅注重住宿设施的豪华与品质,还强调服务的个性化和独特性,旨在为客人打造一个独一无二的高端住宿环境。通常配备高档家具、家电以及豪华的室内装饰,如私人泳池、温泉浴缸、健身房等,满足客人对高品质生活的追求。',
+      '2、选址上,高端民宿倾向于位于自然风光优美或具有独特文化底蕴的地区,如风景名胜区、海岛、古镇等,让客人在享受住宿的同时,也能领略到独特的自然风光和人文景观。',
+      '3、为了满足不同消费者的需求,高端民宿将呈现多元化的发展态势,如结合当地文化特色打造主题民宿、提供特色餐饮和娱乐活动等。'
+    ]
+  },
+  {
+    title: '高端康养',
+    id: 'high-end-consumption',
+    startDesc: '高品质、全方位身心健康服务和生活体验的一种康养模式。',
+    endDesc: '作为一种高品质、全方位的康养模式,具有显著的特点和广阔的发展前景。在未来发展中,高端康养将更加注重市场需求、科技融合、国际化发展、生态化建设和个性化服务升级等方面的发展趋势。',
+    children: [
+      '1、高端康养通常融合了先进的医疗技术、健康管理理念、自然生态环境以及个性化服务等元素,旨在为客户打造一个全方位、高品质的康养生活。',
+      '2、提供定制化的健康管理方案、专业的医疗团队和先进的医疗设备,确保客户得到全面、精准的健康服务,于自然风光优美、空气质量优良的地区,如海滨、山区、温泉等,为客户营造宁静、舒适的康养环境。',
+      '3、除了基本的住宿设施外,还配备有医疗中心、康复中心、活动中心、图书馆、健身房等多样化的配套设施,满足客户在健康、娱乐、社交等多方面的需求。',
+      '4、随着人口老龄化问题的加剧和人们生活水平的提高,对高品质康养服务的需求不断增长。高端康养市场将迎来更大的发展机遇。'
+    ]
+  },
+  {
+    title: '会展业',
+    id: 'exhibition-industry',
+    startDesc: '会展业是一个新兴的服务行业,具有广泛的影响面和高度关联性。',
+    endDesc: '会展企业将通过兼并重组等方式实现规模化经营,同时加强与国际会展业的交流与合作,提升国际竞争力,会展业在全球范围内呈现出蓬勃发展的态势。',
+    children: [
+      '1、它涵盖了会议、展览、博览会、交易会、展销会、展示会等多种形式的集体活动,是围绕特定主题,多人在特定时空的集聚交流活动。会展业不仅是信息传递和交流的平台,也是促进经济、文化、科技等多领域交流与合作的重要渠道。',
+      '2、能带来直接的场租费、搭建费等收入,还能拉动数十个相关行业的发展,如商业购物、餐饮、住宿、娱乐、交通、通讯、广告、旅游、印刷、房地产等,形成显著的经济效应。',
+      '3、会展业是文化、科技、经贸等多领域交融的载体,能够促进不同领域之间的交流与融合。'
+    ]
+  },
+  {
+    title: '邮轮产业',
+    id: 'cruise-industry',
+    startDesc: '以跨国旅行为核心,通过丰富的旅游产品吸引游客,以航线经营为手段,提供海上观光旅游及相关服务,由交通运输、船舶制造、港口服务、旅游观光、餐饮、购物和银行保险等多个行业组合而成的复合型产业。',
+    endDesc: '随着中国市场的开放和发展,越来越多的国际邮轮公司进入中国市场,中国本土邮轮公司也在逐步崛起。',
+    children: [
+      '1、融合了多个服务行业,形成了强大的经济集聚效应,带动了相关产业链的发展。',
+      '2、邮轮旅游通常涉及多个国家和地区,形成了跨国、跨洋的旅游网络,游客可以在一次旅行中体验多个目的地的文化和风景,作为移动的海上度假村,汇集了来自不同国家和地区的游客和船员,形成了独特的多元文化交融环境。',
+      '3、邮轮旅游通常提供高端、豪华的住宿、餐饮和娱乐设施,为游客带来独特的奢华体验。'
+    ]
+  },
+  {
+    title: '旅游及地产',
+    id: 'tourism-and-real-estate',
+    startDesc: '旅游产业是为国内外游客提供出行、住宿、餐饮、游览、购物、娱乐等服务活动的一系列相关行业的统称。',
+    endDesc: '旅游地产市场受到多种因素的影响,如政策调整、市场需求变化等,具有一定的风险性,但同时也存在较大的发展机遇。',
+    children: [
+      '1、它具有综合性强、关联性高、拉动性大的特点,在政治、经济、社会、文化、生态等领域显示出了巨大发展活力,对国民经济与社会的发展具有突出贡献和拉动作用。',
+      '2、涉及多个行业,包括交通、住宿、餐饮、购物、娱乐等,形成了一个综合性的服务体系,各个环节紧密相连,相互影响,任何一个环节的改变都可能对整个产业产生影响。',
+      '3、旅游产业的发展能够带动相关产业的发展,如交通运输、商业零售、餐饮住宿等,形成经济联动效应。',
+      '4、旅游地产的开发和运营需要依托丰富的旅游资源,包括自然景观、人文景观等,投资回报期相对较长,需要长期稳定的运营和管理。'
+    ]
+  },
+  {
+    title: '体育与休闲业',
+    id: 'sports-and-leisure-industry',
+    startDesc: '体育休闲产业为社会各部门提供的与体育活动密切相关的产业领域,它涵盖了体育产品和服务,以及与这些产品和服务相关的经营活动的总和。',
+    endDesc: '体育与休闲业在促进人民健康、丰富社会文化生活、增强社会凝聚力等方面具有显著的社会效益。',
+    children: [
+      '1、这一产业不仅包括传统的体育运动项目,还融合了休闲、娱乐、旅游等多个元素,为人们提供多样化的身心放松和健康生活方式。',
+      '2、具有广泛的参与群体,不同年龄、性别、职业的人们都可以参与其中,享受运动的乐趣,该行业提供的不仅仅是体育设施和产品,更是一种健康、积极的生活方式,强调服务的全面性和个性化。',
+      '3、不断推出新的运动项目、服务模式和产品,以满足消费者日益多样化的需求。例如,智能健身设备、VR体验等新兴技术的应用,为休闲体育带来更多可能性。'
+    ]
+  },
+  {
+    title: '医院与博物馆',
+    id: 'hospital-and-museum',
+    startDesc: '医院是按照法律法规和行业规范,为病员开展必要的医学检查、治疗措施、护理技术、接诊服务、康复设备、救治运输等服务,以救死扶伤、治病救人为主要目的的医疗机构,汇聚了各类医学专业人才,能够提供从预防、诊断、治疗到康复的全方位医疗服务。博物馆作为公共文化机构,具有公益性质,向全社会开放,为公众提供知识、教育和娱乐服务。',
+    endDesc: '医院和博物馆作为社会的重要机构,在定义、特点和发展趋势上各有侧重但又相互关联。医院致力于提供高质量的医疗服务,而博物馆则致力于传承和弘扬人类文化和历史。两者都在不断适应时代的变化和发展需求,为社会和公众提供更好的服务。'
+  }
+]
+
+// 顾问
+export const consultantData = [
+  {
+    id: 'simon',
+    title: '田森先生(Simon Tian)',
+    job: '创始人兼首席执行官',
+    country: '苏州',
+    avatar: 'https://minio.citupro.com/dev/menduner/consultant/simon.png',
+    desc: [
+      '2018年10月创办门墩儿人力资源一体化技术平台,田森博士为酒店行业提供了专业的人才招聘和寻访服务。通过搭建线上线下相结合的学习平台、组织行业交流活动、提供定制化培训方案等方式,帮助酒店从业者提升专业技能和职业素养。同时他还致力于人才数据的开发和利用,为酒店业的人力资源管理提供了更加科学、智能的解决方案。',
+      '田森博士凭借其丰富的酒店行业工作经历和卓越的学术背景,在酒店运营管理、市场营销、人力资源管理及人才数据开发等方面展现出了深厚的专业知识和创新能力。他的工作成果不仅为酒店业的发展做出了积极贡献,也为行业内的其他从业者树立了榜样。'
+    ],
+    edu: ['香港理工大学酒店及旅游管理博士', '香港理工大学信息管理理学硕士', '瑞士恺撒里兹大学国际饭店管理研究生', '中国旅游饭店国家级星评员', '中山大学校外兼职研究生导师', '苏州大学旅游管理专业学位研究生教育指导委员会委员'],
+  },
+  {
+    id: 'peter',
+    title: '潘青海先生(Peter Pan)',
+    job: '猎头顾问',
+    country: '北京',
+    avatar: 'https://minio.citupro.com/dev/menduner/consultant/peter.png',
+    desc: [
+      '潘青海先生在酒店及酒店式服务公寓领域积累的16年房务背景经验,积累了丰富的专业知识和实践经验。拥有深入的理解和实战经验。这段经历不仅让他掌握了高效运营客房部门的技巧,还培养了他对细节的关注和对服务质量的执着追求。2018年9月加入门墩儿人力资源一体化技术平台,是公司创始团队成员之一,并承担起日常运营的重任。他利用自己的专业技能优化了公司的运营流程,提高了工作效率。注重团队协作,通过有效的沟通和协调,确保与同事和客户之间的顺畅衔接,为公司的整体运营提供了坚实的保障。',
+      '在招聘和猎头业务方面,展现出了敏锐的洞察力和精准的判断力。利用自己的行业资源和人脉,积极开拓招聘渠道,与各大集团、酒店及业主公司建立了良好的合作关系。',
+      '注重客户需求分析,为客户提供个性化的解决方案,赢得了客户的信任和好评。在他的带领下门墩儿的销售业绩持续攀升,市场份额不断扩大,为公司的发展奠定了坚实的基础。'
+    ]
+  },
+  {
+    id: 'julie',
+    title: '姚嘉庆女士(Julie)',
+    job: '猎头顾问',
+    country: '北京',
+    avatar: 'https://minio.citupro.com/dev/menduner/consultant/julie.png',
+    desc: [
+      '在酒店行业深耕十五年之久,是一位拥有丰富市场销售经验与深厚行业洞察力的专业人士。多年的酒店从业经历使姚嘉庆女士具备了敏锐的市场洞察力、卓越的团队领导力和深厚的行业资源积累。她能够迅速适应不同市场环境的变化,灵活调整销售策略,确保酒店业绩的持续增长。',
+      '姚嘉庆女士对酒店行业及其他相关行业的市场动态、企业需求、人才分布等有深入的了解,这有助于她更准确地把握客户需求,提高寻访效率。她具备出色的人才评估能力,能够准确地判断候选人的专业技能、职业素养、性格特质等是否符合客户企业的要求。同时,她也是一位注重持续学习和自我提升的顶尖猎头顾问,在快速变化的市场动向中不断掌握新的行业知识、招聘技巧和行业动态,以保持自己的专业竞争力。'
+    ]
+  }
+]
+
+// 见解
+export const articleData = [
+  {
+    id: 'china-needs-more-women-in-executive-leadership',
+    title: '女性领导力崛起:《走进中国女性高管的职场现状》',
+    bgUrl: 'https://minio.citupro.com/dev/menduner/article/Hero-CNWomen-Mar23.jpg',
+    desc: [
+      { text: '高管团队性别多样性是企业价值创造的重要抓手,若女性不能充分发挥自身潜力,企业也无法实现全部潜力。既然性别多样性有助于优化企业管理和业绩表现,如何让更多女性晋升高管层是一个重要的议题。' },
+      { text: '相较于其他国家的女性,中国女性劳动参与率更高,但职业发展却略逊一筹,她们较少能成晋升成为高管,且成功晋升的也多为职能部门的高管。' },
+      { text: '在此份报告中,贝恩携手史宾沙分析归纳了中国女性在职场中面临的四大挑战:肩负更多家庭责任、更加谨慎三思后行、难以融入男性主导的社交圈、受到先入为主的偏见。' },
+      { 
+        text: '我们建议中国企业采取以下行动,提高职场中的性别平等:',
+        children: ['核心高管积极支持并进行背书', '强化并培育包容的企业文化', '打造性别平等的员工支持体系', '帮助职场女性建立社交和人脉']
+      },
+      { 
+        text: '我们也建议有志女性采取以下积极行动,释放自身在职场的领导潜力:',
+        children: ['敢想敢说,转变观念', '勇于发声,自信行事', '主动出击,建立人脉', '打破偏见,自我定义']
+      }
+    ]
+  },
+  {
+    id: 'leadership-for-a-complex-world-planning-for-the-ceo-of-the-future',
+    title: '纷繁复杂世界中的领导力:为未来的首席执行官做好规划',
+    bgUrl: 'https://minio.citupro.com/dev/menduner/article/THMB-CEOoftheFuture.jpg',
+    desc: '随着首席执行官在过去三年间承受了工作的重重压力精疲力尽,并考虑退休,一家大型全球性公司正面临着严峻的领导层和战略层面的挑战。这家公司以高质量产品和卓越客户服务而著称,长期以来一直占据着市场主导地位。然而,为了跟上技术进步、新进入者和富有创新性的竞争对手的步伐,它可谓步履维艰。其文化偏向规避风险和以共识为导向,这使得变革难以实施和维持。与许多公司一样,该公司正在应对2020年代的诸多挑战 - 宏观经济不确定性、地缘政治动荡、供应链中断、人才争夺战、对工作态度的转变、更广泛的社会变革以及ESG(环境、社会和公司治理)压力,包括管理层更多元化的需求。员工流动率居高不下,一名高层领导刚刚因为其他机会辞职。公司股价表现不及整体市场,并且有关股东可能采取激进行动的传闻不绝于耳。'
+  },
+  {
+    id: 'the-evolution-of-the-cfo-role-from-a-global-perspective',
+    title: '全球视角下的CFO角色演变:跨领域高管的洞察与应对策略',
+    isBgFull: true,
+    bgUrl: 'https://minio.citupro.com/dev/menduner/article/PageBanner_Migration_Default.png',
+    startText: '信息高度发达的今天,人们实时知道世界在发生什么,深切感受全球经济、金融和政治的跌宕起伏。从科技巨头到创业企业,从古老的制造业到新生的互联网数字平台,每一个角落都充斥着挑战与机遇。近期,史宾沙(Spencer Stuart)和AICPA&CIMA国际专业注册会计师公会组织了一场CFO讨论会,来自消费品、电商、餐饮服务、新能源汽车、医疗健康服务以及私募基金等不同领域的嘉宾们探讨了热点问题和CFO的角色变化。大家的分享交流不仅显示了市场的复杂性,也展示了应对策略的多元化。本文概况了嘉宾们的主要观点以及我们更广范围与高管交流获得的洞见。',
+    desc: [
+      { 
+        title: '消费服务和电商行业的关键词:',
+        subTitle: '控制成本、集中资源、投资品牌、拓展渠道、调整模式',
+        children: [
+          '疫情后,我曾非常好奇问一位欧洲食品公司的CEO如何看中国市场,他坦言,“我们要成为一家大公司,就必须拓展中国市场,这是世界上少有的单一大市场。”但中国市场竞争激烈,且消费者对于商品的选择变得越来越理性,追求性价比是公认的趋势。一位快消品CFO提到,产品可能定价较低,但并不意味着其利润率低。本土企业在这方面表现良好,而外资企业需要重新考虑其价值链和利润要求,拿出相应对策。',
+          '广阔的市场和科技进步继续促进渠道多元化,线上线下同步快速发展和变化。品牌商已经从传统线下渠道转到电商,但传统电商流量正在被社交电商、兴趣电商、以及抖音、小红书等平台吞噬,挤进这些新兴渠道的品牌商很快发现,在这里知名度高了但盈利能力仍不确定。另一方面,线下渠道也呈现新的机会,一线城市消费者挤爆新开业的连锁会员制仓储店的时候,量贩零食连锁店正在下沉市场蓬勃发展,有的在全国采用加盟制已经开了几千甚至上万家店。',
+          '但是渠道红利并不持续,注重品牌建设和投资高质量产品才是品牌长期成功的关键。在过去几年,有些国产品牌如完美日记主要通过流量和电商平台获得一时的关注,但这种策略很难实现消费者的持续复购。相比之下,另有国产品牌像薇诺娜和珀莱雅通过重点投资研发和特定的渠道建设,逐渐培养了消费者的信任并保证产品质量,从而实现了消费者的重复购买,才能给品牌带来持续发展。',
+          '一位来自国际知名化妆品公司CFO讲到:“为了品牌的长期健康,需要牺牲一些短期利益,包括控制代购和折扣情况,尽管这可能影响股价和短期销售。我们集中资源,关闭不盈利的店铺,专注于有竞争力的品牌,同时对于未来潜力不大的小品牌作出果断决策,缩减规模或退出市场。”',
+          '一位来自知名咖啡连锁品牌的CFO提到,面对价格战,尽管已是性价比较高的外资品牌,仍需要提供更大力度的折扣。从财务角度看,应对策略包括降低成本、提高供应链和市场费用的效率。策略上,公司倾向于通过加盟方式扩展新店,利用强大的供应链支持加盟店稳定增长。',
+          '电商行业在经历了高速增长后,面临增长放缓的挑战,正通过战略投资和拓展业务至相关上下游领域,寻求新增长曲线,同时关注优化成本结构,提升长期的竞争力。比如宝尊收购了Gap品牌大中华区运营权和英国的Hunter Boots在中国和东南亚的IP等,旨在从品牌服务商转向品牌运营商。同时,通过设立区域运营中心,将员工从成本较高的上海迁移到成本较低的城市,有效降低了整体成本,提升了公司的盈利能力。',
+          '我们发现,面对同样的艰难环境,优秀的企业依然能够找到适应市场环境和改善财务状况的方法,灵活的业务模式和高效的运营管理是赢得竞争的关键,展现了企业和组织的韧性。'
+        ]
+      },
+      { 
+        title: '全球化与本土化并行',
+        children: [
+          '受地缘政治的影响,有的知名大公司在中国采取低调态度进行运营与宣传,更倾向于通过第三方来影响政策而非直接亮相;也有跨国公司选择逆势而上,致力于将企业更深入地融入中国市场,从而实现高回报,目标把公司变成一个深受中国市场欢迎、估值可能超过母公司的企业,取得如百胜在中国所实现的成功。',
+          '这些年,很多跨国公司在实施“China for China”的本土化战略,主要是以下四个方面的考虑:一、全球供应链的重构促使企业必须在维持全球视野的同时加强本地市场的布局,才能对市场变化的做出快速反应;二、中国的营销生态系统在国际上是独特的,尤其是电商平台比例、营销策略和供应链灵活度等,因此跨国公司需要专门针对中国市场制定策略;三、中国像美国和欧洲有独立的数据合规和安全体系。如果公司的服务器不位于中国,则无法满足合规要求;四、前几年中国的“热钱”为外资公司利用中国资金在中国市场扩展提供了机会。',
+          '一位具有丰富跨国工作经验的CFO也提到,实施“China for China”的战略,一方面对本地业务和团队是机遇,另一方面也要保持与总部的连接,发挥跨国公司全球的资源和战略协同的优势。过度本土化可能影响董事会对中国市场的预期,认为其是一个较容易进行取舍的市场。所以,需要在全球化和本土化之间找到平衡。'
+        ]
+      },
+      { 
+        title: '供应链的韧性与“出海”策略',
+        children: [
+          '无论是为了寻求发展还是增强供应链的韧性,或是为适应地缘政治变化的影响,更多企业正在放眼全球,重新考虑布局。尽管有些企业思考将生产线迁出中国,但因劳动力成本效率、合规问题及生产力等因素,实际上也不愿意离开中国。同时,也有一些企业选择将部分供应链延伸至东南亚等地区,既能满足下游需求,又保持与中国市场的联系。全球而言,亚洲、非洲等新兴市场仍展现了巨大的消费和投资潜力,这些区域市场可能成为未来增长的关键。特别是在新能源汽车领域,中国企业通过在东南亚、墨西哥等地生产并销售汽车,展现了突出的国际化趋势。如火如荼的跨境电商,借助数字科技和全球化的供应链基础也成为产品进入欧美市场的新渠道。'
+        ]
+      },
+      { 
+        title: '投资逻辑的变化',
+        children: [
+          'CFO们最羡慕的是当下帐上有充足现金的企业。一位创业公司的CFO分享到,以前在讨论创业公司上市的前景时,投资者主要关注两个问题:一是对技术在未来十年内成为重要市场的信心;二是相信公司能成为未来市场的前三名之一。即使未来五六年没有收入,但只要投资者相信这个故事,他们就会投资。然而,现在市场的关注点转向了公司的现金流和造血能力,不再是单纯追求技术或市场的领先地位,更加关心企业是否能够实现自我维持和长期增长。这一变化也促使企业重新评估他们的投资策略。'
+        ]
+      },
+      { 
+        title: 'CFO和财务团队的角色的延展与人才升级',
+        children: [
+          '在这复杂多变的背景下,CFO和财务团队的角色不仅需要掌握传统的财务管理技能,还需要具备更为全面的视野,以理解和预测市场动态以及内外部风险,进而支持企业的战略决策和长期发展。一位来自快消品行业CFO提出了市场周期和风险管理的重要性,强调过去的乐观态度需要重新评估,应对全局性风险需具备清晰的预备方案(Plan B),不仅是对财务健康的维护,更是对企业未来发展的规划,成为与首席执行官(CEO)并肩作战的战略伙伴。为了适应财务职能的转变,CFO需要关注并提升团队的能力,这意味着不仅要培养团队成员对行业和业务的深入了解,还要提高他们的战略思维和前瞻性分析能力。'
+        ]
+      },
+      { 
+        title: 'CFO所需的关键能力',
+        children: [
+          '每当讨论这个话题时,会记录下长长的单子,企业在不同阶段和不同境况下,对CFO核心能力的要求也会有不同的侧重。有些是财务职能独特的,有些是高管都需要具备的,最主要有以下五个方面:',
+          '<strong>一、对市场和客户的深刻理解:</strong>CFO需要对市场周期、自身行业和公司业务具有深入的洞察力,这不仅能够帮助企业在资源可能紧缺的情况下有效地分配资源,还能在变化莫测的市场环境中制定有效的应对策略。',
+          '比如,饮料行业长期看仍在增长,但出现了价值增长落后于量增长的现象,明显的消费降级趋势,而且不同品类和渠道之间的差异化显著。企业面临来自价格管理和电商挑战,包括团购在内的新兴购物模式对价格体系造成干扰,CFO需要把握如何精确投资于新媒体并评估回报成为关键,同时助力直接面向消费者的销售方式来获得更深入的市场洞察。',
+          '<strong>二、思维的体系化、前瞻性和全局观:</strong>这意味着能够全面地理解和掌握财务和业务的各个方面,并能整合这些信息来做出最佳的战略决策。CFO不应仅仅看重短期目标,如上市或控制成本,而应保持全局的视角和系统思维,确保在市场和内部环境变化时,公司的财务体系能够稳健运行,适应新的优先事项。正如一位金融业CFO指出,财务职能从传统的后视镜角色(财务控制和报告),演变为更前置且具有战略性的角色,类似于电动车的车机系统,具备导航和全方位监控的功能。',
+          'CFO不仅在财务决策中起到平衡作用,还需在营销预测、产品研发、定价策略等多方面展现全面掌控力,确保企业能在不确定性中找准发展方向。决策过程中,维持平衡机制的重要性愈发突显。',
+          '随着全球化的不断发展,CFO需要具备全球化视野,掌握跨国运营的知识,并能够准确评估不同市场之间的机遇与风险,以及如何在全球范围内有效分配资源。',
+          '<strong>三、利用技术与数据驱动决策和创造效益:</strong>CFO的能力和韧性在技术和数据驱动的业务环境中得到显著提升,这不仅在财务数据的基础上做出决策,还要能够利用大数据和AI工具进行前瞻性分析和风险评估。',
+          '一位CFO分享了她如何领导财务部门利用杜邦分析,提升销售组织效率和创造收入增量。他们细分销售流程的每一个环节,以识别和利用能够驱动销售的关键因子,从而识别并排除无效因子。然后进行预测管理。如果发现目标销售额低于设定值,财务团队需要和销售团队一起寻找新的创意和机会来填补可能的风险和损失。这样,财务团队直接参与到收入目标的设定和实现中,也要求财务团队具备更广泛的技能,包括市场洞察、战略规划和跨部门协作的能力。',
+          '<strong>四、促进企业创新与发展:</strong>企业越来越倾向于采用创新驱动的发展模式,CFO的角色也在向促进创新和支持企业发展扩展。前瞻性和在财务职能中的战略性前置作用越发重要,CFO需要通过精准的资产配置和有效的资源管理来促进企业的健康发展。一位来自医疗健康行业CFO提到,虽然行业风波不断,但他们依旧致力于财务、运营、渠道管理以及与政府的关系中找到平衡,尤其专注于如何在保持合规的同时,鼓励团队进行尝试和创新。',
+          '<strong>五、卓越的沟通和领导力:</strong>如前所述CFO需要成为企业战略规划和执行过程中的核心参与者,就需要与CEO以及其他高管成员建立紧密的合作关系,共同探索和实施可能影响企业长期发展和股东价值的策略。CFO还需要成为CEO的重要伙伴(copilot),与公司的创始人、大股东以及外部投资者建立和保持良好的关系,将个人想法和策略有效地转化为公司的决策和行动。',
+          '综上所述,CFO的角色将继续向战略规划者、风险管理者以及创新推动者转变,需要具备前瞻性体系化思维、全局视野、强烈的风险意识、高度的领导力和沟通能力,以及对市场、客户、数据和技术的深入理解。同时,要不断地学习和积累经验,培养体系化思维和全局观,有效地管理和领导财务团队,以及与公司管理层、股东和外部投资者建立良好的合作关系。'
+        ]
+      },
+    ]
   }
 ]

+ 1 - 1
src/utils/index.js

@@ -21,7 +21,7 @@ export const checkIsImage = (url) => {
   var link = new URL(url)
   var path = link.pathname
   var extension = path.split('.').pop().toLowerCase()
-  var imageExtensions = ['jpg', 'jpeg', 'gif', 'png']
+  var imageExtensions = ['jpg', 'jpeg', 'gif', 'png', 'jfif']
   var videoExtensions = ['mp4', 'wmv', 'avi', 'mov']
 
   // 图片

+ 1 - 1
src/utils/validate.js

@@ -66,4 +66,4 @@ export const checkEmail = (email) => {
 const USCIReg = /^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$/;  
 export const checkUSCI = (code) => {  
   return USCIReg.test(code)
-} 
+}

+ 12 - 10
src/views/headhunting/components/content.vue

@@ -37,7 +37,7 @@
     <div class="country-offices common-width mb-10">
       <h2 class="section-header--country-page">我们的顾问</h2>
       <div class="d-flex consultant-box">
-        <div v-for="(val, i) in consultant" :key="i" class="consultant-item">
+        <div v-for="(val, i) in consultant" :key="i" class="consultant-item" @click="handleClick('consultant', val.id)">
           <div class="consultant-item__img" :style="{'background-image': `url('${val.avatar}')`, 'width': '100%', 'height': '100%' }"></div>
           <div class="consultant-item__name">{{ val.enName }}</div>
           <p class="consultant-item__country">{{ val.country }}</p>
@@ -65,7 +65,7 @@
       <div class="line"></div>
       <h2 class="text-center">我们的见解</h2>
       <div class="d-flex article-box">
-        <div v-for="(val, i) in articles" :key="i" class="article-box-item">
+        <div v-for="(val, i) in articles" :key="i" class="article-box-item" @click="handleClick('article', val.id)">
           <div class="sshr__article__flyout-placeholder"></div>
           <div class="sshr__article__content-wrapper active">
             <div class="sshr__article__picture">
@@ -138,15 +138,15 @@ const countryOffice = [
 ]
 // 我们的顾问
 const consultant = [
-  { country: '苏州', enName: '田森', avatar: 'https://cn.spencerstuart.com/-/media/consultant-photos-new/hong-kong/young_jeremy-web-5d.jpg' },
-  { country: '北京', enName: '潘青海', avatar: 'https://cn.spencerstuart.com/-/media/consultant-photos-new/singapore/ng_siewkiang-web-5a.jpg' },
-  { country: '北京', enName: '姚嘉庆', avatar: 'https://cn.spencerstuart.com/-/media/consultant-photos-new/hong-kong/au_alice-web-2d.jpg' }
+  { country: '苏州', enName: '田森先生(Simon Tian)', id: 'simon', avatar: 'https://minio.citupro.com/dev/menduner/consultant/simon.png' },
+  { country: '北京', enName: '潘青海先生(Peter Pan)', id: 'peter', avatar: 'https://minio.citupro.com/dev/menduner/consultant/peter.png' },
+  { country: '北京', enName: '姚嘉庆女士(Julie)', id: 'julie', avatar: 'https://minio.citupro.com/dev/menduner/consultant/julie.png' }
 ]
 // 我们的见解
 const articles = [
-  { title: '女性领导力崛起:《走进中国女性高管的职场现状》', url: 'https://cn.spencerstuart.com/-/media/2023/march/chinaneedsmorewomen/web-cnwomen-mar23.jpg' },
-  { title: '纷繁复杂世界中的领导力:为未来的首席执行官做好规划', url: 'https://cn.spencerstuart.com/-/media/2023/september/ceofuture/thmb-ceoofthefuture-sept2023-616x434.jpg' },
-  { title: '全球视角下的CFO角色演变:跨领域高管的洞察与应对策略', url: 'https://cn.spencerstuart.com/-/media/search-images/placeholder-hdr.jpg' }
+  { title: '女性领导力崛起:《走进中国女性高管的职场现状》', id: 'china-needs-more-women-in-executive-leadership', url: 'https://cn.spencerstuart.com/-/media/2023/march/chinaneedsmorewomen/web-cnwomen-mar23.jpg' },
+  { title: '纷繁复杂世界中的领导力:为未来的首席执行官做好规划', id: 'leadership-for-a-complex-world-planning-for-the-ceo-of-the-future', url: 'https://cn.spencerstuart.com/-/media/2023/september/ceofuture/thmb-ceoofthefuture-sept2023-616x434.jpg' },
+  { title: '全球视角下的CFO角色演变:跨领域高管的洞察与应对策略', id: 'the-evolution-of-the-cfo-role-from-a-global-perspective', url: 'https://cn.spencerstuart.com/-/media/search-images/placeholder-hdr.jpg' }
 ]
 // 联系我们
 const social = [
@@ -348,7 +348,7 @@ const handleClick = (type, id) => {
 }
 .consultant-box {
   width: 100%;
-  height: 200px;
+  height: 283px;
   .consultant-item {
     width: calc((100% - 60px) / 3);
     max-width: 350px;
@@ -360,19 +360,21 @@ const handleClick = (type, id) => {
     .consultant-item__img {
       background-repeat: no-repeat;
       background-position: center center;
-      background-size: cover;
+      background-size: contain;
     }
     .consultant-item__name {
       font-family: FFScalaWebBold, Georgia, Utopia, Charter, serif;
       font-weight: 700;
       color: #4c4c4e;
       margin-top: 10px;
+      margin-left: 55px;
     }
     .consultant-item__country {
       font-family: FFScalaWebItalic, Georgia, Utopia, Charter, sans-serif;
       font-style: italic;
       font-weight: 400;
       color: #818183;
+      margin-left: 55px;
     }
   }
 }

+ 1 - 1
src/views/headhunting/components/nav.vue

@@ -22,7 +22,7 @@ defineOptions({ name: 'headhunting-nav'})
 const emit = defineEmits(['click'])
 const navList = [
   { title: '我们的服务', path: '/headhunting/service' },
-  { title: '我们的顾问', path: '' },
+  // { title: '我们的顾问', path: '' },
   { title: '候选人', path: '' }
 ]
 </script>

+ 14 - 14
src/views/headhunting/components/serviceContent.vue

@@ -43,10 +43,10 @@
           </div>
           <div class="industry-item mt-5 industry-right ml-15 d-flex">
             <ul style="list-style-type: none; width: 50%;">
-              <li v-for="(val, index) in k.items1" :key="index" class="right-item">{{ val.title }}</li>
+              <li v-for="(val, index) in k.items1" :key="index" class="right-item" @click="handleClick('industry', val.id)">{{ val.title }}</li>
             </ul>
             <ul v-if="k?.items2" style="list-style-type: none; width: 50%;">
-              <li v-for="(val, index) in k.items2" :key="index" class="right-item">{{ val.title }}</li>
+              <li v-for="(val, index) in k.items2" :key="index" class="right-item" @click="handleClick('industry', val.id)">{{ val.title }}</li>
             </ul>
           </div>
         </div>
@@ -81,10 +81,10 @@ const list = [
     desc: '最佳的领导力决策,源于对行业动态及企业关键需求的深入理解。我们的顾问在各自领域拥有丰富的实践经验。凭借一套行之有效的高管寻访流程,我们能够帮助您找到与企业独特需求无缝对接,且适应竞争环境的领导者。凭借我们的经验、国际声望,以及与卓越领导者的深厚关系,我们能够在全球范围内寻觅炙手可热的候选人。',
     btnTitle: '查看所有行业',
     items1: [
-      { title: '酒店集团总部' },
-      { title: '业主公司' },
-      { title: '连锁酒店及公寓' },
-      { title: '单体酒店' }
+      { title: '酒店集团总部', id: 'hotel-group-headquarters' },
+      { title: '业主公司', id: 'owner-company' },
+      { title: '连锁酒店及公寓', id: 'chain-hotels-and-apartments' },
+      { title: '单体酒店', id: 'single-hotel' }
     ]
   },
   {
@@ -92,16 +92,16 @@ const list = [
     desc: '最佳的领导力决策,源于对行业动态及企业关键需求的深入理解。我们的顾问在各自领域拥有丰富的实践经验。凭借一套行之有效的高管寻访流程,我们能够帮助您找到与企业独特需求无缝对接,且适应竞争环境的领导者。凭借我们的经验、国际声望,以及与卓越领导者的深厚关系,我们能够在全球范围内寻觅炙手可热的候选人。',
     btnTitle: '查看所有职能',
     items1: [
-      { title: '新餐饮' },
-      { title: '高端民宿' },
-      { title: '高端康养' },
-      { title: '会展业' }
+      { title: '新餐饮', id: 'new-catering' },
+      { title: '高端民宿', id: 'high-end-cottage' },
+      { title: '高端康养', id: 'high-end-consumption' },
+      { title: '会展业', id: 'exhibition-industry' }
     ],
     items2: [
-      { title: '游轮产业' },
-      { title: '旅游及地产' },
-      { title: '体育与休闲业' },
-      { title: '医院与博物馆' }
+      { title: '游轮产业', id: 'cruise-industry' },
+      { title: '旅游及地产', id: 'tourism-and-real-estate' },
+      { title: '体育与休闲业', id: 'sports-and-leisure-industry' },
+      { title: '医院与博物馆', id: 'hospital-and-museum' }
     ]
   }
 ]

+ 5 - 1
src/views/headhunting/details.vue

@@ -2,7 +2,9 @@
   <div>
     <navBar @click="handleClickNav"></navBar>
     <div>
-      <service v-if="query.type === 'service'" :id="query.key" class="content-box"></service>
+      <service v-if="query.type === 'service' || query.type === 'industry'" :id="query.key" :type="query.type" class="content-box"></service>
+      <consultant v-if="query.type === 'consultant'" :id="query.key" class="content-box"></consultant>
+      <ArticleDrill v-if="query.type === 'article'" :id="query.key"></ArticleDrill>
     </div>
   </div>
 </template>
@@ -12,6 +14,8 @@ defineOptions({ name: 'headhuntingDetails' })
 import { useRouter } from 'vue-router'
 import navBar from './components/nav.vue'
 import service from './drill/service.vue'
+import consultant from './drill/consultant.vue'
+import ArticleDrill from './drill/article.vue'
 
 const router = useRouter()
 const query = router.currentRoute.value.query

+ 118 - 0
src/views/headhunting/drill/article.vue

@@ -0,0 +1,118 @@
+<template>
+  <div style="color: #4c4c4e;">
+    <div v-if="data.isBgFull" class="bg-full" :style="{'background-image': `url('${data.bgUrl}')`}">
+      <div class="width content-box">
+        <h1>{{ data.title }}</h1>
+      </div>
+    </div>
+    <div v-else class="half-bg">
+      <div class="half-bg-item half-bg-title">
+        <h1>{{ data.title }}</h1>
+      </div>
+      <div class="half-bg-item half-bg-img" :style="{'background-image': `url('${data.bgUrl}')`}"></div>
+    </div>
+    <div class="width">
+      <div v-if="id === 'china-needs-more-women-in-executive-leadership'">
+        <div v-for="(val, index) in data.desc" :key="index" class="mb-5">
+          <p v-if="!val.children">{{ val.text }}</p>
+          <div v-else>
+            <p>{{ val.text }}</p>
+            <ul class="ml-5 pa-5">
+              <li v-for="(k, i) in val.children" :key="i">{{ k }}</li>
+            </ul>
+          </div>
+        </div>
+      </div>
+      <!-- 全球视角下的CFO角色演变:跨领域高管的洞察与应对策略 -->
+      <div v-else-if="id === 'the-evolution-of-the-cfo-role-from-a-global-perspective'">
+        <p>{{ data.startText }}</p>
+        <div v-for="(val, index) in data.desc" :key="index">
+          <h2 class="first-title">{{ val.title }}</h2>
+          <h3 v-if="val?.subTitle" class="sub-title">{{ val.subTitle }}</h3>
+          <p v-for="(k, i) in val.children" :key="i" class="mb-7" v-html="k"></p>
+        </div>
+      </div>
+      <p v-else>{{ data.desc }}</p>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineOptions({ name: 'headhunting-drill-article'})
+import { ref } from 'vue'
+import { articleData } from '@/utils/headhuntingData'
+
+const props = defineProps({
+  id: {
+    type: String,
+    default: ''
+  }
+})
+
+const data = ref(articleData.find(e => e.id === props.id))
+
+</script>
+
+<style scoped lang="scss">
+.bg-full {
+  position: relative;
+  height: 363px;
+  background-position: center center;
+  background-repeat: no-repeat;
+  background-size: cover;
+  .content-box {
+    position: absolute;
+    bottom: 0;
+    left: 50%;
+    transform: translateX(-50%);
+    height: 241px;
+    padding: 64px 200px 0 95px;
+    background-color: #fff;
+    color: #000;
+  }
+}
+.width {
+  width: 1190px;
+  margin: 0 auto;
+  min-width: 1190px;
+  max-width: 1190px;
+  margin-top: 96px;
+}
+h1 {
+  font-family: FFScalaWeb, Georgia, Utopia, Charter, sans-serif;
+  font-style: normal;
+  font-weight: 400;
+  line-height: 1.134;
+  font-size: 52px;
+  margin-left: -3px;
+}
+.half-bg {
+  display: flex;
+  height: 520px;
+  .half-bg-item {
+    width: 50%;
+  }
+  .half-bg-title {
+    background-color: #202020;
+    color: #fff;
+    padding: 173px 93px;
+  }
+  .half-bg-img {
+    background-position: center center;
+    background-repeat: no-repeat;
+    background-size: cover;
+  }
+}
+.first-title {
+  color: #00695C;
+  font-size: 30px;
+  margin-top: 3rem;
+  margin-bottom: 15px;
+}
+.sub-title {
+  margin-bottom: 8px;
+  line-height: 1.4;
+  font-size: 24px;
+  color: #000;
+}
+</style>

+ 83 - 0
src/views/headhunting/drill/consultant.vue

@@ -0,0 +1,83 @@
+<template>
+  <div style="color: #4c4c4e;">
+    <div class="mt-5">
+      <v-breadcrumbs :items="paths" class="pa-0 ma-0"></v-breadcrumbs>
+    </div>
+    <div class="d-flex mt-10 justify-space-between">
+      <div>
+        <v-img :src="data.avatar" width="250" height="350"></v-img>
+        <p class="title-job">{{ data.job }}</p>
+      </div>
+      <div class="mt-10">
+        <h1 class="name">{{ data.title }}</h1>
+        <p class="tips mt-3">Consultant</p>
+        <p class="country">{{ data.country }}</p>
+        <ul v-if="data?.edu" class="mt-5">
+          <li v-for="(edu, index) in data.edu" :key="index">{{ edu }}</li>
+        </ul>
+      </div>
+    </div>
+    <div class="mt-10" style="flex: 1;">
+      <p v-for="(k, i) in data.desc" :key="i" class="desc">{{ k }}</p>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineOptions({ name: 'headhunting-drill-consultant'})
+import { ref } from 'vue'
+import { consultantData } from '@/utils/headhuntingData'
+
+const props = defineProps({
+  id: {
+    type: String,
+    default: ''
+  }
+})
+
+const data = ref(consultantData.find(e => e.id === props.id))
+
+const paths = [
+  { title: '主页', disabled: false, href: '/headhunting' },
+  { title: '我们的顾问', disabled: true, href: '' },
+  { title: data.value.title, disabled: true, href: '' }
+]
+</script>
+
+<style scoped lang="scss">
+.info {
+  width: 400px;
+}
+.desc {
+  margin-bottom: 20px;
+}
+li {
+  list-style-type: none;
+}
+.title-name {
+  font-family: FFScalaWebBold, Georgia, Utopia, Charter, serif;
+  font-weight: 700;
+}
+.title-job {
+  font-family: FFScalaWebItalic, Georgia, Utopia, Charter, sans-serif;
+  font-style: italic;
+  font-weight: 400;
+}
+.name {
+  font-family: FFScalaWebBold, Georgia, Utopia, Charter, sans-serif;
+  font-style: normal;
+  font-weight: 700;
+  color: #00695C;
+}
+.tips {
+  font-family: FFScalaWebItalic, Georgia, Utopia, Charter, serif;
+  font-style: italic;
+  font-weight: 400;
+}
+.country {
+  font-family: FFScalaWebItalic, Georgia, Utopia, Charter, sans-serif;
+  font-style: italic;
+  font-weight: 400;
+  color: #00695C;
+}
+</style>

+ 7 - 3
src/views/headhunting/drill/service.vue

@@ -6,10 +6,10 @@
     <h1 class="my-5">{{ data.title }}</h1>
     <p class="font-weight-bold">{{ data.startDesc }}</p>
     <p v-if="data.startTitle" class="font-weight-bold mt-1">{{ data.startTitle }}</p>
-    <ul>
+    <ul v-if="data?.children && data?.children.length">
       <li v-for="(val, index) in data.children" :key="index">{{ val }}</li>
     </ul>
-    <p>{{ data.endDesc }}</p>
+    <p v-if="data?.endDesc" :class="{'mt-5': type !== 'service'}">{{ data.endDesc }}</p>
   </div>
 </template>
 
@@ -22,13 +22,17 @@ const props = defineProps({
   id: {
     type: String,
     default: ''
+  },
+  type: {
+    type: String,
+    default: ''
   }
 })
 
 const data = ref(serviceData.find(e => e.id === props.id))
 
 const paths = [
-  { title: '页', disabled: false, href: '/headhunting' },
+  { title: '页', disabled: false, href: '/headhunting' },
   { title: '我们的服务', disabled: false, href: '/headhunting/service' },
   { title: data.value.title, disabled: true, href: '' }
 ]

+ 12 - 4
src/views/login/components/editPassword.vue

@@ -23,7 +23,7 @@
         prepend-inner-icon="mdi-lock-outline" 
         :append-inner-icon="show ? 'mdi-eye-outline' : 'mdi-eye-off-outline'"
         :type="show ? 'text' : 'password'"
-        :rules="[v=> !!v || '请再次输入新密码', passwordCheck]"
+        :rules="secondaryConfirmation"
         @click:append-inner="show = !show"
       ></v-text-field>
     </v-form>
@@ -67,14 +67,22 @@ const loading = ref(false)
 const passwordType = ref(false)
 const phoneRef = ref()
 
+const secondaryConfirmation = ref([
+  value => {
+    if (value) return true
+    return '请再次输入密码'
+  },
+  value => {
+    if (value === query.password) return true
+    return '两次输入密码不一致'
+  }
+])
+
 // 密码效验
 const regex = /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,16}$/
 const validPassword = computed(() => {
   return regex.test(query.password) || '请输入8-16位数由数字、大小写字母组成的密码'
 })
-const passwordCheck = computed(() => {
-  return (query.checkPassword === query.password && regex.test(query.checkPassword)) || '两次密码输入不一致'
-})
 
 
 const handleClose = () => {

+ 11 - 2
src/views/login/index.vue

@@ -52,6 +52,7 @@ import { useUserStore } from '@/store/user'
 import { useRouter } from 'vue-router'
 import { useI18n } from '@/hooks/web/useI18n'
 import Snackbar from '@/plugins/snackbar'
+import Confirm from '@/plugins/confirm'
 defineOptions({ name: 'login-index' })
 
 const { t } = useI18n()
@@ -85,8 +86,16 @@ const handleLogin = async () => {
     if (params.isEnterprise) return // 企业邮箱登录
     Snackbar.success(t('login.loginSuccess'))
     router.push({ path: '/recruitHome' })
-  }
-  finally {
+
+  } catch (err) {
+    const text = err.code === 1100016002 ? '您的手机号还未注册过' : '您的邮箱还未注册过'
+    Confirm('系统提示',  `${text},去注册?`, {
+      cancelCallback: true
+    }).then(() => {
+      localStorage.setItem('loginAccount', tab.value === 1 ? phoneRef.value.loginData.phone : passRef.value.loginData.phone)
+      router.push(err.code === 1100016002 ? '/register/person?type=noLoginToRegister' : '/register/company?type=noLoginToRegister')
+    })
+  } finally {
     loginLoading.value = false
   }
 }

+ 7 - 2
src/views/recruit/components/message/components/chatting.vue

@@ -103,7 +103,7 @@
             <div style="width: 40px; height: 40px;">
               <v-avatar>
                 <v-img
-                  :src="(val.from_uid === IM.uid ? mAvatar : getUserAvatar(info.avatar, info.sex)) || 'https://minio.citupro.com/dev/menduner/7.png'"
+                  :src="(val.from_uid === IM.uid ? mAvatar() : getUserAvatar(info.avatar, info.sex)) || 'https://minio.citupro.com/dev/menduner/7.png'"
                   :width="40"
                   height="40"
                   rounded
@@ -286,7 +286,12 @@ const IM = useIMStore()
 const userStore = useUserStore()
 const loading = ref(false)
 
-const mAvatar = getUserAvatar(userStore.baseInfo?.avatar, userStore.baseInfo?.sex)
+const mAvatar = () => {
+  if (isEnterprise) {
+    return getUserAvatar(userStore.entBaseInfo?.avatar, userStore.entBaseInfo?.sex)
+  }
+  return getUserAvatar(userStore.baseInfo?.avatar, userStore.baseInfo?.sex)
+}
 
 const chatRef = ref()
 const inputVal = ref('')

+ 9 - 5
src/views/recruit/components/message/index.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="default-width message" :style="`height: calc(100vh - ${isEnterprise ? '130px' : '50px'});`">
-    <div class="message-left">
+    <div class="message-left d-flex flex-column">
       <div class="message-left-search d-flex align-center px-3 justify-space-between" >
         <div>
           <v-icon class="mr-3">mdi-history</v-icon>
@@ -171,7 +171,7 @@ const chatRef = ref()
 
 const IM = useIMStore()
 // 自己的信息
-const { baseInfo } = useUserStore()
+const { entBaseInfo } = useUserStore()
 
 const isEnterprise = inject('isEnterprise')
 // 实例
@@ -270,7 +270,7 @@ const updateUnreadMessageCount = (val) => {
   const obj = val.find(e => e.userInfoVo.userId === info.value.userId)
   if (!obj?.unread || obj.unread === 0) return
   delete info.value.unread
-  Object.assign(info.value, { unread: obj.unread, enterpriseId: baseInfo?.enterpriseId })
+  Object.assign(info.value, { unread: obj.unread, enterpriseId: entBaseInfo?.enterpriseId })
 }
 
 watch(
@@ -313,7 +313,7 @@ async function handleChange (items) {
     hasMore.value = more
     chatRef.value.scrollBottom()
     // 点开窗口消除未读数量
-    await resetUnread(channel.value, baseInfo?.enterpriseId)
+    await resetUnread(channel.value, entBaseInfo?.enterpriseId)
     await updateConversation()
     updateUnreadCount()
   } catch (error) {
@@ -425,7 +425,7 @@ const handleGetMore = async () => {
 }
 
 const handleDelete = async ({ channel }) => {
-  await deleteConversations(channel, baseInfo?.enterpriseId)
+  await deleteConversations(channel, entBaseInfo?.enterpriseId)
   await updateConversation()
   updateUnreadCount()
 }
@@ -535,6 +535,10 @@ const handleRefuse = (val) => {
       border-radius: 8px 8px 0 0;
     }
     .message-chat-box {
+      height: 0;
+      flex: 1;
+      overflow: auto;
+      padding-bottom: 20px;
       .chat-item {
         position: relative;
         width: 100%;

+ 28 - 10
src/views/recruit/entRegister/register.vue

@@ -3,7 +3,7 @@
     <v-card class="pa-5" :class="isMobile? 'mobileBox' : 'default-width'" :elevation="isMobile? '0' : '3'">
       <!-- 标题 -->
       <div class="mt-3">
-        <v-btn v-if="pageType !== 'noLoginToRegister'" color="black" variant="text" @click="router.push('/recruitHome')">{{ `<< 回到首页` }}</v-btn>
+        <v-btn v-if="pageType !== 'noLoginToRegister'" color="primary" variant="text" @click="router.push('/recruitHome')">{{ `<< 回到首页` }}</v-btn>
         <div v-else style="height: 30px;"></div>
       </div>
       <!-- 表单 -->
@@ -72,8 +72,9 @@ const loginLoading = ref(false)
 // 图片预览
 const showPreview = ref(false)
 const current = ref(0)
+const email = localStorage.getItem('loginAccount') || ''
 
-// 组件挂载后添加事件监听器  
+// 组件挂载后添加事件监听器
 const isMobile = ref(false)
 onMounted(() => {
   const userAgent = navigator.userAgent
@@ -94,6 +95,18 @@ const isPrepareChange = () => {
   }
 }
 
+const handleSecondConfirm = () => {
+  const obj = formItems.value.options.find(e => e.key === 'passwordConfirm')
+  obj.type = obj.type === 'password' ? 'text' : 'password'
+  obj.appendInnerIcon = obj.type === 'password' ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
+}
+
+const handlePassword = () => {
+  const obj = formItems.value.options.find(e => e.key === 'password')
+  obj.type = obj.type === 'password' ? 'text' : 'password'
+  obj.appendInnerIcon = obj.type === 'password' ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
+}
+
 const formItems = ref({
   options: [
     {
@@ -132,7 +145,7 @@ const formItems = ref({
     {
       type: 'text',
       key: 'email',
-      value: '',
+      value: email ? email : '',
       label: '联系邮箱(可用于企业招聘登录) *',
       rules: [
         value => {
@@ -146,36 +159,40 @@ const formItems = ref({
       ]
     },
     {
-      type: 'text',
+      type: 'password',
       key: 'password',
       value: '',
+      appendInnerIcon: 'mdi-eye-off-outline',
       label: '邮箱登录密码(用于企业招聘邮箱登录) *',
       placeholder: '请输入邮箱登录密码(用于企业招聘邮箱登录)',
+      appendInnerClick: handlePassword,
       rules: [
         value => {
           if (value) return true
           return '请输入邮箱登录密码(用于企业招聘邮箱登录)'
         },
         value => {
-          if (!(/^[\s]+$/.test(value))) return true
-          return '请输入邮箱登录密码(用于企业招聘邮箱登录)'
+          if (/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,16}$/.test(value)) return true
+          return '请输入8-16位数由数字、大小写字母组成的密码'
         }
       ]
     },
     {
-      type: 'text',
+      type: 'password',
       key: 'passwordConfirm',
       value: '',
+      appendInnerIcon: 'mdi-eye-off-outline',
       label: '请再次输入邮箱登录密码 *',
       placeholder: '请再次输入邮箱登录密码',
+      appendInnerClick: handleSecondConfirm,
       rules: [
         value => {
           if (value) return true
-          return '请再次输入邮箱登录密码'
+          return '请再次输入密码(用于企业招聘邮箱登录)'
         },
         value => {
-          if (!(/^[\s]+$/.test(value))) return true
-          return '请再次输入邮箱登录密码'
+          if (value === formItems.value.options.find(e => e.key === 'password').value) return true
+          return '两次输入密码不一致'
         }
       ]
     },
@@ -211,6 +228,7 @@ const handleCommit = async () => {
   if (params.password !== params.passwordConfirm) return Snackbar.warning('两次输入的密码不一致,请确认')
 
   await enterpriseRegisterApply(params)
+  localStorage.removeItem('loginAccount')
   Snackbar.success(t('common.submittedSuccessfully'))
   router.push({ path: '/recruit/entRegister/inReview' })
 }

+ 12 - 2
src/views/recruit/enterprise/entInfoSetting/informationSettingsComponents/authentication.vue

@@ -33,18 +33,20 @@
       <CtForm ref="CtFormRef" :items="formItems" style="width: 300px;">
         <template #backUrl="{ item }">
           <div class="color-666 font-size-14 mr-5">{{ item.label }}</div>
-          <Img :value="item.value" @success="val => item.value = val" @delete="item.value = ''"></Img>
+          <Img :value="item.value" @success="val => item.value = val" @delete="item.value = ''" @imgClick="handlePreview(item.value)"></Img>
         </template>
         <template #frontUrl="{ item }">
           <div class="mt-5 d-flex">
             <div class="color-666 font-size-14 mr-5">{{ item.label }}</div>
-            <Img :value="item.value" @success="val => item.value = val" @delete="item.value = ''"></Img>
+            <Img :value="item.value" @success="val => item.value = val" @delete="item.value = ''" @imgClick="handlePreview(item.value)"></Img>
           </div>
         </template>
       </CtForm>
       <v-btn class="buttons mt-5" color="primary" @click="handleSave">{{ $t('common.submit') }}</v-btn>
     </div>
   </div>
+
+  <PreviewImg v-if="showPreview" :list="imgList" @close="showPreview = !showPreview"></PreviewImg>
 </template>
 
 <script setup>
@@ -116,6 +118,14 @@ const getData = async () => {
 }
 getData()
 
+// 图片预览
+const showPreview = ref(false)
+const imgList = ref([])
+const handlePreview = (url) => {
+  imgList.value = [url]
+  showPreview.value = true
+}
+
 const handleAgain = () => {
   formItems.value.options.forEach(item => {
     item.value = info.value[item.key]

+ 1 - 1
src/views/recruit/enterprise/entInfoSetting/informationSettingsComponents/welfareLabel.vue

@@ -76,7 +76,7 @@ const appendInnerClick = (value) => {
 // 保存
 const handleSave = async (type) => {
   try {
-    const appAdminEnterpriseWelfareReqVO = { tagList: chosen.value }
+    const appAdminEnterpriseWelfareReqVO = { welfareList: chosen.value }
     await updateEnterpriseWelfare(appAdminEnterpriseWelfareReqVO)
     customTag.value = false
     Snackbar.success(`${type}成功`)

+ 5 - 1
src/views/recruit/enterprise/hirePosition/components/add.vue

@@ -17,7 +17,7 @@
       </v-timeline>
       <div class="text-center mb">
         <v-btn class="half-button mr-3" color="primary" variant="outlined" @click="handleCancel()">{{ $t('common.cancel') }}</v-btn>
-        <v-btn class="half-button" color="primary" @click="handleSave">{{ $t('common.release') }}</v-btn>
+        <v-btn class="half-button" color="primary" :loading="loading" @click="handleSave">{{ $t('common.release') }}</v-btn>
       </div>
     </v-card>
   </div>
@@ -42,6 +42,7 @@ const userStore = useUserStore()
 const baseInfoRef = ref()
 const jobRequirementsRef = ref()
 const itemData = ref({})
+const loading = ref(false)
 
 const list = [
   {
@@ -79,6 +80,7 @@ const handleSave = async () => {
 }
 
 const saveEmit = async () => {
+  loading.value = true
   try {
     const res = await saveJobAdvertised(submitParams)
     Snackbar.success(submitParams.id ? t('common.editSuccessMsg') : t('common.publishSuccessMsg1'))
@@ -92,6 +94,8 @@ const saveEmit = async () => {
     handleCancel()
   } catch (error) {
     console.log('error', error)
+  } finally {
+    loading.value = false
   }
 }
 

+ 11 - 3
src/views/recruit/enterprise/hirePosition/components/item.vue

@@ -50,6 +50,8 @@
     @paySuccess="paySuccess"
     @close="showConfirmPaymentDialog = false"
   ></confirmPaymentDialog>
+
+  <Loading :visible="loading"></Loading>
 </template>
 
 <script setup>
@@ -69,6 +71,7 @@ defineProps({
   items: Array
 })
 
+const loading = ref(false)
 const showConfirmPaymentDialog = ref(false)
 const cost = ref(0)
 const spuId = ref('')
@@ -95,9 +98,14 @@ const apiList = [ closeJobAdvertised, enableJobAdvertised ]
 const handleAction = async (index, { id }) => {
   const ids = [id]
   if (!ids.length && !index) return
-  await apiList[index](ids)
-  Snackbar.success(t('common.operationSuccessful'))
-  emit('refresh')
+  loading.value = true
+  try {
+    await apiList[index](ids)
+    Snackbar.success(t('common.operationSuccessful'))
+    emit('refresh')
+  } finally {
+    loading.value = false
+  }
 }
 
 const router = useRouter()

+ 9 - 3
src/views/recruit/enterprise/hirePosition/index.vue

@@ -6,7 +6,7 @@
       </div>
       <div class="text-end">
         <v-btn prepend-icon="mdi-plus" color="primary" @click="handleAdd">{{ $t('position.newPositionsAdded') }}</v-btn>
-        <v-btn prepend-icon="mdi-export-variant" color="primary" variant="tonal" class="ml-3" @click="handleExport">职位列表下载</v-btn>
+        <v-btn :loading="exportLoading" prepend-icon="mdi-export-variant" color="primary" variant="tonal" class="ml-3" @click="handleExport">职位列表下载</v-btn>
       </div>
       
       <div class="mt-3">
@@ -46,6 +46,7 @@ const query = ref({
   pageNo: 1,
   hire: true
 })
+const exportLoading = ref(false)
 
 const items = ref([])
 const textItem = ref({
@@ -63,8 +64,13 @@ const handleAdd = async () => {
 }
 
 const handleExport = async () => {
-  const data = await getJobAdvertisedExport(query.value)
-  download.excel(data, '众聘职位列表')
+  exportLoading.value = true
+  try {
+    const data = await getJobAdvertisedExport(query.value)
+    download.excel(data, '众聘职位列表')
+  } finally {
+    exportLoading.value = false
+  }
 }
 
 

+ 6 - 0
src/views/recruit/enterprise/interviewManagement/components/invite.vue

@@ -6,6 +6,7 @@
         placeholder="面试时间 *"
         class="mb-4"
         model-type="timestamp"
+        :disabled-dates="disabledDates"
         :text-input="{ format: 'MM.dd.yyyy HH:mm' }" />
     </template>
   </CtForm>
@@ -26,6 +27,11 @@ const props = defineProps({
   }
 })
 
+// 过去的日期不可选
+const disabledDates = (date) => {
+  return date.getTime() < new Date().getTime()
+}
+
 const CtFormRef = ref()
 const formItems = ref({
   options: [

+ 5 - 1
src/views/recruit/enterprise/positionManagement/components/add.vue

@@ -17,7 +17,7 @@
       </v-timeline>
       <div class="text-center mb">
         <v-btn class="half-button mr-3" color="primary" variant="outlined" @click="handleCancel()">{{ $t('common.cancel') }}</v-btn>
-        <v-btn class="half-button" color="primary" @click="handleSave">{{ $t('common.release') }}</v-btn>
+        <v-btn class="half-button" color="primary" :loading="loading" @click="handleSave">{{ $t('common.release') }}</v-btn>
       </div>
     </v-card>
   </div>
@@ -41,6 +41,7 @@ const router = useRouter()
 const userStore = useUserStore()
 const baseInfoRef = ref()
 const jobRequirementsRef = ref()
+const loading = ref(false)
 const itemData = ref({})
 
 const list = [
@@ -78,12 +79,15 @@ const handleSave = async () => {
 }
 
 const saveEmit = async () => {
+  loading.value = true
   try {
     await saveJobAdvertised(submitParams)
     Snackbar.success(submitParams.id ? t('common.editSuccessMsg') : t('common.publishSuccessMsg'))
     handleCancel()
   } catch (error) {
     console.log('error', error)
+  } finally {
+    loading.value = false
   }
 }
 

+ 14 - 6
src/views/recruit/enterprise/positionManagement/components/item.vue

@@ -53,6 +53,8 @@
       </div>
     </div>
   </div>
+
+  <Loading :visible="loading"></Loading>
 </template>
 
 <script setup>
@@ -74,6 +76,7 @@ const props = defineProps({
   items: Array
 })
 
+const loading = ref(false)
 const selectAll = ref(false) // 全选
 const selectList = ref([]) // 选中列表
 const dealSelect = () => {
@@ -125,12 +128,17 @@ const apiList = [ closeJobAdvertised, enableJobAdvertised, refreshJobAdvertised,
 const handleAction = async (index, type, { id }) => {
   const ids = type ? props.items.filter(e => e.select).map(k => k.id) : [id]
   if (!ids.length && !index) return
-  await apiList[index](ids)
-  Snackbar.success(t('common.operationSuccessful'))
-  // 清空选项
-  selectList.value = []
-  selectAll.value = false
-  emit('refresh')
+  loading.value = true
+  try {
+    await apiList[index](ids)
+    Snackbar.success(t('common.operationSuccessful'))
+    // 清空选项
+    selectList.value = []
+    selectAll.value = false
+    emit('refresh')
+  } finally {
+    loading.value = false
+  }
 }
 
 const router = useRouter()

+ 11 - 5
src/views/recruit/enterprise/positionManagement/index.vue

@@ -6,7 +6,7 @@
       </div>
       <div class="text-end">
         <v-btn prepend-icon="mdi-plus" color="primary" @click="handleAdd">{{ $t('position.newPositionsAdded') }}</v-btn>
-        <v-btn prepend-icon="mdi-export-variant" color="primary" variant="tonal" class="ml-3" @click="handleExport">职位列表下载</v-btn>
+        <v-btn :loading="exportLoading" prepend-icon="mdi-export-variant" color="primary" variant="tonal" class="ml-3" @click="handleExport">职位列表下载</v-btn>
       </div>
       
       <div class="mt-3">
@@ -55,6 +55,7 @@ const query = ref({
   // hasExpiredData: false, // true 到期职位
   hire: false // true 众聘岗位
 })
+const exportLoading = ref(false)
 
 const tab = ref(1)
 
@@ -80,10 +81,15 @@ const handleAdd = async () => {
 }
 
 const handleExport = async () => {
-  const data = await getJobAdvertisedExport(query.value)
-  const label = tabList.find(e => e.value === tab.value)?.label || ''
-  const txt = `职位列表${label? '(' + label + ')' : ''}`
-  download.excel(data, txt)
+  exportLoading.value = true
+  try {
+    const data = await getJobAdvertisedExport(query.value)
+    const label = tabList.find(e => e.value === tab.value)?.label || ''
+    const txt = `职位列表${label? '(' + label + ')' : ''}`
+    download.excel(data, txt)
+  } finally {
+    exportLoading.value = false
+  }
 }
 
 

+ 0 - 0
src/views/recruit/enterprise/talentRecruitment/components/commonStyle.vue → src/views/recruit/enterprise/resume/components/commonStyle.vue


+ 0 - 0
src/views/recruit/enterprise/talentRecruitment/components/invite.vue → src/views/recruit/enterprise/resume/components/invite.vue


+ 0 - 0
src/views/recruit/enterprise/talentRecruitment/components/public.vue → src/views/recruit/enterprise/resume/components/public.vue


+ 0 - 0
src/views/recruit/enterprise/talentRecruitment/components/screen.vue → src/views/recruit/enterprise/resume/components/screen.vue


+ 0 - 0
src/views/recruit/enterprise/talentRecruitment/components/table.vue → src/views/recruit/enterprise/resume/components/table.vue


+ 0 - 0
src/views/recruit/enterprise/talentRecruitment/index.vue → src/views/recruit/enterprise/resume/index.vue


+ 4 - 1
src/views/recruit/enterprise/talentPool/components/details/baseInfo.vue

@@ -59,7 +59,10 @@
       </div>
       <div class="mt-4">
         <span style="font-size: 15px;">个人画像:</span>
-        <v-chip size="small" label v-for="(k, i) in ['响应', '改变', '诚信', '进取精神', '信任', '卓越']" :key="i" class="mr-2" color="primary">{{ k }}</v-chip>
+        <span v-if="info?.tagList && info?.tagList.length > 0">
+          <v-chip size="small" label v-for="(k, i) in info.tagList" :key="i" class="mr-2 mb-2" color="primary">{{ k }}</v-chip>
+        </span>
+        <span v-else>暂无</span>
       </div>
     </div>
   </div>

+ 1 - 1
src/views/recruit/personal/PersonalCenter/jobFeedback/components/companyCollection.vue

@@ -9,7 +9,7 @@
         @handleChange="handleChangePage"
       ></CtPagination>
     </div>
-    <Empty v-else></Empty>
+    <Empty v-else :elevation="false"></Empty>
   </div>
 </template>
 

+ 1 - 1
src/views/recruit/personal/PersonalCenter/jobFeedback/components/delivery.vue

@@ -9,7 +9,7 @@
         @handleChange="handleChangePage"
       ></CtPagination>
     </div>
-    <Empty v-else></Empty>
+    <Empty v-else :elevation="false"></Empty>
   </div>
 </template>
 

+ 1 - 1
src/views/recruit/personal/PersonalCenter/jobFeedback/components/interview/index.vue

@@ -14,7 +14,7 @@
           @handleChange="handleChangePage"
         ></CtPagination>
       </div>
-      <Empty v-else class="mt-3"></Empty>
+      <Empty v-else class="mt-3" :elevation="false"></Empty>
   </div>
 </template>
 

+ 21 - 15
src/views/recruit/personal/PersonalCenter/jobFeedback/components/interview/item.vue

@@ -1,14 +1,18 @@
 <template>
-  <div class="position-item mb-3 job-closed elevation-2" v-for="(val, i) in props.items" :key="i" @mouseenter="val.active = true" @mouseleave="val.active = false">
-      <div class="info-header">
-        <div v-if="val.active && val.status === '0'" class="header-btn">
-          <v-btn color="primary" size="small" @click="handleAgree(val)">同意</v-btn>
-          <v-btn class="ml-3" color="error" size="small" @click="handleRefuse(val)">拒绝</v-btn>
-        </div>
-        <div v-if="tab === '1' || tab === '98'" class="float-right font-size-13" :style="{'padding': '12px 12px 0 0', 'color': tab === '1' ? 'var(--v-primary-base)' : 'var(--v-error-base)'}">
-          您已于{{ timesTampChange(val.updateTime, 'Y-M-D h:m') }}{{ tab === '1' ? '接受' : '拒绝'}}了此面试邀请
-        </div>
-        <div class="img-box">
+  <div class="position-item mb-3 job-closed elevation-2"
+    style="position: relative;"
+    v-for="(val, i) in props.items" :key="i" @mouseenter="val.active = true" @mouseleave="val.active = false"
+  >
+    <div class="info-header">
+      <div v-if="val.active && val.status === '0' && val.job.status === '0'" class="header-btn">
+        <v-btn color="primary" size="small" @click="handleAgree(val)">同意</v-btn>
+        <v-btn class="ml-3" color="error" size="small" @click="handleRefuse(val)">拒绝</v-btn>
+      </div>
+      <div v-if="val.job.status === '1'" class="font-size-14 header-btn color-error">职位已关闭</div>
+      <!-- <div v-if="tab === '1' || tab === '98'" class="float-right font-size-13" :style="{'padding': '12px 12px 0 0', 'color': tab === '1' ? 'var(--v-primary-base)' : 'var(--v-error-base)'}">
+        您已于{{ timesTampChange(val.updateTime, 'Y-M-D h:m') }}{{ tab === '1' ? '接受' : '拒绝'}}了此面试邀请
+      </div> -->
+      <div class="img-box">
           <v-avatar :image="getUserAvatar(val.contact.avatar, val.contact.sex)" size="x-small"></v-avatar>
           <span class="name">
             <span class="mx-3">{{ val.contact.name }}</span>
@@ -16,16 +20,17 @@
             <span v-if="val.invitePhone" class="septal-line"></span>
             <span class="gray">{{ val.invitePhone }}</span>
           </span>
-        </div>
       </div>
-      <div class="info-content">
+    </div>
+    <div class="info-content">
         <div class="font-size-16 color-333 mr-5" style="width: 322px;">
+          <div v-if="tab === '1' || tab === '98'" class="font-size-13 mb-1" :style="{'color': tab === '1' ? 'var(--v-primary-base)' : 'var(--v-error-base)'}">您已于{{ timesTampChange(val.updateTime, 'Y-M-D h:m') }}{{ tab === '1' ? '接受' : '拒绝'}}了此面试邀请</div>
           <div>面试时间:{{ timesTampChange(val.time, 'Y-M-D h:m') }}</div>
           <div class="mt-3 ellipsis" style="max-width: 322px;">面试地点:{{ val.address }}</div>
         </div>
         <div class="job-info color-666">
           <div class="job-name ellipsis" style="max-width: 410px;">
-            <span class="mr-3 cursor-pointer position-name" @click="handleToPositionDetails(val)">{{ val.job.name }}</span>
+            <span class="mr-3" :class="{'cursor-pointer': val.job.status === '0', 'position-name': val.job.status === '0'}" @click="handleToPositionDetails(val)">{{ val.job.name }}</span>
             <span v-if="!val.job.payFrom && !val.job.payTo">面议</span>
             <span v-else>{{ val.job.payFrom ? val.job.payFrom + '-' : '' }}{{ val.job.payTo }}{{ val.job.payName ? '/' + val.job.payName : '' }}</span>
           </div>
@@ -43,7 +48,7 @@
             </div>
           </div>
         </div>
-      </div>
+    </div>
   </div>
 </template>
 
@@ -81,6 +86,7 @@ const handleToEnterprise = (item) => {
 
 // 职位详情
 const handleToPositionDetails = (item) => {
+  if (item.job.status === '1') return
   router.push(`/recruit/personal/position/details/${item.job.id}`)
 }
 
@@ -116,7 +122,7 @@ const handleRefuse = (val) => {
 
 <style scoped lang="scss">
 .position-item {
-  height: 144px;
+  height: 160px;
   background-color: #fff;
   border-radius: 12px;
   &:hover {

+ 1 - 1
src/views/recruit/personal/PersonalCenter/jobFeedback/components/positionCollection.vue

@@ -9,7 +9,7 @@
         @handleChange="handleChangePage"
       ></CtPagination>
     </div>
-    <Empty v-else></Empty>
+    <Empty v-else :elevation="false"></Empty>
   </div>
 </template>
 

+ 1 - 1
src/views/recruit/personal/PersonalCenter/jobFeedback/components/seenMe.vue

@@ -33,7 +33,7 @@
         @handleChange="handleChangePage"
       ></CtPagination>
     </div>
-    <Empty v-else></Empty>
+    <Empty v-else :elevation="false"></Empty>
   </div>
 </template>
 

+ 3 - 2
src/views/recruit/personal/PersonalCenter/resume/attachment/index.vue

@@ -1,13 +1,14 @@
 <template>
   <div class="resume-box">
-    <div class="resume-header mb-3">
+    <div class="resume-header">
       <div class="resume-title">附件简历</div>
       <v-btn variant="text" color="primary" prepend-icon="mdi-plus-box" @click="openFileInput">
         上传
         <File ref="uploadFile" @success="handleUploadResume"></File>
       </v-btn>
     </div>
-    <div v-if="attachmentList.length">
+    <p class="font-size-14 color-999">最多只能上传5份附件简历</p>
+    <div v-if="attachmentList.length" class="mt-5">
       <div
         :class="['position-item', 'mx-n2', 'px-2']" 
         v-for="(k, i) in attachmentList" 

+ 20 - 15
src/views/recruit/personal/PersonalCenter/resume/online/components/basicInfo.vue

@@ -97,9 +97,9 @@
               </div>
             </div>
           </div>
+          <!-- 个人画像 -->
           <div class="mt-4 ml-50">
-            <span style="font-size: 15px;">个人画像:</span>
-            <v-chip size="small" label v-for="(k, i) in welfareList.slice(0, 8)" :key="i" class="mr-2" color="primary">{{ k }}</v-chip>
+            <portrait></portrait>
           </div>
         </div>
       </div>
@@ -122,6 +122,8 @@ import { useUserStore } from '@/store/user'
 import { uploadFile } from '@/api/common'
 import { getUserAvatar } from '@/utils/avatar'
 import { useI18n } from '@/hooks/web/useI18n'
+import portrait from './portrait.vue'
+import { checkEmail } from '@/utils/validate'
 import { ref } from 'vue';
 defineOptions({name: 'resume-components-basicInfo'})
 const emit = defineEmits(['complete'])
@@ -131,8 +133,8 @@ const userStore = useUserStore()
 const CtFormRef = ref()
 const isEdit = ref(false)
 const showIcon = ref(false)
+let completeStatus = false
 const overlay = ref(false) // 加载中
-const welfareList = ref(['响应', '改变', '诚信', '进取精神', '信任', '卓越'])
 let baseInfo = ref({})
 let userInfo = ref({})
 const getBasicInfo = () => { // 获取基础信息
@@ -140,6 +142,10 @@ const getBasicInfo = () => { // 获取基础信息
   if (!key || !Object.keys(key).length) return
   baseInfo.value = JSON.parse(key) // 人才信息
   userInfo.value = JSON.parse(localStorage.getItem('userInfo'))
+  if (baseInfo.value && Object.keys(baseInfo.value).length) {
+    completeStatus = true
+    emit('complete', { status: completeStatus, id: 'basicInfo' })
+  }
 }
 getBasicInfo()
 
@@ -252,7 +258,16 @@ const items = ref({
       label: '常用邮箱 *',
       col: 6,
       outlined: true,
-      rules: [v => !!v || '请输入常用邮箱']
+      rules: [
+        value => {
+          if (value) return true
+          return '请输入常用邮箱'
+        },
+        value => {
+          if (checkEmail(value)) return true
+          return '请输入正确的电子邮箱'
+        }
+      ]
     },
     {
       type: 'autocomplete',
@@ -411,10 +426,9 @@ const handleSave = async () => {
   await saveResumeBasicInfo(obj)
   Snackbar.success(t('common.saveMsg'))
   isEdit.value = false
-  // 获取当前登录账户信息
-  // if (baseInfo.value.userId) await userStore.getUserBaseInfos(baseInfo.value.userId)
   await userStore.getUserBaseInfos(baseInfo.value.userId || null)
   getBasicInfo()
+
   // 清除户籍地:省
   if (clearRegProvinceId) items.value.options.forEach(e => { if (e.key === 'regProvinceId') e.value = null })
 }
@@ -448,18 +462,14 @@ const deal = async (id, cityKey, provinceKey) => {
   }
 }
 
-let completeStatus = false
 items.value.options.forEach((e, index) => {
   if ((index + 2) % 2 === 0) e.flexStyle = 'mr-3'
   if (e.dictTypeName) getDictData(e.dictTypeName) // 查字典set options
-  // formItems回显
   const infoExist = baseInfo.value && Object.keys(baseInfo.value).length
-  if (infoExist) completeStatus = true
   if (infoExist && baseInfo.value[e.key]) e.value = baseInfo.value[e.key]
   // 日期相关
   if (e.type === 'datepicker') e.value = timesTampChange(e.value, 'Y-M-D')
   // 所在城市回显
-  // if (infoExist && e.nameKey) e[e.nameKey] = baseInfo.value[e.nameKey]
   if (infoExist && e.key === 'areaId' && baseInfo.value[e.key]) {
     const id = baseInfo.value[e.key]
     deal(id, e.key, 'workAreaProvinceId')
@@ -471,7 +481,6 @@ items.value.options.forEach((e, index) => {
   if (e.value === undefined || e.value === null || e.value === '') completeStatus = false
 })
 // 完成度展示
-emit('complete', { status: completeStatus, id: 'basicInfo' })
   
 const provinceChange = (value, val, obj) => {
   let cityKey
@@ -486,11 +495,7 @@ const provinceChange = (value, val, obj) => {
 getDict('areaTreeData', null, 'areaTreeData').then(({ data }) => {
   data = data?.length && data || []
   if (!data?.length) return console.error('areaTreeData获取失败!')
-  //
-  // const china = data.find(e => e.id === '1')
-  // const chinaTreeData = china?.children?.length ? china.children : []
   const chinaTreeData = data
-  //
   if (!chinaTreeData?.length) return console.error('chinaTreeData获取失败!')
   const workAreaProvince = items.value.options.find(e => e.key === 'workAreaProvinceId')
   const regAreaProvince = items.value.options.find(e => e.key === 'regProvinceId')

+ 0 - 2
src/views/recruit/personal/PersonalCenter/resume/online/components/jobIntention.vue

@@ -278,8 +278,6 @@ const handleArea = (list, arr) => {
   if (!list.length) return setValue('interestedAreaIdList', '')
   query.interestedAreaIdList = list
   areaSelect = arr
-  // const str = arr.map(e => e.name).join('、')
-  // setValue('interestedAreaIdList', str)
 }
 const handleAreaClear = (k) => {
   query.interestedAreaIdList = query.interestedAreaIdList.filter(i => i !== k.id)

+ 130 - 0
src/views/recruit/personal/PersonalCenter/resume/online/components/portrait.vue

@@ -0,0 +1,130 @@
+<template>
+  <div>
+    <span>个人画像:</span>
+    <v-chip size="small" label v-for="(k, i) in list" :key="i" class="mr-2 mb-2" color="primary" closable @click:close="handleDelete(k)">{{ k }}</v-chip>
+    <v-btn icon="mdi-plus" variant="outlined" color="primary" size="small" @click="handleAdd"></v-btn>
+  </div>
+
+  <CtDialog
+    :visible="visible"
+    titleClass="text-h6"
+    title="个人画像标签选择"
+    :footer="true"
+    @close="handleCancel"
+    @submit="handleSubmit"
+  >
+    <div>已选中标签:</div>
+      <div v-if="select.length">
+        <v-chip v-for="(item, index) in select" :key="index" class="chip mr-2 mt-4" label color="#ea8d03">
+          {{ item }}
+          <v-icon size="18" color="#ea8d03" style="margin-left: 6px;" @click="handleCancelSelect(item)">mdi-close-circle</v-icon>
+        </v-chip>
+    </div>
+    <v-divider class="my-5"></v-divider>
+    <div v-for="val in tagList" :key="val.id" class="mb-8">
+      <span style="font-size: 16px;">{{ val?.nameCn || '--' }}</span>
+      <div v-if="val?.children?.length">
+        <v-chip 
+          v-for="k in val.children" 
+          :key="k.id"
+          class="mx-2 mt-4 cursor-pointer"
+          :text="k.nameCn"
+          variant="outlined"
+          color="primary"
+          :value="k.id"
+          label
+          :disabled="select.includes(k.nameCn)"
+          @click="handleSelect(k.nameCn)"
+        >
+          <v-icon icon="mdi-plus" start></v-icon>
+          {{ k?.nameCn || '--' }}
+        </v-chip>
+      </div>
+    </div>
+  </CtDialog>
+
+  <Loading :visible="loading"></Loading>
+</template>
+
+<script setup>
+// 逻辑思维能力
+defineOptions({ name: 'person-portrait'})
+import { ref } from 'vue'
+import { getTagTreeDataApi } from '@/api/enterprise'
+import { useUserStore } from '@/store/user'
+import { savePersonPortrait } from '@/api/recruit/personal/resume'
+import Snackbar from '@/plugins/snackbar'
+import cloneDeep from 'lodash/cloneDeep'
+
+const loading = ref(false)
+const visible = ref(false)
+const user = useUserStore()
+const baseInfo = ref(JSON.parse(localStorage.getItem('baseInfo')) || {})
+const list = ref(baseInfo.value?.tagList || [])
+const select = ref([])
+const tagList = ref([])
+
+user.$subscribe((mutation, state) => {
+  if (Object.keys(state.baseInfo).length) {
+    baseInfo.value = state.baseInfo
+    list.value = baseInfo.value?.tagList || []
+  }
+})
+
+// 获取标签字典数据
+const getTagList = async () => {
+  const data = await getTagTreeDataApi({ type: 0 })
+  tagList.value = data || []
+}
+getTagList()
+
+const handleAdd = () => {
+  visible.value = true
+  select.value = cloneDeep(list.value)
+}
+
+// 选择
+const handleSelect = (nameCn) => {
+  const result = select.value.includes(nameCn)
+  if (!result) return select.value.push(nameCn)
+  else select.value = select.value.filter(e => e !== nameCn)
+}
+
+const handleCancelSelect = (nameCn) => {
+  select.value = select.value.filter(e => e !== nameCn)
+}
+
+// 删除标签
+const handleDelete = async (k) => {
+  loading.value = true
+  try {
+    list.value = list.value.filter(e => e !== k)
+    await savePersonPortrait({ tagList: list.value })
+    await user.getUserBaseInfos()
+  } finally {
+    loading.value = false
+    Snackbar.success('删除成功')
+  }
+}
+
+// 取消
+const handleCancel = () => {
+  visible.value = false
+}
+// 提交
+const handleSubmit = async () => {
+  loading.value = true
+  try {
+    await savePersonPortrait({ tagList: select.value })
+    Snackbar.success('保存成功')
+    await user.getUserBaseInfos()
+  } finally {
+    loading.value = false
+    visible.value = false
+  }
+}
+</script>
+
+<style scoped lang="scss">
+
+</style>

+ 4 - 2
src/views/recruit/personal/PersonalCenter/resume/online/index.vue

@@ -103,15 +103,17 @@ const complete = (val) => {
   overflow: auto;
 }
 ::-webkit-scrollbar {
-  width: 0;
-  height: 0;
+  width: 8px;
+  height: 10px;
 }
 ::-webkit-scrollbar-thumb, .temporaryAdd ::-webkit-scrollbar-thumb, .details_edit ::-webkit-scrollbar-thumb {
   // 滚动条-颜色
   background: #c3c3c379;
+  border-radius: 10px;
 }
 ::-webkit-scrollbar-track, .temporaryAdd ::-webkit-scrollbar-track, .details_edit ::-webkit-scrollbar-track {
   // 滚动条-底色
   background: #e5e5e58f;
+  border-radius: 10px;
 }
 </style>

+ 9 - 5
src/views/register/person.vue

@@ -2,8 +2,8 @@
   <div class="box">
     <div class="content pa-10">
       <div class="content-title text-center mt-4">请输入手机号码进行注册认证</div>
-      <phoneFrom class="mt-10" ref="phoneRef" @handleEnter="handleRegister"></phoneFrom>
-      <div v-if="!isCompany" class="font-size-14 tips color-primary cursor-pointer text-end" @click="router.push('/login')">已有账号?去登录</div>
+      <phoneFrom class="mt-10" ref="phoneRef" @handleEnter="handleRegister" :phone="phone"></phoneFrom>
+      <div class="font-size-14 tips color-primary cursor-pointer text-end" @click="router.push('/login')">已有账号?去登录</div>
       <v-btn :loading="loading" color="primary" class="white--text mt-5" min-width="370" @click="handleRegister">{{ isCompany ? '下一步' : '注册' }}</v-btn>
       <div class="login-tips mt-3" v-if="!isCompany">
         点击注册即代表您同意
@@ -21,6 +21,7 @@ import { useRouter } from 'vue-router'
 import phoneFrom from '@/components/VerificationCode'
 import { useUserStore } from '@/store/user'
 import Snackbar from '@/plugins/snackbar'
+import { checkEmail } from '@/utils/validate'
 
 const emit = defineEmits(['success'])
 const props = defineProps({
@@ -34,6 +35,7 @@ const router = useRouter()
 const phoneRef = ref()
 const loading = ref(false)
 const userStore = useUserStore()
+const phone = localStorage.getItem('loginAccount') && !checkEmail(localStorage.getItem('loginAccount')) ? localStorage.getItem('loginAccount') : ''
 
 // 注册
 const handleRegister = async () => {
@@ -41,10 +43,12 @@ const handleRegister = async () => {
   if (!valid) return
   loading.value = true
   try {
-    await userStore.handleSmsLogin({ ...phoneRef.value.loginData })
+    await userStore.handleUserRegister({ ...phoneRef.value.loginData })
     Snackbar.success(props.isCompany ? '手机号验证成功' : '注册成功')
-    if (!props.isCompany) router.push({ path: '/recruitHome' })
-    else emit('success')
+    if (!props.isCompany) {
+      router.push({ path: '/recruitHome' })
+      localStorage.removeItem('loginAccount')
+    } else emit('success')
   } finally {
     loading.value = false
   }

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