Browse Source

简历解析

lifanagju_citu 5 months ago
parent
commit
66876b3396

+ 100 - 0
src/views/recruit/personal/PersonalCenter/resume/analysis/components/avatar.vue

@@ -0,0 +1,100 @@
+<!--  -->
+<template>
+  <div class="avatarsBox" @mouseover="showIcon = true" @mouseleave="showIcon = false">
+    <div style="width: 130px; height: 130px;">
+      <v-img :src="getUserAvatar(avatarResult, '1')" width="130" height="130" style="border-radius: 6px;"></v-img>
+      <div v-show="showIcon" @click="openFileInput" class="mdi mdi-camera-outline camera">
+        <input
+          type="file"
+          ref="fileInput"
+          accept="image/png, image/jpg, image/jpeg"
+          style="display: none;"
+          @change="handleUploadFile"
+        />
+      </div>
+    </div>
+  </div>
+  <!-- <Loading :visible="overlay"></Loading> -->
+  <!-- 图片裁剪 -->
+  <ImgCropper :visible="isShowCopper" :image="selectPic" :cropBoxResizable="true" @submit="handleHideCopper" :aspectRatio="1 / 1" @close="isShowCopper = false, selectPic = ''"></ImgCropper>
+</template>
+
+<script setup>
+defineOptions({ name: 'resumeAnalysis-avatar'})
+import { getUserAvatar } from '@/utils/avatar'
+// import { blobToJson } from '@/utils'
+import { ref } from 'vue'
+const props = defineProps({
+  id: {
+    type: String,
+    default: ''
+  },
+  data: {
+    type: String,
+    default: ''
+  }
+})
+
+const showIcon = ref(false)
+
+// 选择文件
+const fileInput = ref()
+const clicked = ref(false)
+const openFileInput = () => {
+  if (clicked.value) return
+  clicked.value = true
+  fileInput.value.click()
+  clicked.value = false
+}
+
+// 上传头像
+const selectPic = ref('')
+const isShowCopper = ref(false)
+const accept = ['jpg', 'png', 'jpeg']
+const handleUploadFile = async (e) => {
+  const file = e.target.files[0]
+  const fileType = file.name.split('.')[1]
+  if (!accept.includes(fileType)) return Snackbar.warning('请上传图片格式')
+  const reader = new FileReader()
+  reader.readAsDataURL(file)
+  reader.onload = () => {
+    selectPic.value = String(reader.result)
+    isShowCopper.value = true
+  }
+}
+
+const avatarResult = ref(props.data)
+
+// 图片裁剪
+const handleHideCopper = async (res) => {
+  isShowCopper.value = false
+  avatarResult.value = res?.result.blobURL || ''
+  // avatarResult.value = res?.result.dataURL || '' // dataURL文件名过长或者过大不成功
+}
+
+const submit = async () => {
+  return { id: props.id, data: avatarResult.value}
+}
+
+defineExpose({
+  id: props.id,
+  submit
+})
+</script>
+<style lang="scss" scoped>
+.avatarsBox {
+  height: 150px;
+  width: 120px;
+  position: relative;
+  cursor: pointer;
+  margin: 0 32px;
+  .camera {
+    color: #fff;
+    font-size: 42px;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+  }
+}
+</style>

+ 279 - 0
src/views/recruit/personal/PersonalCenter/resume/analysis/components/basicInfo.vue

@@ -0,0 +1,279 @@
+<template>
+  <CtForm ref="CtFormRef" :items="items" style="width: 100%;"></CtForm>
+</template>
+
+<script setup>
+defineOptions({ name: 'resumeAnalysis-basicInfo'})
+import { ref, watch } from 'vue'
+import { checkEmail } from '@/utils/validate'
+import { getDict } from '@/hooks/web/useDictionaries'
+const props = defineProps({
+  id: {
+    type: String,
+    default: ''
+  },
+  data: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const CtFormRef = ref()
+const userInfo = ref(JSON.parse(localStorage.getItem('userInfo')))
+const items = ref({
+  options: [
+    {
+      type: 'text',
+      key: 'name',
+      value: null,
+      default: null,
+      label: '中文名 *',
+      col: 6,
+      outlined: true,
+      rules: [
+        value => {
+          if (value) return true
+          return '请输入您的中文名'
+        },
+        value => {
+          var regex = /^[\u4e00-\u9fa5]+$/
+          if (regex.test(value)) return true
+          return '请输入正确的中文名'
+        }
+      ]
+    },
+    {
+      type: 'text',
+      key: 'foreignName',
+      value: null,
+      label: '英文名',
+      col: 6,
+      outlined: true,
+      clearable: true,
+      rules: [
+        value => {
+          if (!value) return true
+          var regex = /^[A-Za-z]+(?:\s[A-Za-z]+)?$/
+          if (regex.test(value)) return true
+          return '请输入正确的英文名'
+        }
+      ]
+    },
+    {
+      type: 'ifRadio',
+      key: 'sex',
+      value: null, // '1' ? '男' : '女'
+      default: '1',
+      label: '性别 *',
+      col: 6,
+      width: 50,
+      dictTypeName: 'menduner_sex',
+      items: [],
+    },
+    {
+      type: 'phoneNumber',
+      key: 'phone',
+      value: null,
+      default: userInfo?.value?.phone || '',
+      label: '电话号码 *',
+      col: 6,
+      outlined: true,
+      rules: [v => !!v || '请填写联系手机号']
+    },
+    {
+      type: 'datePicker',
+      mode: 'date',
+      labelWidth: 80,
+      key: 'birthday',
+      value: '2000-01-01',
+      defaultValue: new Date(2000, 1, 1),
+      label: '出生日期 *',
+      disabledFutureDates: true,
+      col: 6,
+      format: 'YYYY/MM/DD',
+      flexStyle: 'mb-7',
+      outlined: true,
+      rules: [v => !!v || '请选择出生日期']
+    },
+    {
+      type: 'datePicker',
+      mode: 'month',
+      key: 'firstWorkTime',
+      value: '2000-01',
+      disabledFutureDates: true,
+      defaultValue: new Date(2000, 1),
+      format: 'YYYY/MM',
+      labelWidth: 90,
+      flexStyle: 'mb-7',
+      label: '首次工作时间',
+      col: 6,
+      outlined: true
+    },
+    {
+      type: 'text',
+      key: 'email',
+      value: null,
+      default: null,
+      label: '常用邮箱 *',
+      col: 6,
+      outlined: true,
+      rules: [
+        value => {
+          if (value) return true
+          return '请输入联系邮箱'
+        },
+        value => {
+          if (value && !checkEmail(value)) return '请输入正确的电子邮箱'
+          return true
+        }
+      ]
+    },
+    {
+      type: 'autocomplete',
+      key: 'expType',
+      value: null,
+      default: null,
+      label: '工作年限 *',
+      col: 6,
+      outlined: true,
+      itemText: 'label',
+      itemValue: 'value',
+      dictTypeName: 'menduner_exp_type',
+      rules: [v => !!v || '请选择工作年限'],
+      items: []
+    },
+    {
+      type: 'autocomplete',
+      key: 'eduType',
+      value: null,
+      default: null,
+      label: '最高学历 *',
+      col: 6,
+      outlined: true,
+      itemText: 'label',
+      itemValue: 'value',
+      dictTypeName: 'menduner_education_type',
+      rules: [v => !!v || '请选择最高学历'],
+      items: []
+    },
+    {
+      type: 'autocomplete',
+      key: 'jobType',
+      value: null,
+      default: null,
+      label: '求职类型 *',
+      col: 6,
+      outlined: true,
+      itemText: 'label',
+      itemValue: 'value',
+      dictTypeName: 'menduner_job_type',
+      rules: [v => !!v || '请选择求职类型'],
+      items: []
+    },
+    {
+      type: 'autocomplete',
+      key: 'jobStatus',
+      value: null,
+      default: null,
+      label: '求职状态 *',
+      col: 6,
+      outlined: true,
+      itemText: 'label',
+      itemValue: 'value',
+      dictTypeName: 'menduner_job_seek_status',
+      rules: [v => !!v || '请选择求职状态'],
+      items: []
+    },
+    {
+      type: 'autocomplete',
+      key: 'maritalStatus',
+      value: null,
+      default: null,
+      label: '婚姻状况',
+      col: 6,
+      outlined: true,
+      itemText: 'label',
+      itemValue: 'value',
+      dictTypeName: 'menduner_marital_status',
+      items: []
+    },
+    {
+      type: 'cascade',
+      key: 'areaId',
+      value: null,
+      default: null,
+      label: '所在城市',
+      itemText: 'name',
+      itemValue: 'id',
+      required: true,
+      checkStrictly: true,
+      clearable: false,
+      col: 6,
+      items: [],
+    },
+    {
+      type: 'cascade',
+      key: 'regId',
+      value: null,
+      default: null,
+      label: '户籍地',
+      itemText: 'name',
+      itemValue: 'id',
+      checkStrictly: true,
+      required: false,
+      clearable: true,
+      col: 6,
+      items: [],
+    },
+    
+  ]
+})
+
+items.value.options.forEach(async (e, index) => {
+  if ((index + 2) % 2 === 0) e.flexStyle = 'mr-3'
+  if (e.dictTypeName) {
+    const { data } = await getDict(e.dictTypeName)
+    e.items = data
+  }
+})
+
+watch(
+  () => props.data,
+  (newVal) => {
+    if (newVal && Object.keys(newVal)) {
+      items.value.options.forEach(e => {
+        if (newVal[e.key]) e.value = newVal[e.key]
+        else e.value = e.default || null
+      })
+    }
+  },
+  { immediate: true },
+)
+
+const submit = async () => {
+  const { valid } = await CtFormRef.value.formRef.validate()
+  if (!valid) return { id: props.id, data: null}
+  const obj = {}
+  items.value.options.forEach(e => obj[e.key] = e.value)
+  return { id: props.id, data: obj}
+}
+
+defineExpose({
+  id: props.id,
+  submit
+})
+
+getDict('areaTreeData', null, 'areaTreeData').then(({ data }) => {
+  data = data?.length && data || []
+  if (!data?.length) return console.error('areaTreeData获取失败!')
+  const chinaTreeData = data
+  if (!chinaTreeData?.length) return console.error('chinaTreeData获取失败!')
+  const workAreaProvince = items.value.options.find(e => e.key === 'areaId')
+  const regAreaProvince = items.value.options.find(e => e.key === 'regId')
+  if (workAreaProvince?.items) workAreaProvince.items = chinaTreeData
+  if (regAreaProvince?.items) regAreaProvince.items = chinaTreeData
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 234 - 0
src/views/recruit/personal/PersonalCenter/resume/analysis/components/educationExp.vue

@@ -0,0 +1,234 @@
+<!--  -->
+<template>
+  <CtForm ref="CtFormRef" :items="items" style="width: 100%;"></CtForm>
+</template>
+
+<script setup>
+defineOptions({ name: 'resumeAnalysis-educationExp'})
+import { debounce } from 'lodash'
+import { getDict } from '@/hooks/web/useDictionaries'
+import { ref, reactive, watch } from 'vue'
+import { schoolSearchByName, schoolMajorByName } from '@/api/recruit/personal/resume'
+import { dealCanBeInputtedSave, dealCanBeInputtedValueAndLabel } from '@/utils/getText'
+
+const props = defineProps({
+  id: {
+    type: String,
+    default: ''
+  },
+  data: {
+    type: Object,
+    default: () => {}
+  }
+})
+
+const CtFormRef = ref()
+const dictItemsObj = reactive({})
+
+// 学校下拉列表
+const schoolNameInput = ref('')
+const getSchoolListData = async (name) => {
+  const item = items.value.options.find(e => e.key === 'schoolId')
+  if (!item) return
+  if (item.items?.length && (schoolNameInput.value === name)) return // 防抖
+  item[item.itemTextName] = schoolNameInput.value = name
+
+  if (name === null || name === '') { item.items = [] }
+  else {
+    const data = await schoolSearchByName({ name })
+    item.items = data
+  }
+}
+const debouncedCallbackSchool = debounce(newValue => {
+  if (!newValue) return
+  getSchoolListData(newValue)
+}, 500)
+
+// 专业下拉列表
+const majorNameInput = ref('')
+const getMajorListData = async (name) => {
+  const item = items.value.options.find(e => e.key === 'majorId')
+  if (name === '') { // 此接口不支持传空值
+    item.items = []
+    return
+  }
+  if (item.items?.length && (majorNameInput.value === name)) return // 防抖
+  item[item.itemTextName] = majorNameInput.value = name
+  
+  if (name === null || name === '') { item.items = [] }
+  else {
+    const data = await schoolMajorByName({ name })
+    item.items = data
+  }
+}
+const debouncedCallbackMajor = debounce(newValue => {
+  getMajorListData(newValue)
+}, 500)
+
+const items = ref({
+  options: [
+    {
+      type: 'combobox',
+      key: 'schoolId',
+      value: null,
+      default: null,
+      label: '学校名称 *',
+      col: 6,
+      outlined: true,
+      clearable: true,
+      canBeInputted: true, //
+      itemTextName: 'schoolName',
+      itemText: 'value',
+      itemValue: 'key',
+      rules: [v => !!v || '请选择学校名称'],
+      search: debouncedCallbackSchool,
+      items: []
+    },
+    {
+      type: 'combobox',
+      key: 'majorId',
+      value: null,
+      default: null,
+      label: '所学专业 *',
+      col: 6,
+      outlined: true,
+      clearable: true,
+      canBeInputted: true, //
+      itemTextName: 'major',
+      itemText: 'nameCn',
+      itemValue: 'id',
+      rules: [v => !!v || '请选择所学专业'],
+      search: debouncedCallbackMajor,
+      items: []
+    },
+    {
+      type: 'autocomplete',
+      key: 'educationType',
+      value: null,
+      default: null,
+      label: '学历 *',
+      col: 6,
+      outlined: true,
+      itemText: 'label',
+      itemValue: 'value',
+      rules: [v => !!v || '请选择学历'],
+      items: []
+    },
+    {
+      type: 'autocomplete',
+      key: 'educationSystemType',
+      value: null,
+      default: null,
+      label: '学制类型 *',
+      col: 6,
+      outlined: true,
+      itemText: 'label',
+      itemValue: 'value',
+      rules: [v => !!v || '请选择学制类型'],
+      items: dictItemsObj.educationSystemType,
+    },
+    {
+      type: 'datePicker',
+      key: 'startTime',
+      mode: 'month', // 时间类型 year month date time
+      value: null,
+      default: '2014-01',
+      format: 'YYYY/MM',
+      labelWidth: 80,
+      label: '开始时间 *',
+      defaultValue: new Date(2014, 1),
+      disabledFutureDates: true,
+      col: 6,
+      rules: [v => !!v || '请选择起始时间']
+    },
+    {
+      type: 'datePicker',
+      key: 'endTime',
+      mode: 'month', // 时间类型 year month date time
+      value: null,
+      default: '2018-01',
+      format: 'YYYY/MM',
+      defaultValue: new Date(2018, 1),
+      disabledFutureDates: true,
+      labelWidth: 80,
+      label: '结束时间 *',
+      col: 6,
+      rules: [v => !!v || '请选择结束时间']
+    },
+    {
+      type: 'textarea',
+      key: 'content',
+      value: null,
+      default: null,
+      rows: 5,
+      flexStyle: 'mt-5',
+      resize: true,
+      counter: 1600,
+      label: '在校经历',
+      outlined: true
+    },
+  ]
+})
+
+watch(
+  () => props.data,
+  (newVal) => {
+    if (newVal && Object.keys(newVal)) {
+      schoolNameInput.value = newVal.schoolName || null
+      majorNameInput.value = newVal.major || null
+      //
+      items.value.options.forEach(e => {
+        if (newVal[e.key]) e.value = newVal[e.key]
+        else e.value = e.default || null
+        if (e.canBeInputted) { // 特殊处理可输入下拉框
+          dealCanBeInputtedValueAndLabel(e, newVal)
+        }
+      })
+    }
+  },
+  { immediate: true },
+)
+
+const submit = async () => {
+  const { valid } = await CtFormRef.value.formRef.validate()
+  if (!valid) return { id: props.id, data: null}
+  const obj = {}
+  items.value.options.forEach(e => {
+    if (e.canBeInputted) { // 特殊处理可输入下拉框
+      dealCanBeInputtedSave(e, obj)
+    }
+    else obj[e.key] = e.value
+  })
+  return { id: props.id, data: obj}
+}
+
+defineExpose({
+  id: props.id,
+  submit
+})
+
+// 左侧加mr
+items.value.options.forEach((e, index) => {
+  if (((index + 2) % 2 === 0) && Boolean(e.col) && e.col !== 12) e.flexStyle = 'mr-3'
+})
+
+// 获取字典内容
+const dictList = [
+  { type: 'menduner_education_type', key: 'educationType' },
+  { type: 'menduner_education_system_type', key: 'educationSystemType' }
+]
+const getDictData = async (obj) => {
+  const item = items.value.options.find(e => e.key === obj.key)
+  if (item) { //  && !item.items?.length
+    const { data } = await getDict(obj.type)
+    item.items = data || []
+    dictItemsObj[obj.key] = data || []
+  }
+}
+const getOptions = () => {
+  dictList.forEach(obj =>  getDictData(obj))
+}
+getOptions()
+</script>
+<style lang="scss" scoped>
+</style>

+ 59 - 0
src/views/recruit/personal/PersonalCenter/resume/analysis/components/selfEvaluation.vue

@@ -0,0 +1,59 @@
+<!--  -->
+<template>
+  <div>
+    <v-textarea 
+      v-model="advantage" 
+      :label="$t('resume.dataDefaultPrompt') + $t('resume.personalAdvantages') + '...'" 
+      variant="outlined" 
+      counter="300" 
+      color="primary" 
+      validate-on="input"
+      :rules="advantageRules"
+    ></v-textarea>
+  </div>
+</template>
+
+<script setup>
+defineOptions({ name: 'resumeAnalysis-selfEvaluation'})
+// import Snackbar from '@/plugins/snackbar'
+import DOMPurify from 'dompurify'
+import { ref } from 'vue'
+const props = defineProps({
+  id: {
+    type: String,
+    default: ''
+  },
+  data: {
+    type: String,
+    default: ''
+  }
+})
+
+const advantage = ref(props.data)
+const advantageRules = ref([
+  value => {
+    if (value) return true
+    return '请输入您的个人优势'
+  },
+  value => {
+    if (value?.length <= 300) return true
+    return '请输入2-300个字符'
+  }
+])
+
+const submit = async () => {
+  advantage.value = DOMPurify.sanitize(advantage.value)
+  if (!advantage.value) {
+    // Snackbar.warning('请先输入个人优势!')
+    return { id: props.id, data: null}
+  }
+  return { id: props.id, data: { content: advantage.value}}
+}
+
+defineExpose({
+  id: props.id,
+  submit
+})
+</script>
+<style lang="scss" scoped>
+</style>

+ 112 - 0
src/views/recruit/personal/PersonalCenter/resume/analysis/components/trainingExperience.vue

@@ -0,0 +1,112 @@
+<!--  -->
+<template>
+  <CtForm ref="CtFormRef" :items="items" style="width: 100%;"></CtForm>
+</template>
+
+<script setup>
+defineOptions({ name: 'resumeAnalysis-trainingExperience'})
+import { ref, watch } from 'vue'
+const props = defineProps({
+  id: {
+    type: String,
+    default: ''
+  },
+  data: {
+    type: Object,
+    default: () => {}
+  }
+})
+
+const CtFormRef = ref()
+const items = ref({
+  options: [
+    {
+      type: 'text',
+      key: 'orgName',
+      value: '',
+      col: 6,
+      label: '培训中心 *',
+      flexStyle: 'mr-3',
+      rules: [v => !!v || '请输入培训中心']
+    },
+    {
+      type: 'text',
+      key: 'course',
+      value: '',
+      col: 6,
+      label: '培训课程 *',
+      rules: [v => !!v || '请输入培训课程']
+    },
+    {
+      type: 'datePicker',
+      key: 'startTime',
+      mode: 'month',
+      value: '2020-01',
+      labelWidth: 80,
+      format: 'YYYY/MM',
+      defaultValue: new Date(2020, 1),
+      label: '开始时间 *',
+      disabledFutureDates: true,
+      col: 6,
+      flexStyle: 'mr-3',
+      rules: [v => !!v || '请选择培训开始时间']
+    },
+    {
+      type: 'datePicker',
+      key: 'endTime',
+      mode: 'month',
+      value: '2022-01',
+      format: 'YYYY/MM',
+      labelWidth: 80,
+      defaultValue: new Date(2022, 1),
+      label: '结束时间 *',
+      disabledFutureDates: true,
+      col: 6,
+      rules: [v => !!v || '请选择培训结束时间']
+    },
+    {
+      type: 'textarea',
+      key: 'content',
+      value: '',
+      label: '培训描述',
+      rows: 5,
+      flexStyle: 'mt-5',
+      resize: true,
+      counter: 2000,
+      rules: [
+        value => {
+          if (value?.length <= 2000) return true
+          return '请输入2-2000个字符'
+        }
+      ]
+    }
+  ]
+})
+
+watch(
+  () => props.data,
+  (newVal) => {
+    if (newVal && Object.keys(newVal)) {
+      items.value.options.forEach(e => {
+        if (newVal[e.key]) e.value = newVal[e.key]
+      })
+    }
+  },
+  { immediate: true }
+)
+
+const submit = async () => {
+  const { valid } = await CtFormRef.value.formRef.validate()
+  if (!valid) return { id: props.id, data: null}
+  const obj = {}
+  items.value.options.forEach(e => obj[e.key] = e.value)
+  return { id: props.id, data: obj}
+}
+
+defineExpose({
+  id: props.id,
+  submit
+})
+</script>
+<style lang="scss" scoped>
+</style>

+ 203 - 0
src/views/recruit/personal/PersonalCenter/resume/analysis/components/workExperience.vue

@@ -0,0 +1,203 @@
+<!--  -->
+<template>
+  <CtForm ref="CtFormRef" :items="items" style="width: 100%;">
+    <template #endTime="{ item }">
+      <div>
+        <v-checkbox-btn
+          v-model="item.soFar"
+          color="primary"
+          :label="$t('sys.soFar')"
+          class="ml-2"
+          :disabled="false"
+          :style="`line-height: ${item.dense === 'default' ? 56 : item.dense === 'comfortable' ? 48 : 40 }px;`"
+          style="width: 80px;"
+          hide-details
+          @update:modelValue="v => handleSoFarChange(v, item)"
+        ></v-checkbox-btn>
+      </div>
+    </template>
+  </CtForm>
+</template>
+
+<script setup>
+defineOptions({ name: 'resumeAnalysis-workExperience'})
+import { getDict } from '@/hooks/web/useDictionaries'
+import { ref, reactive, watch } from 'vue'
+import { dealCanBeInputtedSave, dealCanBeInputtedValueAndLabel } from '@/utils/getText'
+
+const props = defineProps({
+  id: {
+    type: String,
+    default: ''
+  },
+  data: {
+    type: Object,
+    default: () => {}
+  }
+})
+
+const CtFormRef = ref()
+const dictItemsObj = reactive({})
+dictItemsObj.educationSystemType = [{ label: '全日制', value: '0' }, { label: '非全日制', value: '1' }]
+
+
+// 企业名称下拉列表
+const enterpriseNameInput = ref('')
+const getEnterpriseData = async (name) => {
+  const item = items.value.options.find(e => e.key === 'enterpriseId')
+  if (!item) return
+  if (item.items?.length && (enterpriseNameInput.value === name)) return // 防抖
+  item[item.itemTextName] = enterpriseNameInput.value = name
+  if (name === null || name === '') { item.items = [] }
+  else {
+    const data = await enterpriseSearchByName({ name })
+    item.items = data
+  }
+}
+
+const positionSearch = (name) => {
+  const item = items.value.options.find(e => e.key === 'positionId')
+  if (!item) return
+  item[item.itemTextName] = name
+}
+
+// 《至今》复选框事件
+const handleSoFarChange = (bool, item) => {
+  const opObj = items.value.options.find(e => e.key === item.key)
+  if (opObj) {
+    opObj.value = null
+    opObj.soFar = bool
+    opObj.disabled = bool ? true : false
+  }
+}
+
+const items = ref({
+  options: [
+    {
+      type: 'combobox',
+      key: 'enterpriseId',
+      value: null,
+      default: null,
+      label: '企业名称 *',
+      outlined: true,
+      clearable: true,
+      canBeInputted: true, //
+      itemTextName: 'enterpriseName',
+      itemText: 'value',
+      itemValue: 'key',
+      rules: [v => !!v || '请选择企业名称'],
+      search: getEnterpriseData,
+      items: []
+    },
+    {
+      type: 'combobox',
+      key: 'positionId',
+      value: null,
+      default: null,
+      label: '职位名称 *',
+      outlined: true,
+      clearable: true,
+      canBeInputted: true, //
+      itemTextName: 'positionName',
+      itemText: 'nameCn',
+      itemValue: 'id',
+      rules: [v => !!v || '请选择职位名称'],
+      search: val => positionSearch(val),
+      items: []
+    },
+    {
+      type: 'datePicker',
+      key: 'startTime',
+      mode: 'month', // 时间类型 year month date time
+      value: null,
+      label: '开始时间 *',
+      labelWidth: 100,
+      default: '2020-01',
+      defaultValue: new Date(2020, 1),
+      disabledFutureDates: true,
+      format: 'YYYY/MM',
+      rules: [v => !!v || '请选择起始时间']
+    },
+    {
+      type: 'datePicker',
+      key: 'endTime',
+      slotName: 'endTime',
+      mode: 'month', // 时间类型 year month date time
+      value: null,
+      format: 'YYYY/MM',
+      default: '2022-01',
+      defaultValue: new Date(2022, 1),
+      label: '结束时间 *',
+      disabledFutureDates: true,
+      labelWidth: 100,
+      rules: [v => !!v || '请选择结束时间']
+    },
+    {
+      type: 'textarea',
+      key: 'content',
+      value: null,
+      default: null,
+      rows: 10,
+      resize: true,
+      flexStyle: 'mt-5',
+      counter: 2000,
+      label: '工作内容 *',
+      outlined: true,
+      rules: [v => !!v || '请输入工作内容']
+    },
+  ]
+})
+// 左侧加mr
+// items.value.options.forEach((e, index) => {
+//   if (((index + 2) % 2 === 0) && Boolean(e.col) && e.col !== 12) e.flexStyle = 'mr-3'
+// })
+
+watch(
+  () => props.data,
+  (newVal) => {
+    if (newVal && Object.keys(newVal)) {
+      enterpriseNameInput.value = newVal.enterpriseName
+      //
+      items.value.options.forEach(e => {
+        if (newVal[e.key]) e.value = newVal[e.key]
+        if (e.canBeInputted) { // 特殊处理可输入下拉框
+          dealCanBeInputtedValueAndLabel(e, newVal)
+        }
+      })
+      if (!newVal.endTime) handleSoFarChange(true, {key: 'endTime'})
+    }
+  },
+  { immediate: true },
+)
+
+const submit = async () => {
+  const { valid } = await CtFormRef.value.formRef.validate()
+  if (!valid) return { id: props.id, data: null }
+  const obj = {}
+  items.value.options.forEach(e => {
+    if (e.canBeInputted) { // 特殊处理可输入下拉框
+      dealCanBeInputtedSave(e, obj)
+    }
+    else obj[e.key] = e.value
+  })
+  return { id: props.id, data: obj}
+}
+
+defineExpose({
+  id: props.id,
+  submit
+})
+
+// 岗位treeChildren
+let positionTreeChildrenData = []
+getDict('positionTreeData', null, 'positionTreeData').then(({ data }) => {
+  data = data?.length && data || []
+  data.forEach(e => {
+    if (e?.children?.length) positionTreeChildrenData = positionTreeChildrenData.concat(e.children)
+  })
+  items.value.options.find(e => e.key === 'positionId').items = positionTreeChildrenData
+})
+
+</script>
+<style lang="scss" scoped>
+</style>

+ 217 - 6
src/views/recruit/personal/PersonalCenter/resume/analysis/index.vue

@@ -1,18 +1,229 @@
 <template>
-  <div class="d-flex pa-3" style="height: calc(100vh - 50px)">
-    <div class="mr-5" style="width: 50%; height: 100%">
-      <IFrame v-if="fileUrl" :src="decodeURIComponent(fileUrl)" initHeight="100%"></IFrame>
-    </div>
+  <div style="height: calc(100vh - 50px)">
+    <template v-if="showSelect">
+      <div class="d-flex flex-column pb-10" style="width: 800px; min-width: 800px; height: 100%; min-height: 500px; margin: 0 auto;">
+        <attachmentPage analysis  @analysis="handleAnalysis"></attachmentPage>
+      </div>
+      <!-- <div class="d-flex justify-center align-center" style="height: 100%">
+        <v-btn color="primary" @click="null">选择已上传简历</v-btn>
+        <v-btn prepend-icon="mdi-upload" class="ml-5" @click="null">上传附件简历</v-btn>
+      </div> -->
+    </template>
+    <template v-else>
+      <div class="d-flex pa-3" style="height: 100%">
+        <!-- 简历附件回显 -->
+        <v-card class="d-flex flex-column mr-5 pa-3" style="width: 60%; height: 100%; position: relative;">
+          <v-tabs v-model="tab" align-tabs="start" color="primary" bg-color="#f7f8fa">
+            <v-tab :value="1">查看附件简历</v-tab>
+            <v-tab :value="2">查看文本信息</v-tab>
+          </v-tabs>
+          <div></div>
+          <v-btn style="position: absolute; right: 0;" class="mx-5 mt-2"  variant="tonal" @click="showSelect = true">重新选择附件简历</v-btn>
+          <div class="mt-3" style="width: 100%; height: 100%; flex: 1;">
+            <div v-show="tab === 1" style="width: 100%; height: 100%">
+              <IFrame :src="decodeURIComponent(fileUrl)" initHeight="100%"></IFrame>
+            </div>
+            <div v-show="tab === 2">
+              <template v-if="resumeTxt?.length">
+                简历解析(可复制文本使用)
+                <p v-for="(text, index) in resumeTxt" :key="text + index">{{ text }}</p>
+              </template>
+              <template v-else>
+                无简历解析文本可用
+              </template>
+            </div>
+          </div>
+        </v-card>
+        <!-- 解析内容 -->
+        <v-card class="elevation-2 pa-3" style="flex: 1; height: 100%; overflow: hidden; position: relative;" >
+          <div style="height: 100%; overflow: auto;">
+            <div v-for="item of formLIst" :key="item.id" :id="item.id" class="pa-3 mb-3" style="background-color: var(--color-f8);">
+              <div class="resume-header mb-3">
+                <div class="resume-title">{{ item.text }}</div>
+                <v-btn variant="text" color="error" density="compact" prepend-icon="mdi-delete-outline" @click="del(item)">删除</v-btn>
+              </div>
+              <component ref="componentRef" :id="item.id" :is="item.path" :data="item.data" />
+            </div>
+            <!-- 保存 -->
+            <div style="position: absolute; bottom: 0; background-color: #fff; z-index: 99; width: 100%; border-top: 1px solid #e4e7eb;" class="py-3 ml-n3 text-center">
+              <v-btn class="buttons mx-3" color="primary" @click="submit">保存</v-btn>
+              <!-- <v-btn class="buttons mx-3" variant="tonal" @click="showSelect = true">重新选择附件简历</v-btn> -->
+            </div>
+          </div>
+        </v-card>
+        <v-overlay
+          v-model="loading"
+          :close-on-content-click="false"
+          :no-click-animation="true"
+          contained
+          class="align-center justify-center"
+        >
+          <v-progress-circular color="primary" size="64" indeterminate></v-progress-circular>
+        </v-overlay>
+      </div>
+    </template>
   </div>
 </template>
 
 <script setup>
 defineOptions({ name: 'resume-analysis'})
-import { ref } from 'vue'
+import { shallowRef, ref } from 'vue'
+import { useI18n } from '@/hooks/web/useI18n'
+import IFrame from '@/components/IFrame'
+import avatar from './components/avatar.vue'
+import basicInfo from './components/basicInfo.vue'
+import selfEvaluation from './components/selfEvaluation.vue'
+import educationExp from './components/educationExp.vue'
+import workExperience from './components/workExperience.vue'
+import trainingExperience from './components/trainingExperience.vue'
+import Snackbar from '@/plugins/snackbar'
+import Confirm from '@/plugins/confirm'
+import { saveResumeInfo, resumeParser2 } from '@/api/recruit/personal/resume'
+import { useUserStore } from '@/store/user'
+import attachmentPage from '../attachment'
+const { t } = useI18n()
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => {}
+  }
+})
+
+const tab = ref(1)
+const exampleList = {
+  avatar: { text: t('resume.avatar'), id: 'avatar', path: avatar },
+  person: { text: t('resume.basicInfo'), id: 'person', path: basicInfo },
+  advantage: { text: t('resume.personalAdvantages'), id: 'advantage', path: selfEvaluation },
+  eduList: { text: t('resume.educationExp'), id: 'eduList', path: educationExp },
+  workList: { text: t('resume.workExperience'), id: 'workList', path: workExperience },
+  trainList: { text: t('resume.trainingExperience'), id: 'trainList', path: trainingExperience },
+}
+
+const componentRef = ref()
+const getValue = async () => {
+  let id = ''
+  let data = {}
+  for (let index = 0; index < componentRef.value.length; index++) {
+    const e = componentRef.value[index]
+    const query = await e.submit()
+    if (query && query.data) {
+      data[query.id] = query.data
+    } else {
+      id = id ? id : query.id
+    }
+  }
+  console.log('id:', id)
+  if (id) {
+    Snackbar.warning('请填写完整后提交!')
+    return
+  }
+  // 处理data
+  let obj = Object.keys(data).length ? {} : null
+  const keyTransform = { // 转换给后端的key
+    eduList: 'eduExp',
+    workList: 'workExp',
+    trainList: 'trainExp',
+  }
+  if (obj) {
+    Object.keys(data).forEach(key => {
+      if (key.includes('_')) { // 数组
+        const oldKey = key.split('_')[0]
+        const newKey = keyTransform[oldKey] ? keyTransform[oldKey] : oldKey
+        if (!obj[newKey]) obj[newKey] = [data[key]]
+        else obj[newKey].push(data[key])
+      } else {
+        const newKey = keyTransform[key] ? keyTransform[key] : key
+        obj[newKey] = data[key]
+      }
+    })
+    const defaultObj = { avatar: "", person: {}, tag: { tagList: [] }, jobInterested: [], eduExp: [], workExp: [], trainExp: [] } // 必传字段
+    obj = { ...defaultObj, ...obj }
+  }
+  // console.log('123456:', obj)
+  return obj && Object.keys(obj).length ? JSON.stringify(obj) : null
+}
+const loading = ref(false)
+const submit = async () => {
+  const obj = await getValue()
+  if (!obj) return
+  try {
+    loading.value = true
+    await saveResumeInfo(obj)
+    Snackbar.success(t('common.saveMsg'))
+    await useUserStore().getUserBaseInfos() // 更新用户信息
+    setTimeout(() => { window.location.reload() }, 1000)
+  } catch (error) {
+    console.log(error)
+  } finally {
+    loading.value = false
+  }
+}
+
+const del = (item) => {
+  Confirm('系统提示', `是否确认删除${item.text}?`).then(async () => {
+    formLIst.value = formLIst.value.filter(e => e.id !== item.id)
+  })
+}
+
+const resumeTxt = ref([]) // 查看文本信息
+const formLIst = shallowRef([])
+const transformToLIst = async (result) => {
+  formLIst.value = []
+  if (result && Object.keys(result)) {
+    if (result.resume?.rawText) resumeTxt.value = result.resume.rawText.split('\n') || []
+    if (result.person?.advantage) result.advantage = result.person.advantage
+    if (result.person?.avatar) result.avatar = result.person.avatar
+    // obj
+    const dealObjKeys = ['avatar', 'person', 'advantage']
+    dealObjKeys.forEach(key => {
+      if (result[key]) {
+        const obj = {...exampleList[key]}
+        obj.data = result[key]
+        formLIst.value.push(obj)
+      }
+    })
+    // arr
+    const dealArrKeys = ['eduList', 'workList', 'trainList']
+    dealArrKeys.forEach(key => {
+      if (result[key]?.length) {
+        for (let index = 0; index < result[key].length; index++) {
+          const obj = {...exampleList[key]}
+          obj.id = obj.id + '_' + index
+          obj.text = result[key].length > 1 ? obj.text + (index+1) : obj.text
+          obj.data = result[key][index]
+          formLIst.value.push(obj)
+        }
+      }
+    })
+  }
+
+}
+
+const result = ref({}) // ref(JSON.parse(JSON.stringify(dataObj))) // 测试
+const fileUrl = ref('')
+const showSelect = ref(true)
+const handleAnalysis = async (url) => {
+  url = decodeURIComponent(url)
+  if (!url) return
+  showSelect.value = false
+  loading.value = true
+  const baseUrl = import.meta.env.VITE_PREVIEW_URL
+  fileUrl.value = !url.includes('.pdf') ?  `${baseUrl}/onlinePreview?url=${encodeURIComponent(Base64.encode(url))}` : url
+  try {
+    const data = await resumeParser2({ fileUrl: fileUrl.value })
+    result.value = data || {}
+    // result.value = {person: data.person} || {} // 测试
+    await transformToLIst(result.value)
+  } catch (error) {
+    console.log(error)
+  } finally {
+    loading.value = false
+  }
+}
 
-const fileUrl = ref('https://minio.menduner.com/dev/person/1864497212765208578/attachment/85c2977656417e1c30028646b814e4ffcc962ab1ad715816c5f0083630f80289.pdf')
 </script>
 
+
 <style scoped lang="scss">
 
 </style>

+ 32 - 1
src/views/recruit/personal/PersonalCenter/resume/attachment/index.vue

@@ -12,14 +12,21 @@
         :key="i" 
         @mouseenter="k.active = true" 
         @mouseleave="k.active = false"
+        @click.self="checkboxClick(i)"
       >
-        <div>{{ k.title }}</div>
+        <div class="d-flex">
+          <v-checkbox-btn v-if="props.analysis" v-model="k.choose" color="primary" class="pe-2"></v-checkbox-btn>
+          <span @click="checkboxClick(i)">{{ k.title }}</span>
+        </div>
         <div class="float-right" v-if="k.active">
           <v-btn variant="text" color="primary" prepend-icon="mdi-eye-outline" @click="previewFile(k.url)">预览</v-btn>
           <v-btn variant="text" color="primary" prepend-icon="mdi-square-edit-outline" @click="handleDownload(k)">下载</v-btn>
           <v-btn variant="text" color="primary" prepend-icon="mdi-trash-can-outline" @click="handleDelete(k)">{{ $t('common.delete') }}</v-btn>
         </div>
       </div>
+      <div v-if="props.analysis" class="text-center mt-15">
+        <v-btn class="buttons" color="primary" @click="handleAnalysis">开始解析</v-btn>
+      </div>
     </div>
     <div v-else class="resumeNoDataText tips">请上传您的附件简历</div>
   </div>
@@ -52,6 +59,13 @@ import { useI18n } from '@/hooks/web/useI18n'
 import { getPersonResumeCv, deletePersonResumeCv, savePersonResumeCv } from '@/api/recruit/personal/resume'
 import { getBlob, saveAs } from '@/utils'
 import { previewFile } from '@/utils'
+const emit = defineEmits(['analysis'])
+const props = defineProps({
+  analysis: { // 简历解析
+    type: Boolean,
+    default: false
+  }
+})
 
 const { t } = useI18n()
 
@@ -140,6 +154,23 @@ const handleDownload = (k) => {
     saveAs(blob, k.title)
   })
 }
+
+const fileUrl = ref('')
+const checkboxClick = (i) => {
+  if (!props.analysis) return
+  let item = null
+  attachmentList.value.forEach((e, index) => {
+    e.choose = i === index ? !e.choose : false
+    if (e.choose) item = e
+  })
+  if (item?.url) fileUrl.value = encodeURIComponent(item.url)
+}
+
+const handleAnalysis = () => {
+  if (!fileUrl.value) return Snackbar.warning('请选择要解析的简历附件')
+  emit('analysis', fileUrl.value)
+}
+
 </script>
 
 <style scoped lang="scss">

+ 1 - 1
src/views/recruit/personal/PersonalCenter/resume/online/analysis/workExperience.vue

@@ -172,7 +172,7 @@ watch(
 
 const submit = async () => {
   const { valid } = await CtFormRef.value.formRef.validate()
-  if (!valid) return { id: props.id, data: null}
+  if (!valid) return { id: props.id, data: null }
   const obj = {}
   items.value.options.forEach(e => {
     if (e.canBeInputted) { // 特殊处理可输入下拉框