Files
offerpai_web/src/components/ProfileEditDrawer.vue
T
2026-05-06 15:07:44 +08:00

1307 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 个人资料编辑抽屉 从右侧滑入占满屏幕高度 -->
<Teleport to="body">
<!-- 遮罩层 -->
<Transition name="profile-drawer-overlay">
<div
v-if="modelValue"
class="profile-drawer-overlay"
@click="handleClose"
/>
</Transition>
<!-- 抽屉主体 -->
<Transition name="profile-drawer-slide">
<div v-if="modelValue" class="profile-drawer" @click.stop>
<!-- 顶部栏关闭按钮 + 模块标题 -->
<div class="profile-drawer__header">
<button class="profile-drawer__close-btn" @click="handleClose" aria-label="关闭">
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__close-icon">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<h3 class="profile-drawer__title">{{ moduleTitle }}</h3>
</div>
<!-- 表单内容区 根据模块名动态渲染不同字段 -->
<div class="profile-drawer__body">
<!-- ========== 基本信息模块 ========== -->
<template v-if="module === 'info'">
<div
v-for="field in infoFields"
:key="field.key"
class="profile-drawer__field"
>
<label class="profile-drawer__label">
<span v-if="field.required" class="profile-drawer__required">*</span>{{ field.label }}
</label>
<!-- 城市字段使用地区选择组件替代普通输入框 -->
<RegionSelector
v-if="field.key === 'location'"
:regionCodes="locationCodes"
:level="2"
:maxSelect="1"
:triggerStyle="regionTriggerStyle"
@update:regionCodes="onLocationChange"
/>
<!-- 其他字段普通输入框 -->
<input
v-else
class="profile-drawer__input"
:type="field.type || 'text'"
:placeholder="field.placeholder"
v-model="formData[field.key]"
/>
</div>
</template>
<!-- ========== 教育经历模块 ========== -->
<template v-else-if="module === 'education'">
<div
v-for="(edu, index) in educationList"
:key="index"
class="profile-drawer__edu-card"
>
<!-- 教育经历标题栏序号 + 删除按钮 -->
<div class="profile-drawer__edu-header">
<span class="profile-drawer__edu-index">
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-index-icon">
<circle cx="8" cy="8" r="3" fill="currentColor"/>
</svg>
教育经历{{ index + 1 }}
</span>
<button
v-if="educationList.length > 1"
class="profile-drawer__edu-delete"
@click="removeEducation(index)"
aria-label="删除"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-delete-icon">
<path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<!-- 学校 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>学校
</label>
<div class="profile-drawer__select-wrap">
<input class="profile-drawer__input" placeholder="请输入学校名称" v-model="edu.school" />
</div>
</div>
<!-- 专业 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>专业
</label>
<div class="profile-drawer__select-wrap">
<input class="profile-drawer__input" placeholder="请输入专业名称" v-model="edu.major" />
</div>
</div>
<!-- 学历类型 + 学历横排 -->
<div class="profile-drawer__row">
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>学历类型
</label>
<div class="profile-drawer__select-wrap">
<select class="profile-drawer__input" v-model="edu.studyType">
<option v-for="opt in studyTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>学历
</label>
<div class="profile-drawer__select-wrap">
<select class="profile-drawer__input" v-model="edu.degree">
<option v-for="opt in degreeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
</div>
<!-- 入学年份 + 毕业年份横排 -->
<div class="profile-drawer__row">
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>入学年份
</label>
<el-date-picker
v-model="edu.startDate"
type="month"
placeholder="请选择入学年份"
format="YYYY-MM"
value-format="YYYY-MM"
class="profile-drawer__date-picker"
:teleported="true"
popper-class="profile-drawer-date-popper"
/>
</div>
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>毕业年份
</label>
<el-date-picker
v-model="edu.endDate"
type="month"
placeholder="请选择毕业年份"
format="YYYY-MM"
value-format="YYYY-MM"
class="profile-drawer__date-picker"
:teleported="true"
popper-class="profile-drawer-date-popper"
/>
</div>
</div>
<!-- 经历描述 分段编辑每段带删除按钮 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">经历描述</label>
<!-- 描述段落列表 -->
<div
v-for="(desc, dIdx) in edu.description"
:key="desc.id"
class="profile-drawer__desc-item"
>
<textarea
class="profile-drawer__textarea profile-drawer__textarea--short"
placeholder="请输入经历描述"
v-model="desc.text"
rows="4"
></textarea>
<!-- 段落内右侧删除按钮 -->
<button
v-if="edu.description.length > 1"
class="profile-drawer__desc-remove"
@click="removeEducationDescriptionParagraph(index, dIdx)"
aria-label="删除该段描述"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__desc-remove-icon">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- 新增一段描述按钮 -->
<button class="profile-drawer__add-btn profile-drawer__add-btn--small" @click="addEducationDescriptionParagraph(index)">
<span class="profile-drawer__add-icon">+</span> 新增一段描述
</button>
</div>
</div>
<!-- 新增教育经历按钮 -->
<button class="profile-drawer__add-btn wp100 mb20" @click="addEducation">
<span class="profile-drawer__add-icon">+</span> 新增教育经历
</button>
</template>
<!-- ========== 工作经历模块 ========== -->
<template v-else-if="module === 'work'">
<div
v-for="(work, index) in workList"
:key="index"
class="profile-drawer__edu-card"
>
<!-- 工作经历标题栏序号 + 删除按钮 -->
<div class="profile-drawer__edu-header">
<span class="profile-drawer__edu-index">
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-index-icon">
<circle cx="8" cy="8" r="3" fill="currentColor"/>
</svg>
工作经历{{ index + 1 }}
</span>
<button
v-if="workList.length > 1"
class="profile-drawer__edu-delete"
@click="removeWork(index)"
aria-label="删除"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-delete-icon">
<path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<!-- 公司名称 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>公司名称
</label>
<div class="profile-drawer__select-wrap">
<input class="profile-drawer__input" placeholder="请输入公司名称" v-model="work.companyName" />
</div>
</div>
<!-- 职位 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>职位
</label>
<div class="profile-drawer__select-wrap">
<input class="profile-drawer__input" placeholder="请输入职位" v-model="work.position" />
</div>
</div>
<!-- 开始时间 + 结束时间横排 -->
<div class="profile-drawer__row">
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>开始时间
</label>
<el-date-picker
v-model="work.startDate"
type="month"
placeholder="请输入开始时间"
format="YYYY-MM"
value-format="YYYY-MM"
class="profile-drawer__date-picker"
:teleported="true"
popper-class="profile-drawer-date-popper"
/>
</div>
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>结束时间
</label>
<el-date-picker
v-model="work.endDate"
type="month"
placeholder="请输入结束时间"
format="YYYY-MM"
value-format="YYYY-MM"
class="profile-drawer__date-picker"
:teleported="true"
popper-class="profile-drawer-date-popper"
/>
</div>
</div>
<!-- 经历描述 分段编辑每段带删除按钮 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>经历描述
</label>
<!-- 描述段落列表 -->
<div
v-for="(desc, dIdx) in work.description"
:key="desc.id"
class="profile-drawer__desc-item"
>
<textarea
class="profile-drawer__textarea profile-drawer__textarea--short"
placeholder="请输入经历描述"
v-model="desc.text"
rows="4"
></textarea>
<!-- 段落内右侧删除按钮 -->
<button
v-if="work.description.length > 1"
class="profile-drawer__desc-remove"
@click="removeWorkDescriptionParagraph(index, dIdx)"
aria-label="删除该段描述"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__desc-remove-icon">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- 新增一段描述按钮 -->
<button class="profile-drawer__add-btn profile-drawer__add-btn--small" @click="addWorkDescriptionParagraph(index)">
<span class="profile-drawer__add-icon">+</span> 新增一段描述
</button>
</div>
</div>
<!-- 新增工作经历按钮 -->
<button class="profile-drawer__add-btn wp100 mb20" @click="addWork">
<span class="profile-drawer__add-icon">+</span> 新增工作经历
</button>
</template>
<!-- ========== 实习经历模块 ========== -->
<template v-else-if="module === 'internship'">
<div
v-for="(intern, index) in internshipList"
:key="index"
class="profile-drawer__edu-card"
>
<!-- 实习经历标题栏序号 + 删除按钮 -->
<div class="profile-drawer__edu-header">
<span class="profile-drawer__edu-index">
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-index-icon">
<circle cx="8" cy="8" r="3" fill="currentColor"/>
</svg>
实习经历{{ index + 1 }}
</span>
<button
v-if="internshipList.length > 1"
class="profile-drawer__edu-delete"
@click="removeInternship(index)"
aria-label="删除"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-delete-icon">
<path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<!-- 公司名称 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>公司名称
</label>
<div class="profile-drawer__select-wrap">
<input class="profile-drawer__input" placeholder="请输入公司名称" v-model="intern.companyName" />
</div>
</div>
<!-- 职位 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>职位
</label>
<div class="profile-drawer__select-wrap">
<input class="profile-drawer__input" placeholder="请输入职位" v-model="intern.position" />
</div>
</div>
<!-- 开始时间 + 结束时间横排 -->
<div class="profile-drawer__row">
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>开始时间
</label>
<el-date-picker
v-model="intern.startDate"
type="month"
placeholder="请输入开始时间"
format="YYYY-MM"
value-format="YYYY-MM"
class="profile-drawer__date-picker"
:teleported="true"
popper-class="profile-drawer-date-popper"
/>
</div>
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>结束时间
</label>
<el-date-picker
v-model="intern.endDate"
type="month"
placeholder="请输入结束时间"
format="YYYY-MM"
value-format="YYYY-MM"
class="profile-drawer__date-picker"
:teleported="true"
popper-class="profile-drawer-date-popper"
/>
</div>
</div>
<!-- 经历描述 分段编辑每段带删除按钮 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>经历描述
</label>
<!-- 描述段落列表 -->
<div
v-for="(desc, dIdx) in intern.description"
:key="desc.id"
class="profile-drawer__desc-item"
>
<textarea
class="profile-drawer__textarea profile-drawer__textarea--short"
placeholder="请输入经历描述"
v-model="desc.text"
rows="4"
></textarea>
<!-- 段落内右侧删除按钮 -->
<button
v-if="intern.description.length > 1"
class="profile-drawer__desc-remove"
@click="removeInternDescriptionParagraph(index, dIdx)"
aria-label="删除该段描述"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__desc-remove-icon">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- 新增一段描述按钮 -->
<button class="profile-drawer__add-btn profile-drawer__add-btn--small" @click="addInternDescriptionParagraph(index)">
<span class="profile-drawer__add-icon">+</span> 新增一段描述
</button>
</div>
</div>
<!-- 新增实习经历按钮 -->
<button class="profile-drawer__add-btn wp100 mb20" @click="addInternship">
<span class="profile-drawer__add-icon">+</span> 新增实习经历
</button>
</template>
<!-- ========== 项目经历模块 ========== -->
<template v-else-if="module === 'project'">
<div
v-for="(proj, index) in projectList"
:key="index"
class="profile-drawer__edu-card"
>
<!-- 项目经历标题栏序号 + 删除按钮 -->
<div class="profile-drawer__edu-header">
<span class="profile-drawer__edu-index">
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-index-icon">
<circle cx="8" cy="8" r="3" fill="currentColor"/>
</svg>
项目经历{{ index + 1 }}
</span>
<button
v-if="projectList.length > 1"
class="profile-drawer__edu-delete"
@click="removeProject(index)"
aria-label="删除"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-delete-icon">
<path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<!-- 项目名称 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>项目名称
</label>
<div class="profile-drawer__select-wrap">
<input class="profile-drawer__input" placeholder="请输入项目名称" v-model="proj.projectName" />
<!-- <svg viewBox="0 0 16 16" fill="none" class="profile-drawer__select-arrow"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> -->
</div>
</div>
<!-- 项目角色 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>项目角色
</label>
<div class="profile-drawer__select-wrap">
<input class="profile-drawer__input" placeholder="请输入项目角色" v-model="proj.role" />
</div>
</div>
<!-- 开始时间 + 结束时间横排 -->
<div class="profile-drawer__row">
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>开始时间
</label>
<el-date-picker
v-model="proj.startDate"
type="month"
placeholder="请输入开始时间"
format="YYYY-MM"
value-format="YYYY-MM"
class="profile-drawer__date-picker"
:teleported="true"
popper-class="profile-drawer-date-popper"
/>
</div>
<div class="profile-drawer__field profile-drawer__field--half">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>结束时间
</label>
<el-date-picker
v-model="proj.endDate"
type="month"
placeholder="请输入结束时间"
format="YYYY-MM"
value-format="YYYY-MM"
class="profile-drawer__date-picker"
:teleported="true"
popper-class="profile-drawer-date-popper"
/>
</div>
</div>
<!-- 经历描述 分段编辑每段带删除按钮 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>经历描述
</label>
<!-- 描述段落列表 -->
<div
v-for="(desc, dIdx) in proj.description"
:key="desc.id"
class="profile-drawer__desc-item"
>
<textarea
class="profile-drawer__textarea profile-drawer__textarea--short"
placeholder="请输入经历描述"
v-model="desc.text"
rows="4"
></textarea>
<!-- 段落内右侧删除按钮 -->
<button
v-if="proj.description.length > 1"
class="profile-drawer__desc-remove"
@click="removeDescriptionParagraph(index, dIdx)"
aria-label="删除该段描述"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__desc-remove-icon">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- 新增一段描述按钮 -->
<button class="profile-drawer__add-btn profile-drawer__add-btn--small" @click="addDescriptionParagraph(index)">
<span class="profile-drawer__add-icon">+</span> 新增一段描述
</button>
</div>
</div>
<!-- 新增项目经历按钮 -->
<button class="profile-drawer__add-btn wp100 mb20" @click="addProject">
<span class="profile-drawer__add-icon">+</span> 新增项目经历
</button>
</template>
<!-- ========== 竞赛模块 ========== -->
<template v-else-if="module === 'competition'">
<div
v-for="(comp, index) in competitionList"
:key="index"
class="profile-drawer__edu-card"
>
<!-- 竞赛经历标题栏序号 + 删除按钮 -->
<div class="profile-drawer__edu-header">
<span class="profile-drawer__edu-index">
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-index-icon">
<circle cx="8" cy="8" r="3" fill="currentColor"/>
</svg>
竞赛经历{{ index + 1 }}
</span>
<button
v-if="competitionList.length > 1"
class="profile-drawer__edu-delete"
@click="removeCompetition(index)"
aria-label="删除"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__edu-delete-icon">
<path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<!-- 竞赛名称 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>竞赛名称
</label>
<div class="profile-drawer__select-wrap">
<input class="profile-drawer__input" placeholder="请输入竞赛名称" v-model="comp.competitionName" />
<!-- <svg viewBox="0 0 16 16" fill="none" class="profile-drawer__select-arrow"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> -->
</div>
</div>
<!-- 获奖名次 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>获奖名次
</label>
<input class="profile-drawer__input" placeholder="请输入获奖名次" v-model="comp.award" />
</div>
<!-- 获奖时间 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">
<span class="profile-drawer__required">*</span>获奖时间
</label>
<el-date-picker
v-model="comp.awardDate"
type="month"
placeholder="请选择获奖时间"
format="YYYY-MM"
value-format="YYYY-MM"
class="profile-drawer__date-picker"
:teleported="true"
popper-class="profile-drawer-date-popper"
/>
</div>
<!-- 获奖情况描述 单段 textarea -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">获奖情况描述</label>
<textarea
class="profile-drawer__textarea"
placeholder="请输入获奖详情"
v-model="comp.description[0].text"
rows="5"
></textarea>
</div>
</div>
<!-- 新增获奖经历按钮 -->
<button class="profile-drawer__add-btn mb20" @click="addCompetition">
<span class="profile-drawer__add-icon">+</span> 新增获奖经历
</button>
</template>
<!-- ========== 作品集模块 ========== -->
<template v-else-if="module === 'portfolio'">
<div class="profile-drawer__field">
<label class="profile-drawer__label">作品集链接</label>
<textarea
class="profile-drawer__textarea"
placeholder="请输入/粘贴作品集链接"
v-model="portfolioUrl"
rows="6"
></textarea>
</div>
</template>
<!-- ========== 技能模块 ========== -->
<template v-else-if="module === 'skills'">
<!-- 技能标签列表 -->
<div class="profile-drawer__skills-list">
<div
v-for="(skill, index) in skillsList"
:key="index"
class="profile-drawer__skill-tag"
>
<span class="profile-drawer__skill-text">{{ skill }}</span>
<button
class="profile-drawer__skill-remove"
@click="removeSkill(index)"
aria-label="删除技能"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__skill-remove-icon">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
<!-- 添加技能输入框 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">添加新技能</label>
<input
class="profile-drawer__input"
type="text"
placeholder="输入技能名称后按回车或失焦自动添加"
v-model="newSkillInput"
@keydown.enter="handleAddSkill"
@blur="handleAddSkill"
/>
</div>
</template>
<!-- ========== 证书模块 ========== -->
<template v-else-if="module === 'certificate'">
<!-- 证书标签列表 -->
<div class="profile-drawer__skills-list">
<div
v-for="(cert, index) in certificatesList"
:key="index"
class="profile-drawer__skill-tag"
>
<span class="profile-drawer__skill-text">{{ cert }}</span>
<button
class="profile-drawer__skill-remove"
@click="removeCertificate(index)"
aria-label="删除证书"
>
<svg viewBox="0 0 16 16" fill="none" class="profile-drawer__skill-remove-icon">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
<!-- 添加证书输入框 -->
<div class="profile-drawer__field">
<label class="profile-drawer__label">添加新证书</label>
<input
class="profile-drawer__input"
type="text"
placeholder="输入证书名称后按回车或失焦自动添加"
v-model="newCertificateInput"
@keydown.enter="handleAddCertificate"
@blur="handleAddCertificate"
/>
</div>
</template>
<!-- ========== 个人概述模块仅简历使用 ========== -->
<template v-else-if="module === 'summary'">
<div class="profile-drawer__field">
<label class="profile-drawer__label">个人概述</label>
<textarea
class="profile-drawer__textarea"
placeholder="请输入个人概述,简要介绍自己的优势和职业目标"
v-model="summaryText"
rows="8"
></textarea>
</div>
</template>
<!-- ========== 其他模块占位 ========== -->
<template v-else>
<div class="profile-drawer__empty">
<p class="profile-drawer__empty-text">{{ moduleTitle }}编辑功能开发中</p>
</div>
</template>
</div>
<!-- 底部保存按钮 -->
<div class="profile-drawer__footer">
<button class="profile-drawer__save-btn" @click="handleSave">保存</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useStore } from 'vuex'
import RegionSelector from '@/components/tools/RegionSelector.vue'
/** 教育经历单条数据结构 — 对应数据库 bg_user_profile_education */
interface EducationItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 学校名称 */
school: string
/** 专业 */
major: string
/** 学历类型(数字模式:0=全日制 1=非全日制;文本模式:中文字符串) */
studyType: number | string
/** 学历(数字模式:1=大专 2=本科 3=硕士 4=博士;文本模式:中文字符串) */
degree: number | string
/** 入学时间(日期选择器使用字符串,格式:YYYY-MM) */
startDate: string
/** 毕业时间(日期选择器使用字符串,格式:YYYY-MM) */
endDate: string
/** 描述段落数组 */
description: DescriptionParagraph[]
}
/** 实习经历单条数据结构 — 对应数据库 bg_user_profile_internship */
interface InternshipItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 公司名称 */
companyName: string
/** 职位 */
position: string
/** 开始时间 */
startDate: string
/** 结束时间 */
endDate: string
/** 描述段落数组 */
description: DescriptionParagraph[]
}
/** 工作经历单条数据结构 — 对应数据库 bg_user_profile_work */
interface WorkItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 公司名称 */
companyName: string
/** 职位 */
position: string
/** 开始时间 */
startDate: string
/** 结束时间 */
endDate: string
/** 描述段落数组 */
description: DescriptionParagraph[]
}
/** 描述段落单条数据结构 — 对应数据库 description JSON 数组中的一项 */
interface DescriptionParagraph {
/** 前端生成的短标识,用于简历优化时精确定位段落 */
id: string
/** 段落文本内容 */
text: string
}
/** 项目经历单条数据结构 — 对应数据库 bg_user_profile_project */
interface ProjectItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 项目名称 */
projectName: string
/** 所属公司 */
companyName: string
/** 担任角色 */
role: string
/** 开始时间 */
startDate: string
/** 结束时间 */
endDate: string
/** 描述段落数组 */
description: DescriptionParagraph[]
}
/** 竞赛经历单条数据结构 — 对应数据库 bg_user_profile_competition */
interface CompetitionItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 竞赛名称 */
competitionName: string
/** 获奖情况 */
award: string
/** 获奖时间 */
awardDate: string
/** 描述段落(仅一段),格式:[{id, text}] */
description: DescriptionParagraph[]
}
/** 组件 Props */
const props = defineProps<{
/** 控制抽屉显示/隐藏 */
modelValue: boolean
/** 模块名称标识,如 'info' | 'education' | 'internship' 等 */
module: string
/** 初始数据 — 不同模块传入不同 JSON 结构 */
initialData?: Record<string, any>
/** 教育经历是否使用中文文本作为选项值(默认 false 使用数字编码,true 则输出中文字符串) */
useEducationTextValue?: boolean
}>()
/** 学历类型选项映射 — 根据 useEducationTextValue 决定 value 类型 */
const studyTypeOptions = computed(() => props.useEducationTextValue
? [{ value: '全日制', label: '全日制' }, { value: '非全日制', label: '非全日制' }]
: [{ value: 0, label: '全日制' }, { value: 1, label: '非全日制' }]
)
/** 学历选项映射 — 根据 useEducationTextValue 决定 value 类型 */
const degreeOptions = computed(() => props.useEducationTextValue
? [{ value: '大专', label: '大专' }, { value: '本科', label: '本科' }, { value: '硕士', label: '硕士' }, { value: '博士', label: '博士' }]
: [{ value: 1, label: '大专' }, { value: 2, label: '本科' }, { value: 3, label: '硕士' }, { value: 4, label: '博士' }]
)
/** 组件 Emits */
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'save', data: Record<string, any>): void
}>()
/** Vuex store 实例 — 用于从地区树中查找城市名称 */
const store = useStore()
/** 模块名称映射表 — 模块标识 → 中文标题 */
const moduleTitleMap: Record<string, string> = {
info: '基本信息',
summary: '个人概述',
education: '教育经历',
work: '工作经历',
internship: '实习经历',
project: '项目经历',
portfolio: '作品集',
skills: '技能',
competition: '竞赛',
certificate: '证书',
}
/** 当前模块的中文标题 */
const moduleTitle = computed(() => moduleTitleMap[props.module] || props.module)
/** 基本信息模块的字段配置 */
const infoFields = [
{ key: 'name', label: '姓名', placeholder: '请输入姓名', required: true },
{ key: 'email', label: '邮箱', placeholder: '请输入邮箱', required: true, type: 'email' },
{ key: 'phone', label: '电话', placeholder: '请输入联系电话', required: true, type: 'tel' },
{ key: 'location', label: '所在城市', placeholder: '请输入所在城市', required: true },
{ key: 'wechat', label: '微信', placeholder: '请输入微信号', required: false },
]
/** 表单数据 — 基本信息模块使用 */
const formData = ref<Record<string, any>>({})
/** 城市选择器当前选中的地区编码数组 */
const locationCodes = ref<string[]>([])
/** 地区选择器触发按钮的自定义样式 — 与抽屉内输入框保持一致 */
const regionTriggerStyle: Record<string, string> = {
width: '100%',
'box-sizing': 'border-box',
padding: '0.1rem 0.14rem',
'font-size': '0.13rem',
color: '#1a1a2e',
background: '#f6f6f9',
border: '1px solid transparent',
'border-radius': '0.06rem',
'max-width': 'none',
'justify-content': 'space-between',
}
/** 城市选择器确认回调 — 更新 locationCodes,同时将编码和城市名同步到 formData */
function onLocationChange(codes: string[]) {
locationCodes.value = codes
// 取第一个编码作为 regionCode 存入 formData
formData.value.location = codes[0] || ''
// 根据编码从地区树中查找城市名称,用于页面展示
formData.value.locationName = resolveRegionName(codes[0] || '')
}
/** 根据城市编码从 store 地区树中查找对应的城市名称 */
function resolveRegionName(code: string): string {
if (!code) return ''
for (const province of store.state.regions) {
for (const city of province.children) {
if (city.code === code) return city.name
if (city.children) {
for (const district of city.children) {
if (district.code === code) return district.name
}
}
}
}
return code
}
/** 教育经历列表 — 教育经历模块使用 */
const educationList = ref<EducationItem[]>([])
/** 创建一条空的教育经历数据 */
const createEmptyEducation = (): EducationItem => ({
school: '',
major: '',
studyType: props.useEducationTextValue ? '全日制' : 0,
degree: props.useEducationTextValue ? '本科' : 2,
startDate: '',
endDate: '',
description: [{ id: generateId(), text: '' }],
})
/** 新增一条教育经历 */
const addEducation = () => {
educationList.value.push(createEmptyEducation())
}
/** 删除指定索引的教育经历 */
const removeEducation = (index: number) => {
educationList.value.splice(index, 1)
}
/** 为指定教育经历新增一条描述段落 */
const addEducationDescriptionParagraph = (eduIndex: number) => {
educationList.value[eduIndex].description.push({ id: generateId(), text: '' })
}
/** 删除指定教育经历的指定描述段落 */
const removeEducationDescriptionParagraph = (eduIndex: number, descIndex: number) => {
educationList.value[eduIndex].description.splice(descIndex, 1)
}
/** 工作经历列表 — 工作经历模块使用 */
const workList = ref<WorkItem[]>([])
/** 创建一条空的工作经历数据 */
const createEmptyWork = (): WorkItem => ({
companyName: '',
position: '',
startDate: '',
endDate: '',
description: [{ id: generateId(), text: '' }],
})
/** 新增一条工作经历 */
const addWork = () => {
workList.value.push(createEmptyWork())
}
/** 删除指定索引的工作经历 */
const removeWork = (index: number) => {
workList.value.splice(index, 1)
}
/** 为指定工作经历新增一条描述段落 */
const addWorkDescriptionParagraph = (workIndex: number) => {
workList.value[workIndex].description.push({ id: generateId(), text: '' })
}
/** 删除指定工作经历的指定描述段落 */
const removeWorkDescriptionParagraph = (workIndex: number, descIndex: number) => {
workList.value[workIndex].description.splice(descIndex, 1)
}
/** 实习经历列表 — 实习经历模块使用 */
const internshipList = ref<InternshipItem[]>([])
/** 创建一条空的实习经历数据 */
const createEmptyInternship = (): InternshipItem => ({
companyName: '',
position: '',
startDate: '',
endDate: '',
description: [{ id: generateId(), text: '' }],
})
/** 新增一条实习经历 */
const addInternship = () => {
internshipList.value.push(createEmptyInternship())
}
/** 删除指定索引的实习经历 */
const removeInternship = (index: number) => {
internshipList.value.splice(index, 1)
}
/** 为指定实习经历新增一条描述段落 */
const addInternDescriptionParagraph = (internIndex: number) => {
internshipList.value[internIndex].description.push({ id: generateId(), text: '' })
}
/** 删除指定实习经历的指定描述段落 */
const removeInternDescriptionParagraph = (internIndex: number, descIndex: number) => {
internshipList.value[internIndex].description.splice(descIndex, 1)
}
/** 项目经历列表 — 项目经历模块使用 */
const projectList = ref<ProjectItem[]>([])
/** 创建一条空的项目经历数据 */
const createEmptyProject = (): ProjectItem => ({
projectName: '',
companyName: '',
role: '',
startDate: '',
endDate: '',
description: [{ id: generateId(), text: '' }],
})
/** 生成短标识 ID — 用于描述段落的唯一标识 */
const generateId = (): string => {
return Math.random().toString(36).substring(2, 8)
}
/** 为指定项目新增一条描述段落 */
const addDescriptionParagraph = (projIndex: number) => {
projectList.value[projIndex].description.push({ id: generateId(), text: '' })
}
/** 删除指定项目的指定描述段落 */
const removeDescriptionParagraph = (projIndex: number, descIndex: number) => {
projectList.value[projIndex].description.splice(descIndex, 1)
}
/** 新增一条项目经历 */
const addProject = () => {
projectList.value.push(createEmptyProject())
}
/** 删除指定索引的项目经历 */
const removeProject = (index: number) => {
projectList.value.splice(index, 1)
}
/** 竞赛经历列表 — 竞赛模块使用 */
const competitionList = ref<CompetitionItem[]>([])
/** 创建一条空的竞赛经历数据 */
const createEmptyCompetition = (): CompetitionItem => ({
competitionName: '',
award: '',
awardDate: '',
description: [{ id: generateId(), text: '' }],
})
/** 新增一条竞赛经历 */
const addCompetition = () => {
competitionList.value.push(createEmptyCompetition())
}
/** 删除指定索引的竞赛经历 */
const removeCompetition = (index: number) => {
competitionList.value.splice(index, 1)
}
/** 技能列表 — 技能模块使用 */
const skillsList = ref<string[]>([])
/** 作品集链接 — 作品集模块使用 */
const portfolioUrl = ref('')
/** 个人概述文本 — 个人概述模块使用 */
const summaryText = ref('')
/** 新技能输入框的值 */
const newSkillInput = ref('')
/** 添加新技能 — 当输入长度大于0的非空字符串时添加 */
const handleAddSkill = () => {
const trimmedSkill = newSkillInput.value.trim()
// 只有当输入的内容去除空格后长度大于0,且不重复时才添加
if (trimmedSkill.length > 0 && !skillsList.value.includes(trimmedSkill)) {
skillsList.value.push(trimmedSkill)
newSkillInput.value = ''
} else if (trimmedSkill.length === 0) {
// 如果是空字符串,直接清空输入框
newSkillInput.value = ''
}
}
/** 删除指定索引的技能 */
const removeSkill = (index: number) => {
skillsList.value.splice(index, 1)
}
/** 证书列表 — 证书模块使用 */
const certificatesList = ref<string[]>([])
/** 新证书输入框的值 */
const newCertificateInput = ref('')
/** 添加新证书 — 当输入长度大于0的非空字符串时添加 */
const handleAddCertificate = () => {
const trimmedCertificate = newCertificateInput.value.trim()
// 只有当输入的内容去除空格后长度大于0,且不重复时才添加
if (trimmedCertificate.length > 0 && !certificatesList.value.includes(trimmedCertificate)) {
certificatesList.value.push(trimmedCertificate)
newCertificateInput.value = ''
} else if (trimmedCertificate.length === 0) {
// 如果是空字符串,直接清空输入框
newCertificateInput.value = ''
}
}
/** 删除指定索引的证书 */
const removeCertificate = (index: number) => {
certificatesList.value.splice(index, 1)
}
/** 监听抽屉打开 — 初始化表单数据并锁定页面滚动 */
watch(() => props.modelValue, (visible) => {
if (visible) {
if (props.module === 'info') {
// 基本信息:用初始数据填充表单
formData.value = props.initialData ? { ...props.initialData } : {}
// 将城市编码同步到 locationCodes,确保 RegionSelector 能正确回显
locationCodes.value = formData.value.location ? [formData.value.location] : []
} else if (props.module === 'education') {
// 教育经历:用初始数据填充列表,若无则创建一条空记录(深拷贝 description 数组)
if (props.initialData?.education?.length) {
educationList.value = props.initialData.education.map((item: any) => ({
...item,
description: item.description.map((d: any) => ({ ...d })),
}))
} else {
educationList.value = [createEmptyEducation()]
}
} else if (props.module === 'work') {
// 工作经历:用初始数据填充列表,若无则创建一条空记录(深拷贝 description 数组)
if (props.initialData?.works?.length) {
workList.value = props.initialData.works.map((item: any) => ({
...item,
description: item.description.map((d: any) => ({ ...d })),
}))
} else {
workList.value = [createEmptyWork()]
}
} else if (props.module === 'internship') {
// 实习经历:用初始数据填充列表,若无则创建一条空记录(深拷贝 description 数组)
if (props.initialData?.internships?.length) {
internshipList.value = props.initialData.internships.map((item: any) => ({
...item,
description: item.description.map((d: any) => ({ ...d })),
}))
} else {
internshipList.value = [createEmptyInternship()]
}
} else if (props.module === 'project') {
// 项目经历:用初始数据填充列表,若无则创建一条空记录
if (props.initialData?.projects?.length) {
projectList.value = props.initialData.projects.map((item: any) => ({
...item,
description: item.description.map((d: any) => ({ ...d })),
}))
} else {
projectList.value = [createEmptyProject()]
}
} else if (props.module === 'competition') {
// 竞赛经历:用初始数据填充列表,若无则创建一条空记录
if (props.initialData?.competitions?.length) {
competitionList.value = props.initialData.competitions.map((item: any) => ({
...item,
description: item.description.map((d: any) => ({ ...d })),
}))
} else {
competitionList.value = [createEmptyCompetition()]
}
} else if (props.module === 'portfolio') {
// 作品集:用初始数据填充链接
portfolioUrl.value = props.initialData?.portfolioUrl || ''
} else if (props.module === 'summary') {
// 个人概述:用初始数据填充文本
summaryText.value = props.initialData?.summary || ''
} else if (props.module === 'skills') {
// 技能:用初始数据填充列表,若无则创建空数组
skillsList.value = props.initialData?.skills ? [...props.initialData.skills] : []
newSkillInput.value = ''
} else if (props.module === 'certificate') {
// 证书:用初始数据填充列表,若无则创建空数组
certificatesList.value = props.initialData?.certificates ? [...props.initialData.certificates] : []
newCertificateInput.value = ''
}
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
/** 关闭抽屉 */
const handleClose = () => {
emit('update:modelValue', false)
}
/** 保存数据 — 将表单数据通过事件传递给父组件 */
const handleSave = () => {
if (props.module === 'info') {
emit('save', { ...formData.value })
} else if (props.module === 'education') {
emit('save', { education: educationList.value.map(item => ({
...item,
description: item.description.map(d => ({ ...d })),
})) })
} else if (props.module === 'work') {
emit('save', { works: workList.value.map(item => ({
...item,
description: item.description.map(d => ({ ...d })),
})) })
} else if (props.module === 'internship') {
emit('save', { internships: internshipList.value.map(item => ({
...item,
description: item.description.map(d => ({ ...d })),
})) })
} else if (props.module === 'project') {
emit('save', { projects: projectList.value.map(item => ({
...item,
description: item.description.map(d => ({ ...d })),
})) })
} else if (props.module === 'competition') {
emit('save', { competitions: competitionList.value.map(item => ({
...item,
description: item.description.map(d => ({ ...d })),
})) })
} else if (props.module === 'portfolio') {
emit('save', { portfolioUrl: portfolioUrl.value })
} else if (props.module === 'summary') {
emit('save', { summary: summaryText.value })
} else if (props.module === 'skills') {
emit('save', { skills: [...skillsList.value] })
} else if (props.module === 'certificate') {
emit('save', { certificates: [...certificatesList.value] })
}
emit('update:modelValue', false)
}
</script>