新版交互调整

This commit is contained in:
2026-06-24 19:03:41 +08:00
parent 065553f53e
commit 8143a89dab
37 changed files with 4929 additions and 4409 deletions
+86 -42
View File
@@ -1,31 +1,41 @@
<template>
<!-- Agent会话岗位列表组件 聊天区域中显示推荐岗位卡片 -->
<!-- Agent推荐岗位列表组件 示推荐岗位卡片 -->
<div class="agent-chat-job-list">
<!-- 标题 -->
<div class="agent-chat-job-list__header">为你推荐的岗位</div>
<!-- 推荐说明文字 -->
<div class="agent-chat-job-list__summary">{{ summary }}</div>
<div class="agent-chat-job-list__summary">{{ summary || '根据求职目标和简历信息智能推荐' }}</div>
<!-- 岗位列表显示前3个 -->
<!-- 岗位列表显示前3个 -->
<div
v-for="job in displayJobs"
:key="job.id"
class="agent-chat-job-list__item"
v-if="displayJobs.length>0"
@click="handleClickJob(job)"
>
<!-- 左侧公司图标 + 岗位信息 -->
<div class="agent-chat-job-list__info">
<!-- 公司 Logo -->
<div class="agent-chat-job-list__logo">
<img v-if="job.companyLogoUrl" :src="job.companyLogoUrl" :alt="job.companyShortName" />
<svg v-else viewBox="0 0 24 24" fill="none">
<path d="M3 21V7l9-4 9 4v14H3z" stroke="currentColor" stroke-width="1.5" />
<path d="M9 21v-6h6v6" stroke="currentColor" stroke-width="1.5" />
<!-- 左侧匹配度环形 + 岗位信息 -->
<div class="agent-chat-job-list__info" @click="handleClickJob(job)">
<!-- 匹配度环形 -->
<div class="agent-chat-job-list__score">
<svg class="agent-chat-job-list__ring" viewBox="0 0 44 44">
<circle cx="22" cy="22" r="18" fill="none" stroke="#E8E8E8" stroke-width="3" />
<circle
cx="22" cy="22" r="18" fill="none"
stroke="#4FC2C9" stroke-width="3"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 18"
:stroke-dashoffset="2 * Math.PI * 18 * (1 - (job.matchScore || 0) / 100)"
transform="rotate(-90 22 22)"
/>
</svg>
<span class="agent-chat-job-list__score-text">{{ job.matchScore || 0 }}%</span>
</div>
<!-- 岗位详情 -->
<div class="agent-chat-job-list__detail">
<div class="agent-chat-job-list__company">{{ job.companyShortName || job.companyName }}</div>
<div class="agent-chat-job-list__title">{{ job.title }}</div>
<div class="agent-chat-job-list__title-row">
<span class="agent-chat-job-list__title">{{ job.title }}</span>
<span v-if="job.companyShortName || job.companyName" class="agent-chat-job-list__company-dot"></span>
<span class="agent-chat-job-list__company">{{ job.companyShortName || job.companyName }}</span>
</div>
<!-- 标签 -->
<div class="agent-chat-job-list__tags">
<span v-if="job.regionName" class="agent-chat-job-list__tag">{{ job.regionName }}</span>
@@ -35,31 +45,38 @@
</div>
</div>
<!-- 右侧匹配度环形 -->
<div class="agent-chat-job-list__score">
<svg class="agent-chat-job-list__ring" viewBox="0 0 44 44">
<!-- 背景圆环 -->
<circle cx="22" cy="22" r="18" fill="none" stroke="#E8E8E8" stroke-width="3" />
<!-- 进度圆环 -->
<circle
cx="22" cy="22" r="18" fill="none"
stroke="#4FC2C9" stroke-width="3"
stroke-linecap="round"
:stroke-dasharray="2 * Math.PI * 18"
:stroke-dashoffset="2 * Math.PI * 18 * (1 - (job.matchScore || 0) / 100)"
transform="rotate(-90 22 22)"
/>
</svg>
<span class="agent-chat-job-list__score-text">{{ job.matchScore || 0 }}%</span>
<!-- 右侧操作按钮 -->
<div class="agent-chat-job-list__actions">
<!-- 不再推荐 -->
<span class="agent-chat-job-list__dismiss-btn" @click.stop="handleDismiss(job)">不再推荐</span>
<!-- 添加/已加入按钮 -->
<button
v-if="job.applicationStatus === -1"
class="agent-chat-job-list__add-btn agent-chat-job-list__add-btn--added"
disabled
>已加入</button>
<button
v-else
class="agent-chat-job-list__add-btn"
:disabled="loadingJobIds.includes(job.id)"
@click.stop="handleAdd(job)"
>+ 加入投递</button>
</div>
</div>
<div class="color-9" v-if="displayJobs.length==0">
<!-- 空状态 -->
<div class="color-9" v-if="displayJobs.length === 0">
暂无相关职位推荐
</div>
<!-- 查看全部岗位按钮 -->
<div v-if="displayJobs.length>0" class="agent-chat-job-list__footer">
<button class="agent-chat-job-list__view-all-btn" @click="handleViewAll">查看全部岗位</button>
<!-- 底部按钮区域 -->
<div v-if="displayJobs.length > 0" class="agent-chat-job-list__footer">
<!-- 换一批按钮 -->
<button class="agent-chat-job-list__refresh-btn" :disabled="refreshing" @click="handleRefresh">
{{ refreshing ? '加载中...' : '换一批' }}
</button>
<!-- 全部添加按钮 -->
<button class="agent-chat-job-list__add-all-btn" @click="handleAddAll">全部添加</button>
</div>
</div>
</template>
@@ -72,30 +89,57 @@ import type { AgentRecommendJob } from '@/api/agent'
const props = defineProps<{
/** 推荐说明文字 */
summary: string
/** 完整岗位列表数据 */
/** 当前展示的岗位列表数据(父组件已随机抽取10个) */
jobs: AgentRecommendJob[]
/** 正在请求中的岗位 ID 列表 */
loadingJobIds?: number[]
/** 是否正在换一批加载中 */
refreshing?: boolean
}>()
/** 事件 */
const emit = defineEmits<{
/** 点击查看全部岗位 */
/** 点击查看全部岗位(打开右侧面板) */
(e: 'viewAll'): void
/** 点击岗位查看详情 */
(e: 'clickJob', job: AgentRecommendJob): void
/** 添加单个岗位到待投递 */
(e: 'add', job: AgentRecommendJob): void
/** 全部添加 */
(e: 'addAll'): void
/** 换一批 */
(e: 'refresh'): void
/** 不再推荐 */
(e: 'dismiss', job: AgentRecommendJob): void
}>()
/** 只显示前3个岗位 */
const displayJobs = computed(() => props.jobs.slice(0, 3))
/** 点击查看全部岗位 */
function handleViewAll() {
emit('viewAll')
}
/** 点击岗位 — 通知父组件打开岗位预览 */
function handleClickJob(job: AgentRecommendJob) {
emit('clickJob', job)
}
/** 添加岗位到待投递 */
function handleAdd(job: AgentRecommendJob) {
emit('add', job)
}
/** 全部添加 */
function handleAddAll() {
emit('addAll')
}
/** 换一批 */
function handleRefresh() {
emit('refresh')
}
/** 不再推荐 */
function handleDismiss(job: AgentRecommendJob) {
emit('dismiss', job)
}
</script>
<style scoped lang="scss">
+200 -551
View File
@@ -1,207 +1,81 @@
<template>
<!-- Agent设置面板 右侧面板 -->
<!-- Agent设置面板 右侧常驻面板精简版 -->
<div class="agent-setting-panel">
<!-- 顶部标题栏 -->
<div class="agent-setting-panel__header">
<span class="agent-setting-panel__title">设置</span>
<!-- 关闭按钮 -->
<button class="agent-setting-panel__close-btn" @click="emit('close')">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="#1A1A2E" />
<path d="M8 8l8 8M16 8l-8 8" stroke="#fff" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
<!-- 内容区域可滚动 -->
<div class="agent-setting-panel__body">
<!-- ========== 个人资料设置项 ========== -->
<!-- ========== 个人资料 ========== -->
<div class="agent-setting-panel__section">
<!-- 个人资料标题行标题 + 编辑按钮含展开/收起箭头 -->
<div class="agent-setting-panel__section-header" @click="toggleProfileExpand">
<div class="agent-setting-panel__section-header">
<span class="agent-setting-panel__section-title">个人资料</span>
<div class="agent-setting-panel__section-action">
<span class="agent-setting-panel__section-action-text">{{ profileExpanded ? '收起' : '编辑' }}</span>
<!-- 方向箭头展开时朝上收起时朝下 -->
<svg
viewBox="0 0 16 16"
fill="none"
class="agent-setting-panel__arrow"
:class="{ 'agent-setting-panel__arrow--up': profileExpanded }"
>
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<span class="agent-setting-panel__edit-btn" @click="handleEditProfile">编辑 </span>
</div>
<!-- 收起时的简要信息预览 -->
<div v-if="!profileExpanded" class="agent-setting-panel__section-summary">
<span v-if="profile.name">{{ profile.name }}</span>
<span v-if="profile.phone"> · {{ profile.phone }}</span>
<span v-if="profile.email"> · {{ profile.email }}</span>
<span v-if="!profile.name && !profile.phone && !profile.email" class="agent-setting-panel__section-empty">暂未填写个人资料</span>
</div>
<!-- 展开时显示 ProfilePageContent 组件 -->
<div v-if="profileExpanded" class="agent-setting-panel__section-content">
<ProfilePageContent :profile="profile" @edit="handleEdit" />
<!-- 简要信息展示 -->
<div class="agent-setting-panel__profile-info">
<div class="agent-setting-panel__profile-name">{{ profile.name || '未填写' }}</div>
<div class="agent-setting-panel__profile-sub">{{ profileSubText }}</div>
</div>
</div>
<!-- ========== 其他设置项待设计稿补充 ========== -->
<!-- ========== 求职目标 ========== -->
<div class="agent-setting-panel__section">
<!-- 求职目标标题行 + 编辑按钮 -->
<div class="agent-setting-panel__section-header" @click="showJobGoalDialog = true">
<div class="agent-setting-panel__section-header">
<span class="agent-setting-panel__section-title">求职目标</span>
<div class="agent-setting-panel__section-action">
<span class="agent-setting-panel__section-action-text">编辑</span>
<svg viewBox="0 0 16 16" fill="none" class="agent-setting-panel__edit-icon">
<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
</div>
<span class="agent-setting-panel__edit-btn" @click="showJobGoalDialog = true">编辑 </span>
</div>
<!-- 求职意向标签展示 -->
<!-- 求职意向标签 -->
<div class="agent-setting-panel__goal-tags">
<span v-for="name in intentionCategoryNames" :key="'cat-' + name" class="agent-setting-panel__goal-tag">{{ name }}</span>
<span v-for="name in intentionIndustryNames" :key="'ind-' + name" class="agent-setting-panel__goal-tag">{{ name }}</span>
<span v-for="name in intentionRegionNames" :key="'reg-' + name" class="agent-setting-panel__goal-tag">{{ name }}</span>
<span v-if="intentionEmploymentLabel" class="agent-setting-panel__goal-tag">{{ intentionEmploymentLabel }}</span>
<!-- 无意向时的空状态 -->
<span v-for="name in intentionTags" :key="name" class="agent-setting-panel__goal-tag">{{ name }}</span>
<span v-if="intentionTags.length === 0" class="agent-setting-panel__empty-text">暂未设置</span>
</div>
</div>
<!-- ========== 投递简历 ========== -->
<div class="agent-setting-panel__section">
<div class="agent-setting-panel__section-header">
<span class="agent-setting-panel__section-title">浏览器插件</span>
<div class="agent-setting-panel__section-title">投递简历</div>
<el-select v-model="selectedResumeId" placeholder="请选择简历" class="agent-setting-panel__resume-select" @change="handleResumeChange">
<el-option v-for="r in resumeList" :key="r.id" :label="r.resumeName" :value="r.id || ''" />
</el-select>
</div>
<!-- ========== 投递偏好 ========== -->
<div class="agent-setting-panel__section">
<div class="agent-setting-panel__section-title">投递偏好</div>
<!-- 简历针对性优化 -->
<div class="agent-setting-panel__pref-item">
<div class="agent-setting-panel__pref-left">
<span class="agent-setting-panel__pref-check" :class="{ 'agent-setting-panel__pref-check--active': autoOptimizeSwitch }">
<svg v-if="autoOptimizeSwitch" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M5 8l2 2 4-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg v-else viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/></svg>
</span>
<div class="agent-setting-panel__pref-text">
<span class="agent-setting-panel__pref-label">开启简历针对性优化</span>
<span class="agent-setting-panel__pref-desc">结合岗位要求自动优化简历表达</span>
</div>
</div>
<el-switch v-model="autoOptimizeSwitch" active-color="#4FC2C9" @change="handleSavePreference" />
</div>
<!-- 浏览器按钮列表 -->
<!-- 深度人岗匹配分析 -->
<div class="agent-setting-panel__pref-item">
<div class="agent-setting-panel__pref-left">
<span class="agent-setting-panel__pref-check" :class="{ 'agent-setting-panel__pref-check--active': autoMatchAnalysisSwitch }">
<svg v-if="autoMatchAnalysisSwitch" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M5 8l2 2 4-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg v-else viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/></svg>
</span>
<div class="agent-setting-panel__pref-text">
<span class="agent-setting-panel__pref-label">开启深度人岗匹配分析</span>
<span class="agent-setting-panel__pref-desc">根据岗位要求分析你的匹配优势与差距</span>
</div>
</div>
<el-switch v-model="autoMatchAnalysisSwitch" active-color="#4FC2C9" @change="handleSavePreference" />
</div>
</div>
<!-- ========== 浏览器插件 ========== -->
<div class="agent-setting-panel__section">
<div class="agent-setting-panel__section-title">浏览器插件</div>
<div class="agent-setting-panel__browser-btns">
<button
v-for="b in browserList"
:key="b.key"
class="agent-setting-panel__browser-btn"
@click="openBrowserGuide(b.key)"
>{{ b.label }}</button>
</div>
<!-- 下载插件按钮 -->
<div class="agent-setting-panel__download-wrap">
<button class="agent-setting-panel__download-btn" @click="downloadExtension">下载插件</button>
</div>
</div>
<div class="agent-setting-panel__section">
<div class="agent-setting-panel__section-header">
<span class="agent-setting-panel__section-title">求职助手配置</span>
</div>
<!-- 网申常见问题 -->
<div class="agent-setting-panel__config-group">
<div class="agent-setting-panel__config-label">是否愿意接受部门调剂</div>
<div class="agent-setting-panel__config-options">
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.acceptDeptTransfer === '是,服从调剂' }" @click="configForm.acceptDeptTransfer = '是,服从调剂'">服从调剂</button>
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.acceptDeptTransfer === '否,不调剂' }" @click="configForm.acceptDeptTransfer = '否,不调剂'">不调剂</button>
</div>
</div>
<div class="agent-setting-panel__config-group">
<div class="agent-setting-panel__config-label">是否接受地点调剂</div>
<div class="agent-setting-panel__config-options">
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.acceptLocationTransfer === '是' }" @click="configForm.acceptLocationTransfer = '是'"></button>
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.acceptLocationTransfer === '否' }" @click="configForm.acceptLocationTransfer = '否'"></button>
</div>
</div>
<div class="agent-setting-panel__config-group">
<div class="agent-setting-panel__config-label">可以参加面试的方式</div>
<div class="agent-setting-panel__config-options">
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.interviewType.includes('线下面试') }" @click="toggleInterviewType('线下面试')">线下面试</button>
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.interviewType.includes('线上远程') }" @click="toggleInterviewType('线上远程')">线上远程</button>
</div>
</div>
<div class="agent-setting-panel__config-group">
<div class="agent-setting-panel__config-label">你的语言能力</div>
<div class="agent-setting-panel__config-selects">
<el-select v-model="configForm.languages[0].language" placeholder="语种" class="agent-setting-panel__config-select">
<el-option v-for="lang in languageOptions" :key="lang" :label="lang" :value="lang" />
</el-select>
<el-select v-model="configForm.languages[0].proficiency" placeholder="掌握程度" class="agent-setting-panel__config-select">
<el-option v-for="p in proficiencyOptions" :key="p" :label="p" :value="p" />
</el-select>
</div>
</div>
<div class="agent-setting-panel__config-group">
<div class="agent-setting-panel__config-label">预计到岗时间</div>
<div class="agent-setting-panel__config-selects">
<el-select v-model="configForm.availableDate" placeholder="请选择" class="agent-setting-panel__config-select">
<el-option v-for="d in availableDateOptions" :key="d" :label="d" :value="d" />
</el-select>
</div>
</div>
<!-- 实习相关仅实习类型时显示 -->
<template v-if="isInternship">
<div class="agent-setting-panel__config-group">
<div class="agent-setting-panel__config-label">每周可实习天数</div>
<div class="agent-setting-panel__config-selects">
<el-select v-model="configForm.internDaysPerWeek" placeholder="请选择" class="agent-setting-panel__config-select">
<el-option v-for="d in internDaysOptions" :key="d" :label="d" :value="d" />
</el-select>
</div>
</div>
<div class="agent-setting-panel__config-group">
<div class="agent-setting-panel__config-label">预计实习时长</div>
<div class="agent-setting-panel__config-selects">
<el-select v-model="configForm.internDuration" placeholder="请选择" class="agent-setting-panel__config-select">
<el-option v-for="d in internDurationOptions" :key="d" :label="d" :value="d" />
</el-select>
</div>
</div>
</template>
<!-- 简历设置 -->
<div class="agent-setting-panel__config-group">
<div class="agent-setting-panel__config-label">
简历设置
<el-tooltip content="选择投递时使用的默认简历" placement="top">
<span class="agent-setting-panel__tip"></span>
</el-tooltip>
</div>
<div class="agent-setting-panel__config-sub">设置默认简历</div>
<div class="agent-setting-panel__resume-row">
<svg viewBox="0 0 16 16" fill="none" class="agent-setting-panel__resume-icon">
<path d="M10 1H4a1.5 1.5 0 00-1.5 1.5v11A1.5 1.5 0 004 15h8a1.5 1.5 0 001.5-1.5V4.5L10 1z" stroke="currentColor" stroke-width="1"/>
<path d="M10 1v3.5h3.5" stroke="currentColor" stroke-width="1"/>
</svg>
<el-select v-model="selectedResumeId" placeholder="请选择简历" class="agent-setting-panel__config-select agent-setting-panel__config-select--full">
<el-option v-for="r in resumeList" :key="r.id" :label="r.resumeName" :value="r.id || ''" />
</el-select>
</div>
</div>
<!-- 自动优化简历开关 -->
<div class="agent-setting-panel__config-group">
<div class="agent-setting-panel__switch-row">
<div class="agent-setting-panel__switch-text">
<span>在投递时帮我针对岗位自动优化简历</span>
<el-tooltip content="MVP只补充缺少技能" placement="top">
<span class="agent-setting-panel__tip"></span>
</el-tooltip>
<br/>
<span class="agent-setting-panel__switch-sub">MVP只补充缺少技能</span>
</div>
<el-switch v-model="autoOptimizeSwitch" active-color="#4FC2C9" />
</div>
</div>
<!-- 提交设置按钮 -->
<div class="agent-setting-panel__submit-wrap">
<button class="agent-setting-panel__submit-btn" :disabled="configSaving" @click="handleSubmitConfig">
{{ configSaving ? '保存中...' : '保存设置' }}
</button>
<button class="agent-setting-panel__browser-btn" @click="openBrowserGuide('chrome')">Chrome浏览器</button>
<button class="agent-setting-panel__browser-btn" @click="openBrowserGuide('edge')">Edge浏览器</button>
</div>
</div>
</div>
@@ -217,17 +91,6 @@
<!-- 求职目标设置弹窗 -->
<JobGoalDialog v-model="showJobGoalDialog" />
<!-- 浏览器安装指引弹窗 -->
<el-dialog v-model="showBrowserGuide" :title="currentBrowserLabel + ' 安装指引'" width="80%" top="5vh" destroy-on-close>
<el-carousel :autoplay="false" indicator-position="outside" height="70vh" arrow="always">
<el-carousel-item v-for="(img, i) in currentBrowserImages" :key="i">
<div class="agent-setting-panel__guide-slide">
<img :src="img" :alt="currentBrowserLabel + ' 安装步骤 ' + (i + 1)" />
</div>
</el-carousel-item>
</el-carousel>
</el-dialog>
</div>
</template>
@@ -235,240 +98,43 @@
import { ref, computed, onMounted } from 'vue'
import { useStore } from 'vuex'
import { formatEmploymentType } from '@/stores/index'
import ProfilePageContent from '@/components/ProfilePageContent.vue'
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
import JobGoalDialog from '@/components/JobGoalDialog.vue'
import {
saveProfile, saveEducation, saveWork, saveInternship, saveProject, saveCompetition,
fetchProfile, fetchEducation, fetchWork, fetchInternship, fetchProject, fetchCompetition
} from '@/api/profile'
import type { SaveEducationItem, SaveWorkItem, SaveProjectItem, SaveCompetitionItem } from '@/api/profile'
import { saveProfile, fetchProfile, fetchEducation } from '@/api/profile'
import { resolveRegionName } from '@/utils/region'
import { resolveIndustryName } from '@/utils/industry'
import { resolveJobCategoryName } from '@/utils/jobCategory'
import { fetchResumeList } from '@/api/resume'
import { fetchResumeList, setDefaultResume } from '@/api/resume'
import type { ResumeListItem } from '@/api/resume'
import { fetchAgentConfig, saveAgentConfig } from '@/api/agent'
/** 事件 */
const emit = defineEmits<{
/** 关闭面板 */
(e: 'close'): void
}>()
const store = useStore()
// ==================== 个人资料展开/收起状态 ====================
// ==================== 个人资料 ====================
/** 个人资料区域是否展开 */
const profileExpanded = ref(false)
/** 切换个人资料展开/收起 */
function toggleProfileExpand() {
profileExpanded.value = !profileExpanded.value
}
// ==================== 求职目标 ====================
/** 求职目标设置弹窗显隐 */
const showJobGoalDialog = ref(false)
/** 求职意向 — 岗位类型名称列表 */
const intentionCategoryNames = computed(() => (store.state.jobIntention?.categoryIds || []).map((id: number) => resolveJobCategoryName(id)).filter(Boolean))
/** 求职意向 — 行业名称列表 */
const intentionIndustryNames = computed(() => (store.state.jobIntention?.industryIds || []).map((id: number) => resolveIndustryName(id)).filter(Boolean))
/** 求职意向 — 地区名称列表 */
const intentionRegionNames = computed(() => (store.state.jobIntention?.regionCodes || []).map((code: string) => resolveRegionName(code)).filter(Boolean))
/** 求职意向 — 招聘分类文案 */
const intentionEmploymentLabel = computed(() => formatEmploymentType(store.state.jobIntention?.recruitCategory))
// ==================== 浏览器插件 ====================
/** 插件下载地址 */
const extensionDownloadUrl = 'https://offerpie.oss-cn-guangzhou.aliyuncs.com/extension/chrome-mv3-prod.rar'
/** 浏览器列表 */
const browserList = [
{ key: 'chrome', label: 'Chrome浏览器' },
{ key: 'edge', label: 'Edge浏览器' },
{ key: 'safari', label: 'Safari浏览器' },
{ key: '360', label: '360浏览器' },
{ key: 'qq', label: 'QQ浏览器' },
]
/** 每个浏览器的指引图片数量 */
const browserImageCount: Record<string, number> = { chrome: 3, edge: 3, safari: 3, '360': 3, qq: 3 }
/** 指引图片 OSS 基础路径 */
const guidanceImageBase = 'http://offerpie.oss-cn-guangzhou.aliyuncs.com/extension/guidance_image'
/** 浏览器安装指引弹窗显隐 */
const showBrowserGuide = ref(false)
/** 当前选中的浏览器 key */
const currentBrowserKey = ref('')
/** 当前浏览器名称 */
const currentBrowserLabel = computed(() => browserList.find(b => b.key === currentBrowserKey.value)?.label || '')
/** 当前浏览器的指引图片列表 */
const currentBrowserImages = computed(() => {
const key = currentBrowserKey.value
if (!key) return []
const count = browserImageCount[key] || 3
return Array.from({ length: count }, (_, i) => `${guidanceImageBase}/guidance-image-${key}-${String(i + 1).padStart(2, '0')}.png`)
})
/** 打开浏览器安装指引弹窗 */
function openBrowserGuide(key: string) {
currentBrowserKey.value = key
showBrowserGuide.value = true
}
/** 下载插件 */
function downloadExtension() {
window.open(extensionDownloadUrl, '_blank')
}
// ==================== 求职助手配置 ====================
/** 是否为实习类型 */
const isInternship = computed(() => store.state.jobIntention?.recruitCategory === 2)
/** 配置表单数据 */
const configForm = ref({
acceptDeptTransfer: '',
acceptLocationTransfer: '',
interviewType: [] as string[],
languages: [{ language: '', proficiency: '' }] as Array<{ language: string; proficiency: string }>,
availableDate: '',
internDaysPerWeek: '',
internDuration: '',
})
/** 语种选项 */
const languageOptions = ['英语', '日语', '法语', '德语', '韩语', '西班牙语', '俄语']
/** 掌握程度选项 */
const proficiencyOptions = ['入门', '日常会话', '商务会话', '无障碍沟通', '母语']
/** 到岗时间选项 */
const availableDateOptions = ['一周以内', '两周以内', '一个月以内', '一个月以上']
/** 实习天数选项 */
const internDaysOptions = ['3天及以上', '4天及以上', '5天及以上']
/** 实习时长选项 */
const internDurationOptions = ['3个月', '4个月', '5个月', '6个月及以上']
/** 切换面试方式(多选) */
function toggleInterviewType(type: string) {
const idx = configForm.value.interviewType.indexOf(type)
idx >= 0 ? configForm.value.interviewType.splice(idx, 1) : configForm.value.interviewType.push(type)
}
/** 简历列表 */
const resumeList = ref<ResumeListItem[]>([])
/** 当前选中的简历 ID */
const selectedResumeId = ref('')
/** 自动优化简历开关 */
const autoOptimizeSwitch = ref(true)
/** 保存中状态 */
const configSaving = ref(false)
/** 加载简历列表 */
async function loadResumeList() {
try {
const res = await fetchResumeList()
if (res.code === '0' && res.data) {
resumeList.value = res.data
const defaultResume = res.data.find(r => r.isDefault === 1)
if (defaultResume && defaultResume.id) {
selectedResumeId.value = defaultResume.id
} else if (res.data.length > 0 && res.data[0].id) {
selectedResumeId.value = res.data[0].id
}
}
} catch { console.error('[AgentSettingPanel] 加载简历列表失败') }
}
/** 加载已有的求职助手配置 — 填充表单 */
async function loadAgentConfig() {
try {
const res = await fetchAgentConfig()
if (res.code === '0' && res.data) {
const cfg = res.data
configForm.value.acceptDeptTransfer = cfg.acceptDeptTransfer || ''
configForm.value.acceptLocationTransfer = cfg.acceptLocationTransfer || ''
configForm.value.interviewType = cfg.interviewType ? [...cfg.interviewType] : []
configForm.value.languages = cfg.languages?.length
? cfg.languages.map(l => ({ language: l.language || '', proficiency: l.proficiency || '' }))
: [{ language: '', proficiency: '' }]
configForm.value.availableDate = cfg.availableDate || ''
configForm.value.internDaysPerWeek = cfg.internDaysPerWeek || ''
configForm.value.internDuration = cfg.internDuration || ''
if (cfg.autoOptimizeResume !== undefined) autoOptimizeSwitch.value = cfg.autoOptimizeResume === 1
}
} catch { console.error('[AgentSettingPanel] 加载求职助手配置失败') }
}
/** 提交设置 — 调用 saveAgentConfig 接口 */
async function handleSubmitConfig() {
configSaving.value = true
try {
await saveAgentConfig({
acceptDeptTransfer: configForm.value.acceptDeptTransfer,
acceptLocationTransfer: configForm.value.acceptLocationTransfer,
interviewType: configForm.value.interviewType,
languages: configForm.value.languages.filter(l => l.language),
availableDate: configForm.value.availableDate,
internDaysPerWeek: configForm.value.internDaysPerWeek,
internDuration: configForm.value.internDuration,
autoOptimizeResume: autoOptimizeSwitch.value ? 1 : 0,
})
ElMessage.success('设置保存成功')
} catch {
ElMessage.error('设置保存失败,请重试')
} finally {
configSaving.value = false
}
}
// ==================== 个人资料数据 ====================
/** 个人档案响应式数据 */
/** 个人资料数据 */
const profile = ref({
name: '', phone: '', email: '', idNumber: '', regionCode: '', portfolioUrl: '', wechat: '',
skills: [] as string[], certificates: [] as string[],
education: [] as Array<{ school: string; major: string; studyType: number; degree: number; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>,
works: [] as Array<{ companyName: string; position: string; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>,
internships: [] as Array<{ companyName: string; position: string; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>,
projects: [] as Array<{ projectName: string; companyName: string; role: string; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>,
competitions: [] as Array<{ competitionName: string; award: string; awardDate: string; description: Array<{ id: string; text: string }> }>,
name: '',
phone: '',
email: '',
regionCode: '',
wechat: '',
skills: [] as string[],
certificates: [] as string[],
portfolioUrl: '',
education: [] as Array<{ school: string; major: string; degree: number }>,
})
// ==================== 加载个人资料 ====================
onMounted(async () => {
if (!store.state.regions.length) store.dispatch('loadCommonData')
store.dispatch('loadJobIntention')
await loadProfile()
await loadEducation()
await loadWork()
await loadInternship()
await loadProject()
await loadCompetition()
await loadResumeList()
await loadAgentConfig()
/** 个人资料副标题(学校·学历) */
const profileSubText = computed(() => {
if (profile.value.education.length === 0) return ''
const edu = profile.value.education[0]
const degreeMap: Record<number, string> = { 1: '大专', 2: '本科', 3: '硕士', 4: '博士' }
const parts = [edu.school, degreeMap[edu.degree]].filter(Boolean)
return parts.join('·')
})
/** 加载基本信息 */
/** 加载个人资料 */
async function loadProfile() {
try {
const res = await fetchProfile()
@@ -477,181 +143,164 @@ async function loadProfile() {
profile.value.name = d.name || ''
profile.value.phone = d.mobileNumber || ''
profile.value.email = d.email || ''
profile.value.idNumber = d.idCard || ''
profile.value.regionCode = d.regionCode || ''
profile.value.wechat = d.wechatNumber || ''
profile.value.skills = d.skills || []
profile.value.certificates = d.certificates || []
profile.value.portfolioUrl = d.portfolioUrl || ''
}
} catch { console.error('[AgentSettingPanel] 加载个人资料失败') }
} catch { /* 静默 */ }
}
/** 加载教育经历 */
/** 加载教育经历(只取第一条显示) */
async function loadEducation() {
try {
const res = await fetchEducation()
if (res.code === '0' && res.data) {
if (res.code === '0' && res.data && res.data.length > 0) {
profile.value.education = res.data.map(item => ({
school: item.school || '', major: item.major || '', studyType: item.studyType ?? 0,
degree: item.degree ?? 2, startDate: item.startDate || '', endDate: item.endDate || '',
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
school: item.school || '',
major: item.major || '',
degree: item.degree ?? 2,
}))
}
} catch { console.error('[AgentSettingPanel] 加载教育经历失败') }
}
/** 加载工作经历 */
async function loadWork() {
try {
const res = await fetchWork()
if (res.code === '0' && res.data) {
profile.value.works = res.data.map(item => ({
companyName: item.companyName || '', position: item.position || '',
startDate: item.startDate || '', endDate: item.endDate || '',
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
}))
}
} catch { console.error('[AgentSettingPanel] 加载工作经历失败') }
}
/** 加载实习经历 */
async function loadInternship() {
try {
const res = await fetchInternship()
if (res.code === '0' && res.data) {
profile.value.internships = res.data.map(item => ({
companyName: item.companyName || '', position: item.position || '',
startDate: item.startDate || '', endDate: item.endDate || '',
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
}))
}
} catch { console.error('[AgentSettingPanel] 加载实习经历失败') }
}
/** 加载项目经历 */
async function loadProject() {
try {
const res = await fetchProject()
if (res.code === '0' && res.data) {
profile.value.projects = res.data.map(item => ({
projectName: item.projectName || '', companyName: item.companyName || '',
role: item.role || '', startDate: item.startDate || '', endDate: item.endDate || '',
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
}))
}
} catch { console.error('[AgentSettingPanel] 加载项目经历失败') }
}
/** 加载竞赛经历 */
async function loadCompetition() {
try {
const res = await fetchCompetition()
if (res.code === '0' && res.data) {
profile.value.competitions = res.data.map(item => ({
competitionName: item.competitionName || '', award: item.award || '',
awardDate: item.awardDate || '',
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
}))
}
} catch { console.error('[AgentSettingPanel] 加载竞赛经历失败') }
} catch { /* 静默 */ }
}
// ==================== 编辑抽屉 ====================
/** 编辑抽屉显隐 */
const showEditDrawer = ref(false)
/** 当前编辑的模块名 */
const editModule = ref('info')
/** 编辑抽屉初始数据 */
const editInitialData = ref<Record<string, any>>({})
/** 保存中状态 */
const saving = ref(false)
/** ProfilePageContent 点击编辑 — 打开对应模块的编辑抽屉 */
function handleEdit(section: string) {
editModule.value = section
if (section === 'info') {
editInitialData.value = { name: profile.value.name, email: profile.value.email, phone: profile.value.phone, location: profile.value.regionCode, wechat: profile.value.wechat }
} else if (section === 'education') {
editInitialData.value = { education: profile.value.education.map(edu => ({ ...edu, description: edu.description.map(d => ({ ...d })) })) }
} else if (section === 'work') {
editInitialData.value = { works: profile.value.works.map(exp => ({ ...exp, description: exp.description.map(d => ({ ...d })) })) }
} else if (section === 'internship') {
editInitialData.value = { internships: (profile.value.internships || []).map(exp => ({ ...exp, description: exp.description.map(d => ({ ...d })) })) }
} else if (section === 'project') {
editInitialData.value = { projects: (profile.value.projects || []).map(proj => ({ ...proj, description: proj.description.map(d => ({ ...d })) })) }
} else if (section === 'competition') {
editInitialData.value = { competitions: profile.value.competitions.map(comp => ({ ...comp, description: comp.description.map(d => ({ ...d })) })) }
} else if (section === 'portfolio') {
editInitialData.value = { portfolioUrl: profile.value.portfolioUrl }
} else if (section === 'skills') {
editInitialData.value = { skills: [...profile.value.skills] }
} else if (section === 'certificate') {
editInitialData.value = { certificates: [...(profile.value.certificates || [])] }
} else {
editInitialData.value = {}
/** 打开编辑个人资料 */
function handleEditProfile() {
editModule.value = 'info'
editInitialData.value = {
name: profile.value.name,
email: profile.value.email,
phone: profile.value.phone,
location: profile.value.regionCode,
wechat: profile.value.wechat,
}
showEditDrawer.value = true
}
/** 保存编辑数据 — 调用接口持久化 */
/** 保存编辑 */
async function handleSaveEdit(data: Record<string, any>) {
saving.value = true
try {
if (editModule.value === 'info') {
await saveProfile({ name: data.name, email: data.email, mobileNumber: data.phone, regionCode: data.location, wechatNumber: data.wechat })
profile.value.name = data.name; profile.value.email = data.email
profile.value.phone = data.phone; profile.value.wechat = data.wechat
profile.value.regionCode = data.location || ''
ElMessage.success('个人信息保存成功')
} else if (editModule.value === 'education') {
const payload: SaveEducationItem[] = data.education.map((edu: any) => ({ school: edu.school, major: edu.major, degree: edu.degree, studyType: edu.studyType, startDate: edu.startDate, endDate: edu.endDate, description: edu.description.map((d: any) => ({ id: d.id, text: d.text })) }))
await saveEducation(payload)
profile.value.education = data.education.map((edu: any) => ({ school: edu.school, major: edu.major, studyType: edu.studyType, degree: edu.degree, startDate: edu.startDate, endDate: edu.endDate, description: edu.description.map((d: any) => ({ ...d })) }))
ElMessage.success('教育经历保存成功')
} else if (editModule.value === 'work') {
const payload: SaveWorkItem[] = data.works.map((w: any) => ({ companyName: w.companyName, position: w.position, startDate: w.startDate, endDate: w.endDate || '', description: w.description.map((d: any) => ({ id: d.id, text: d.text })) }))
await saveWork(payload)
profile.value.works = data.works.map((w: any) => ({ companyName: w.companyName, position: w.position, startDate: w.startDate, endDate: w.endDate, description: w.description.map((d: any) => ({ ...d })) }))
ElMessage.success('工作经历保存成功')
} else if (editModule.value === 'internship') {
const payload: SaveWorkItem[] = data.internships.map((i: any) => ({ companyName: i.companyName, position: i.position, startDate: i.startDate, endDate: i.endDate || '', description: i.description.map((d: any) => ({ id: d.id, text: d.text })) }))
await saveInternship(payload)
profile.value.internships = data.internships.map((i: any) => ({ companyName: i.companyName, position: i.position, startDate: i.startDate, endDate: i.endDate, description: i.description.map((d: any) => ({ ...d })) }))
ElMessage.success('实习经历保存成功')
} else if (editModule.value === 'project') {
const payload: SaveProjectItem[] = data.projects.map((p: any) => ({ projectName: p.projectName, companyName: p.companyName || '', role: p.role || '', startDate: p.startDate, endDate: p.endDate || '', description: p.description.map((d: any) => ({ id: d.id, text: d.text })) }))
await saveProject(payload)
profile.value.projects = data.projects.map((p: any) => ({ projectName: p.projectName, companyName: p.companyName, role: p.role, startDate: p.startDate, endDate: p.endDate, description: p.description.map((d: any) => ({ ...d })) }))
ElMessage.success('项目经历保存成功')
} else if (editModule.value === 'competition') {
const payload: SaveCompetitionItem[] = data.competitions.map((c: any) => ({ competitionName: c.competitionName, award: c.award || '', awardDate: c.awardDate || '', description: c.description.map((d: any) => ({ id: d.id, text: d.text })) }))
await saveCompetition(payload)
profile.value.competitions = data.competitions.map((c: any) => ({ competitionName: c.competitionName, award: c.award, awardDate: c.awardDate, description: c.description.map((d: any) => ({ ...d })) }))
ElMessage.success('竞赛经历保存成功')
} else if (editModule.value === 'portfolio') {
await saveProfile({ name: profile.value.name, email: profile.value.email, mobileNumber: profile.value.phone, regionCode: profile.value.regionCode, wechatNumber: profile.value.wechat, skills: profile.value.skills, certificates: profile.value.certificates, portfolioUrl: data.portfolioUrl })
profile.value.portfolioUrl = data.portfolioUrl
ElMessage.success('作品集保存成功')
} else if (editModule.value === 'skills') {
await saveProfile({ name: profile.value.name, email: profile.value.email, mobileNumber: profile.value.phone, regionCode: profile.value.regionCode, wechatNumber: profile.value.wechat, skills: [...data.skills], certificates: profile.value.certificates })
profile.value.skills = [...data.skills]
ElMessage.success('技能保存成功')
} else if (editModule.value === 'certificate') {
await saveProfile({ name: profile.value.name, email: profile.value.email, mobileNumber: profile.value.phone, regionCode: profile.value.regionCode, wechatNumber: profile.value.wechat, skills: profile.value.skills, certificates: [...data.certificates] })
profile.value.certificates = [...data.certificates]
ElMessage.success('证书保存成功')
}
await saveProfile({
name: data.name,
email: data.email,
mobileNumber: data.phone,
regionCode: data.location,
wechatNumber: data.wechat,
})
profile.value.name = data.name
profile.value.email = data.email
profile.value.phone = data.phone
profile.value.wechat = data.wechat
profile.value.regionCode = data.location || ''
ElMessage.success('个人信息保存成功')
} catch {
ElMessage.error('保存失败,请重试')
} finally {
saving.value = false
}
}
// ==================== 求职目标 ====================
const showJobGoalDialog = ref(false)
/** 求职意向标签(汇总岗位+行业+地区+招聘类型) */
const intentionTags = computed(() => {
const tags: string[] = []
const catIds = store.state.jobIntention?.categoryIds || []
const indIds = store.state.jobIntention?.industryIds || []
const regCodes = store.state.jobIntention?.regionCodes || []
catIds.forEach((id: number) => { const n = resolveJobCategoryName(id); if (n) tags.push(n) })
indIds.forEach((id: number) => { const n = resolveIndustryName(id); if (n) tags.push(n) })
regCodes.forEach((code: string) => { const n = resolveRegionName(code); if (n) tags.push(n) })
const empLabel = formatEmploymentType(store.state.jobIntention?.recruitCategory)
if (empLabel) tags.push(empLabel)
return tags
})
// ==================== 投递简历 ====================
const resumeList = ref<ResumeListItem[]>([])
const selectedResumeId = ref('')
/** 加载简历列表 */
async function loadResumeList() {
try {
const res = await fetchResumeList()
if (res.code === '0' && res.data) {
resumeList.value = res.data
const defaultResume = res.data.find(r => r.isDefault === 1)
selectedResumeId.value = defaultResume?.id || (res.data[0]?.id || '')
}
} catch { /* 静默 */ }
}
/** 切换简历时设为默认 */
async function handleResumeChange(newId: string) {
if (!newId) return
try {
await setDefaultResume(newId)
} catch { /* 静默 */ }
}
// ==================== 投递偏好 ====================
const autoOptimizeSwitch = ref(true)
const autoMatchAnalysisSwitch = ref(true)
/** 加载配置 */
async function loadAgentConfigData() {
try {
const res = await fetchAgentConfig()
if (res.code === '0' && res.data) {
if (res.data.autoOptimizeResume !== undefined) autoOptimizeSwitch.value = res.data.autoOptimizeResume === 1
if (res.data.autoMatchAnalysis !== undefined) autoMatchAnalysisSwitch.value = res.data.autoMatchAnalysis === 1
}
} catch { /* 静默 */ }
}
/** 保存偏好设置 */
async function handleSavePreference() {
try {
await saveAgentConfig({
autoOptimizeResume: autoOptimizeSwitch.value ? 1 : 0,
autoMatchAnalysis: autoMatchAnalysisSwitch.value ? 1 : 0,
})
} catch { /* 静默 */ }
}
// ==================== 浏览器插件 ====================
/** 打开浏览器安装指引(简单跳转) */
function openBrowserGuide(browser: string) {
if (browser === 'chrome') {
ElMessage.info('请复制地址 chrome://extensions 到浏览器打开')
} else {
ElMessage.info('请复制地址 edge://extensions/ 到浏览器打开')
}
}
// ==================== 初始化 ====================
onMounted(async () => {
if (!store.state.regions.length) store.dispatch('loadCommonData')
store.dispatch('loadJobIntention')
await loadProfile()
await loadEducation()
await loadResumeList()
await loadAgentConfigData()
})
</script>
<style scoped lang="scss">
+309 -252
View File
@@ -31,183 +31,226 @@
<div class="agent-page__main">
<!-- ========== 第1步确认个人资料 ========== -->
<template v-if="currentStep === 1">
<div class="agent-page__left">
<div class="agent-page__intro-card">
<div class="agent-page__intro-header">
<div class="agent-page__intro-icon">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg>
</div>
<h2 class="agent-page__intro-title">在开始之前请先确认你的个人资料是否无误</h2>
</div>
<p class="agent-page__intro-desc">请仔细对你的资料评估包括个人信息教育背景工作履历及技能确保资料齐全让我能顺利为你开启求职之旅</p>
<div class="agent-page__import-row">
<div class="agent-page__import-icon">
<svg viewBox="0 0 24 24" fill="none"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<span class="agent-page__import-text">{{ profile.name }}的个人资料</span>
</div>
<div class="dflex-center aliite-c">
<button class="agent-page__confirm-btn" @click="handleNext">确认并进入</button>
</div>
</div>
</div>
<div class="agent-page__right">
<div class="agent-page__profile-wrapper">
<div class="agent-page__profile-title">个人档案</div>
<ProfilePageContent :profile="profile" @edit="handleEdit" />
</div>
</div>
<div class="agent-page__left">
<div class="agent-page__intro-card">
<div class="agent-page__intro-header por">
<div v-if="false" class="agent-page__intro-icon poa l-22 t0">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg>
</div>
<h2 class="agent-page__intro-title">请先确认你的个人资料是否无误</h2>
</div>
<p class="agent-page__intro-desc mb50 ">请仔细对你的资料评估包括个人信息教育背景工作履历及技能确保资料齐全让我能顺利为你开启求职之旅</p>
<div class="">
<div class="tac"><button class="agent-page__confirm-btn dib" @click="handleNext">确认并进入</button></div>
<div v-if="false" class="tac color-9 mt30 cursor-po" @click="handleBack">返回上一步</div>
</div>
</div>
</div>
</template>
<!-- ========== 第2步确认目标 ========== -->
<template v-if="currentStep === 2">
<div class="agent-page__step2">
<!-- 返回上一步按钮 -->
<button class="agent-page__back-btn" @click="handleBack">
<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>返回上一步</span>
</button>
<div class="agent-page__chat">
<div class="agent-page__chat-row">
<div class="agent-page__chat-avatar"><svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg></div>
<span class="agent-page__chat-text">接下来我想确认一下我为你搜寻的职位方向是否准确</span>
</div>
<div class="p20 bg-f border-ra10">
<div class="agent-page__pref-card">
<p class="agent-page__pref-desc">这些是你之前设定的求职偏好如有需要请随时修改</p>
<div class="agent-page__pref-tags">
<span v-for="name in intentionCategoryNames" :key="'cat-' + name" class="agent-page__pref-tag">{{ name }}</span>
<span v-for="name in intentionIndustryNames" :key="'ind-' + name" class="agent-page__pref-tag">{{ name }}</span>
<span v-for="name in intentionRegionNames" :key="'reg-' + name" class="agent-page__pref-tag">{{ name }}</span>
<span class="agent-page__pref-tag">{{ intentionEmploymentLabel }}</span>
</div>
<div class="agent-page__pref-actions">
<button class="agent-page__pref-btn agent-page__pref-btn--edit" @click="showJobGoalDialog = true">编辑</button>
<button class="agent-page__pref-btn agent-page__pref-btn--confirm" @click="handleNext">确认并继续</button>
<!-- 左侧求职偏好 + 简历设置 -->
<div class="agent-page__right">
<div class="agent-page__profile-wrapper">
<!-- 求职偏好区域 -->
<div class="agent-page__pref-section">
<h3 class="agent-page__pref-section-title">求职偏好</h3>
<p class="agent-page__pref-section-desc">这些是你之前设定的求职偏好如有需要请随时修改</p>
<div class="agent-page__pref-filters">
<!-- 城市选择 -->
<RegionSelector
:regionCodes="step2RegionCodes"
:level="2"
:maxSelect="3"
:triggerStyle="prefFilterTriggerStyle"
:displayStyle="prefFilterDisplayStyle"
@update:regionCodes="onStep2RegionChange"
/>
<!-- 岗位选择 -->
<JobCategorySelector
:categoryIds="step2CategoryIds"
:maxSelect="3"
:level="3"
:allowParentSelect="true"
:triggerStyle="prefFilterTriggerStyle"
:displayStyle="prefFilterDisplayStyle"
@update:categoryIds="onStep2CategoryChange"
/>
<!-- 行业选择 -->
<IndustrySelector
:industryIds="step2IndustryIds"
:maxSelect="3"
:level="2"
:allowParentSelect="true"
:triggerStyle="prefFilterTriggerStyle"
:displayStyle="prefFilterDisplayStyle"
@update:industryIds="onStep2IndustryChange"
/>
<!-- 招聘分类下拉选择 -->
<div class="agent-page__pref-type-dropdown" @click.stop="showJobTypeDropdown = !showJobTypeDropdown">
<span>{{ intentionEmploymentLabel || '类型' }}</span>
<svg class="agent-page__pref-type-arrow" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- 下拉菜单 -->
<div v-if="showJobTypeDropdown" class="agent-page__pref-type-menu" @click.stop>
<div
v-for="option in jobTypeOptions"
:key="option.value"
class="agent-page__pref-type-menu-item"
:class="{ 'agent-page__pref-type-menu-item--active': store.state.jobIntention.recruitCategory === option.value }"
@click.stop="selectJobType(option)"
>{{ option.label }}</div>
</div>
</div>
</div>
</div>
<div v-if="loadingMatchJobs" class="agent-page__chat-row">
<div class="agent-page__chat-avatar"><svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg></div>
<div class="agent-page__chat-typing"><span></span><span></span><span></span></div>
</div>
<div v-if="matchedJobs.length > 0" class="p20 bg-f border-ra10">
<div class="agent-page__match-card">
<p class="agent-page__match-desc">这些是为你匹配的岗位请选择你想投递的岗位如对某个岗位不满意请告诉我</p>
<div v-for="(job, i) in matchedJobs" :key="i" class="agent-page__match-item">
<div class="agent-page__match-info">
<div class="agent-page__match-icon"><svg viewBox="0 0 24 24" fill="none"><path d="M3 21h18M3 7v14M9 3v4M15 3v4M9 3h6M5 7h14a2 2 0 012 2v10H3V9a2 2 0 012-2z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="agent-page__match-detail">
<div class="agent-page__match-company">{{ job.companyShortName || job.companyName }}</div>
<div class="agent-page__match-position">{{ job.title }}</div>
<div class="agent-page__match-tags"><span v-for="(t, j) in job.tags" :key="j" class="agent-page__match-tag">{{ t }}</span></div>
</div>
</div>
<div class="agent-page__match-score">
<svg viewBox="0 0 40 40" class="agent-page__match-ring"><circle cx="20" cy="20" r="16" fill="none" stroke="#E8E8E8" stroke-width="3"/><circle cx="20" cy="20" r="16" fill="none" stroke="#000" stroke-width="3" :stroke-dasharray="100.53" :stroke-dashoffset="100.53 * (1 - job.matchScore / 100)" stroke-linecap="round" transform="rotate(-90 20 20)"/></svg>
<span class="agent-page__match-score-text">{{ job.matchScore }}%</span>
</div>
<div class="agent-page__match-feedback">
<button class="agent-page__match-fb-btn" :class="{ 'agent-page__match-fb-btn--active': job.feedback === 'like' }" @click="job.feedback = job.feedback === 'like' ? '' : 'like'"><svg viewBox="0 0 24 24" fill="none"><path d="M7 22V11l5-9 1.5 1 -1 5h6.5a2 2 0 012 2.2l-1.5 8A2 2 0 0117.6 20H7z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M4 11H2v11h2a1 1 0 001-1V12a1 1 0 00-1-1z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg></button>
<button class="agent-page__match-fb-btn" :class="{ 'agent-page__match-fb-btn--active': job.feedback === 'dislike' }" @click="handleDislike(i)"><svg viewBox="0 0 24 24" fill="none"><path d="M17 2v11l-5 9-1.5-1 1-5H5a2 2 0 01-2-2.2l1.5-8A2 2 0 016.4 4H17z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M20 13h2V2h-2a1 1 0 00-1 1v9a1 1 0 001 1z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg></button>
</div>
</div>
<div v-if="showDislikeInput" class="agent-page__dislike-input"><input v-model="dislikeReason" type="text" placeholder="你为什么对这个岗位不满意?" /></div>
<!-- 简历设置区域 -->
<div class="agent-page__resume-section">
<h3 class="agent-page__pref-section-title">简历设置</h3>
<div class="agent-page__resume-sub-label">设置默认简历</div>
<div class="agent-page__resume-select-row">
<span class="agent-page__resume-check-icon">
<svg viewBox="0 0 16 16" fill="none"><rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="1.2"/></svg>
</span>
<el-select v-model="selectedResumeId" placeholder="请选择简历" class="agent-page__resume-select">
<el-option v-for="r in resumeList" :key="r.id" :label="r.resumeName" :value="r.id || ''" />
</el-select>
</div>
<!-- 自动优化简历开关 -->
<div class="agent-page__resume-switch-row">
<span class="agent-page__resume-switch-text">在投递时帮我针对岗位自动优化简历</span>
<el-switch v-model="autoOptimizeSwitch" :active-color="'#4FC2C9'" />
</div>
<!-- 岗位匹配分析开关 -->
<div class="agent-page__resume-switch-row">
<span class="agent-page__resume-switch-text">在投递时帮我进行岗位匹配分析</span>
<el-switch v-model="autoMatchAnalysisSwitch" :active-color="'#4FC2C9'" />
</div>
</div>
<div v-if="matchedJobs.length > 0" class="agent-page__step2-footer"><button class="agent-page__optimize-btn" @click="loadMatchedJobs">优化我的岗位匹配</button></div>
</div>
</div>
<!-- 右侧确认卡片 -->
<div class="agent-page__left">
<div class="agent-page__intro-card">
<div class="agent-page__intro-header por">
<div class="agent-page__intro-icon poa l-22 t0">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg>
</div>
<h2 class="agent-page__intro-title">接下来我想确认一下我为你搜寻的职位方向是否准确</h2>
</div>
<p class="agent-page__intro-desc mb50">请仔细对你的资料评估包括个人信息教育背景工作履历及技能确保资料齐全让我能顺利为你开启求职之旅</p>
<div class="">
<div class="tac"><button class="agent-page__confirm-btn dib" @click="handleNext">确认并进入</button></div>
<div class="tac color-9 mt30 cursor-po" @click="handleBack">返回上一步</div>
</div>
</div>
</div>
</template>
<!-- ========== 第3步网申常见问题 + 插件安装 ========== -->
<!-- ========== 第3步安装插件 ========== -->
<template v-if="currentStep === 3">
<!-- 上半部分网申常见问题 -->
<div v-if="step3Sub === 1" class="agent-page__step3">
<!-- 返回上一步按钮 -->
<button class="agent-page__back-btn" @click="handleBack">
<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>返回上一步</span>
</button>
<div class="agent-page__left">
<div class="agent-page__intro-card">
<div class="agent-page__intro-header"><div class="agent-page__intro-icon"><svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg></div><h2 class="agent-page__intro-title">现在我们来开启自动投递吧</h2></div>
<p class="agent-page__intro-desc">以下是校招网申常见填写信息<br/>现在填写一次我即可在自动申请时帮你填写信息</p>
<div class="dflex-center aliite-c"><button class="agent-page__confirm-btn" @click="step3Sub = 2">确认并继续</button></div>
</div>
</div>
<div class="agent-page__right">
<div class="agent-page__form-wrapper">
<div class="agent-page__form-title">网申常见问题</div>
<div class="agent-page__form-group"><div class="agent-page__form-label">是否愿意接受部门调剂</div><div class="agent-page__form-options"><button class="agent-page__form-option" :class="{ 'agent-page__form-option--active': step3Form.acceptDeptTransfer === '是,服从调剂' }" @click="step3Form.acceptDeptTransfer = '是,服从调剂'">服从调剂</button><button class="agent-page__form-option" :class="{ 'agent-page__form-option--active': step3Form.acceptDeptTransfer === '否,不调剂' }" @click="step3Form.acceptDeptTransfer = '否,不调剂'">不调剂</button></div></div>
<div class="agent-page__form-group"><div class="agent-page__form-label">是否接受地点调剂</div><div class="agent-page__form-options"><button class="agent-page__form-option" :class="{ 'agent-page__form-option--active': step3Form.acceptLocationTransfer === '是' }" @click="step3Form.acceptLocationTransfer = '是'"></button><button class="agent-page__form-option" :class="{ 'agent-page__form-option--active': step3Form.acceptLocationTransfer === '否' }" @click="step3Form.acceptLocationTransfer = '否'"></button></div></div>
<div class="agent-page__form-group"><div class="agent-page__form-label">可以参加面试的方式</div><div class="agent-page__form-options"><button class="agent-page__form-option" :class="{ 'agent-page__form-option--active': step3Form.interviewType.includes('线下面试') }" @click="toggleInterviewType('线下面试')">线下面试</button><button class="agent-page__form-option" :class="{ 'agent-page__form-option--active': step3Form.interviewType.includes('线上远程') }" @click="toggleInterviewType('线上远程')">线上远程</button></div></div>
<div class="agent-page__form-group"><div class="agent-page__form-label">你的语言能力</div><div class="agent-page__form-selects"><el-select v-model="step3Form.languages[0].language" placeholder="语种" class="agent-page__form-select"><el-option v-for="lang in languageOptions" :key="lang" :label="lang" :value="lang" /></el-select><el-select v-model="step3Form.languages[0].proficiency" placeholder="掌握程度" class="agent-page__form-select"><el-option v-for="p in proficiencyOptions" :key="p" :label="p" :value="p" /></el-select></div></div>
<div class="agent-page__form-group"><div class="agent-page__form-label">预计到岗时间</div><div class="agent-page__form-selects"><el-select v-model="step3Form.availableDate" placeholder="请选择" class="agent-page__form-select"><el-option v-for="d in availableDateOptions" :key="d" :label="d" :value="d" /></el-select></div></div>
<template v-if="isInternship">
<div class="agent-page__form-group"><div class="agent-page__form-label">每周可实习天数</div><div class="agent-page__form-selects"><el-select v-model="step3Form.internDaysPerWeek" placeholder="请选择" class="agent-page__form-select"><el-option v-for="d in internDaysOptions" :key="d" :label="d" :value="d" /></el-select></div></div>
<div class="agent-page__form-group"><div class="agent-page__form-label">预计实习时长</div><div class="agent-page__form-selects"><el-select v-model="step3Form.internDuration" placeholder="请选择" class="agent-page__form-select"><el-option v-for="d in internDurationOptions" :key="d" :label="d" :value="d" /></el-select></div></div>
</template>
</div>
</div>
</div>
<!-- 下半部分插件安装 -->
<div v-if="step3Sub === 2" class="agent-page__step3">
<!-- 返回上一阶段按钮 -->
<button class="agent-page__back-btn" @click="handleBack">
<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>返回上一步</span>
</button>
<div class="agent-page__left">
<div class="agent-page__intro-card">
<div class="agent-page__intro-header"><div class="agent-page__intro-icon"><svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg></div><h2 class="agent-page__intro-title">现在我们来开启自动投递吧</h2></div>
<p class="agent-page__intro-desc">简单三步安装 Chrome 扩展程序<br/>实现自动填表并一站式追踪所有申请进度</p>
<div class="agent-page__install-steps"><p>1. 点击 [ 安装扩展程序 ]前往 Chrome 应用商店</p><p>2. 点击 "添加至 Chrome" 并完成安装</p><p>3. 刷新本页然后点击下方按钮完成同步</p></div>
<div class="dflex-center aliite-c"><button class="agent-page__confirm-btn" @click="handleNext">我已安装</button></div>
</div>
</div>
<div class="agent-page__right">
<div class="agent-page__form-wrapper">
<div class="agent-page__form-title">自动填写插件</div>
<div class="agent-page__plugin-section">
<h3 class="agent-page__plugin-title">自动填写插件</h3>
<div class="agent-page__browser-btns"><button v-for="b in browserList" :key="b.key" class="agent-page__browser-btn" @click="openBrowserGuide(b.key)">{{ b.label }}</button></div>
<div class="dflex-center aliite-c" style="margin-top: 0.24rem;"><button class="agent-page__confirm-btn" @click="downloadExtension">下载插件</button></div>
<!-- 左侧安装教程内容 -->
<div class="agent-page__right">
<div class="agent-page__profile-wrapper">
<div class="agent-page__install-guide">
<h3 class="agent-page__install-guide-title">安装自动填写插件</h3>
<!-- 步骤1下载插件包 -->
<div class="agent-page__install-step">
<p class="agent-page__install-step-text">1下载下方的插件包</p>
<div class="agent-page__install-download">
<button class="agent-page__install-download-btn" @click="downloadExtension">
<span>Offer派自动投递助手.crx</span>
<svg viewBox="0 0 16 16" fill="none" width="14" height="14"><path d="M8 2v8M8 10L5 7M8 10l3-3M3 13h10" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
<!-- 步骤2复制地址进入扩展程序页面 -->
<div class="agent-page__install-step">
<p class="agent-page__install-step-text">2复制右下方的蓝色地址在浏览器地址栏粘贴并进入浏览器的扩展程序页面</p>
<div class="agent-page__install-step-content">
<div class="agent-page__install-step-img">
<img src="@/assets/images/agent-setting/2-1.png" alt="地址栏示例" />
</div>
<div class="agent-page__install-browser-links">
<div class="agent-page__install-browser-item">
<img src="@/assets/images/agent-setting/2-2.png" style="width: 3rem;height: 0.3rem;" alt="Chrome" class="agent-page__install-browser-icon" />
</div>
<a class="agent-page__install-browser-url" href="javascript:void(0)" @click="copyToClipboard('chrome://extensions')">
chrome://extensions
<svg viewBox="0 0 14 14" fill="none" width="12" height="12"><rect x="4" y="4" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1"/><path d="M10 4V3a1 1 0 00-1-1H3a1 1 0 00-1 1v6a1 1 0 001 1h1" stroke="currentColor" stroke-width="1"/></svg>
</a>
<div class="agent-page__install-browser-item mt16">
<img src="@/assets/images/agent-setting/2-3.png" style="width: 3rem;height: 0.3rem;" alt="Edge" class="agent-page__install-browser-icon" />
</div>
<a class="agent-page__install-browser-url" href="javascript:void(0)" @click="copyToClipboard('edge://extensions/')">
edge://extensions/
<svg viewBox="0 0 14 14" fill="none" width="12" height="12"><rect x="4" y="4" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1"/><path d="M10 4V3a1 1 0 00-1-1H3a1 1 0 00-1 1v6a1 1 0 001 1h1" stroke="currentColor" stroke-width="1"/></svg>
</a>
</div>
</div>
</div>
<!-- 步骤3打开开发人员模式 -->
<div class="agent-page__install-step">
<p class="agent-page__install-step-text">3在浏览器拓展页面中打开开发人员模式</p>
<div class="agent-page__install-step-img-row">
<div class="agent-page__install-step-img-item">
<img src="@/assets/images/agent-setting/3-1.png" alt="打开开发人员模式" />
</div>
</div>
</div>
<!-- 步骤4拖入插件包 -->
<div class="agent-page__install-step">
<p class="agent-page__install-step-text">4将下载好的离线插件包使用鼠标拖入到浏览器的扩展程序页面</p>
<div class="agent-page__install-step-img-row">
<div class="agent-page__install-step-img-item">
<img src="@/assets/images/agent-setting/4-1.png" alt="拖入插件包" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 浏览器安装指引弹窗 -->
<el-dialog v-model="showBrowserGuide" :title="currentBrowserLabel + ' 安装指引'" width="80%" top="5vh" destroy-on-close>
<el-carousel :autoplay="false" indicator-position="outside" height="70vh" arrow="always">
<el-carousel-item v-for="(img, i) in currentBrowserImages" :key="i"><div class="agent-page__guide-slide"><img :src="img" :alt="currentBrowserLabel + ' 安装步骤 ' + (i + 1)" /></div></el-carousel-item>
</el-carousel>
</el-dialog>
</template>
<!-- ========== 第4步配置求职助手 ========== -->
<template v-if="currentStep === 4">
<div v-if="!setupComplete" class="agent-page__step3">
<!-- 返回上一步按钮 -->
<button class="agent-page__back-btn" @click="handleBack">
<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>返回上一步</span>
</button>
<div class="agent-page__left">
<div class="agent-page__intro-card">
<div class="agent-page__intro-header"><div class="agent-page__intro-icon"><svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg></div><h2 class="agent-page__intro-title">马上就好啦请选择你想要的投递模式</h2></div>
<p class="agent-page__intro-desc">我已为你准备了关于自动化程度简历生成及求职目标的灵活选项请完成配置并确认</p>
<div class="dflex-center aliite-c"><button class="agent-page__confirm-btn" @click="handleStep4Complete">设置完成</button></div>
<!-- 右侧确认卡片 -->
<div class="agent-page__left">
<div class="agent-page__intro-card">
<div class="agent-page__intro-header por">
<div class="agent-page__intro-icon poa l-22 t0">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg>
</div>
<h2 class="agent-page__intro-title">现在我们来开启自动投递吧</h2>
</div>
</div>
<div class="agent-page__right">
<div class="agent-page__form-wrapper">
<div class="agent-page__form-title">Agent设置</div>
<AgentSettingsPanel ref="settingsPanelRef" :initial-config="initialConfig" />
<p class="agent-page__intro-desc mb20">简单四步安装 Chrome 扩展程序<br/>实现自动填表并一站式追踪所有申请进度</p>
<div class="agent-page__install-steps-list">
<p>1. 下载offer派自动投递助手插件包</p>
<p>2. 进入浏览器扩展程序界面</p>
<p>3. 打开扩展程序开发人员模式</p>
<p>4.拖入安装包完成安装</p>
</div>
<div class="mt30">
<div class="tac"><button class="agent-page__confirm-btn dib" @click="handleNext">我已安装</button></div>
<div class="tac color-9 mt30 cursor-po" @click="handleBack">返回上一步</div>
</div>
</div>
</div>
<div v-else class="agent-page__complete">
</template>
<!-- ========== 第4步完成配置 ========== -->
<template v-if="currentStep === 4">
<div class="agent-page__complete">
<div class="agent-page__complete-icon"><svg viewBox="0 0 80 80" fill="none"><rect x="8" y="8" width="64" height="64" rx="8" fill="#E8E8E8"/><circle cx="40" cy="32" r="8" fill="#BFBFBF"/><path d="M20 60 L32 44 L44 52 L56 36 L64 44 L64 64 L20 64Z" fill="#BFBFBF"/></svg></div>
<h2 class="agent-page__complete-title">恭喜你你的求职助手已经准备好</h2>
<p class="agent-page__complete-desc">启用求职助手后你可以随时调整求职偏好及简历信息</p>
@@ -222,20 +265,19 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useStore } from 'vuex'
import { formatEmploymentType } from '@/stores/index'
import { formatEmploymentType, JOB_TYPE_OPTIONS } from '@/stores/index'
import ProfilePageContent from '@/components/ProfilePageContent.vue'
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
import JobGoalDialog from '@/components/JobGoalDialog.vue'
import AgentSettingsPanel from '@/components/AgentSettingsPanel.vue'
import RegionSelector from '@/components/tools/RegionSelector.vue'
import JobCategorySelector from '@/components/tools/JobCategorySelector.vue'
import IndustrySelector from '@/components/tools/IndustrySelector.vue'
import { saveProfile, saveEducation, saveWork, saveInternship, saveProject, saveCompetition, fetchProfile, fetchEducation, fetchWork, fetchInternship, fetchProject, fetchCompetition } from '@/api/profile'
import type { SaveEducationItem, SaveWorkItem, SaveProjectItem, SaveCompetitionItem } from '@/api/profile'
import { fetchJobList } from '@/api/jobs'
import type { JobListItem } from '@/api/jobs'
import { resolveRegionName } from '@/utils/region'
import { resolveIndustryName } from '@/utils/industry'
import { resolveJobCategoryName } from '@/utils/jobCategory'
import { fetchResumeList, setDefaultResume } from '@/api/resume'
import type { ResumeListItem } from '@/api/resume'
import type { AgentConfig } from '@/api/agent'
const store = useStore()
@@ -253,7 +295,7 @@ const emit = defineEmits<{
}>()
// ==================== 步骤导航 ====================
const steps = ['确认个人资料', '确认目标', '开启自动申请', '配置求职助手']
const steps = ['确认个人资料', '确认目标', '安装插件', '配置求职助手']
const currentStep = ref(1)
/** 进入下一步 */
@@ -261,16 +303,9 @@ function handleNext() {
if (currentStep.value < steps.length) currentStep.value++
}
/** 返回上一步(含子阶段判断) */
/** 返回上一步 */
function handleBack() {
if (currentStep.value === 3 && step3Sub.value === 2) {
// 第3步的第2阶段 → 回到第3步第1阶段
step3Sub.value = 1
} else if (currentStep.value === 4) {
// 第4步 → 回到第3步第2阶段(插件安装)
currentStep.value = 3
step3Sub.value = 2
} else if (currentStep.value > 1) {
if (currentStep.value > 1) {
currentStep.value--
}
}
@@ -400,106 +435,128 @@ async function handleSaveEdit(data: Record<string, any>) {
// ==================== 第2步:确认目标 ====================
const showJobGoalDialog = ref(false)
const intentionCategoryNames = computed(() => (store.state.jobIntention.categoryIds || []).map((id: number) => resolveJobCategoryName(id)))
const intentionIndustryNames = computed(() => (store.state.jobIntention.industryIds || []).map((id: number) => resolveIndustryName(id)))
const intentionRegionNames = computed(() => (store.state.jobIntention.regionCodes || []).map((code: string) => resolveRegionName(code)))
const intentionEmploymentLabel = computed(() => formatEmploymentType(store.state.jobIntention.recruitCategory))
interface MatchedJobItem extends JobListItem { feedback: string }
const matchedJobs = ref<MatchedJobItem[]>([])
const loadingMatchJobs = ref(false)
const showDislikeInput = ref(false)
const dislikeReason = ref('')
/** 招聘分类选项 */
const jobTypeOptions = JOB_TYPE_OPTIONS
/** 招聘分类下拉是否展开 */
const showJobTypeDropdown = ref(false)
watch(showJobGoalDialog, (n, o) => { if (o === true && n === false) loadMatchedJobs() })
watch(currentStep, (val) => { if (val === 2) loadMatchedJobs() })
/** 选中招聘分类 */
function selectJobType(option: { label: string; value: number }) {
showJobTypeDropdown.value = false
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
recruitCategory: option.value,
})
}
async function loadMatchedJobs() {
loadingMatchJobs.value = true; matchedJobs.value = []
/** 第2步:求职偏好筛选组件 — 读写 store.jobIntention */
const step2RegionCodes = computed<string[]>(() => store.state.jobIntention.regionCodes || [])
const step2CategoryIds = computed<number[]>(() => store.state.jobIntention.categoryIds || [])
const step2IndustryIds = computed<number[]>(() => store.state.jobIntention.industryIds || [])
/** 第2步:筛选组件统一触发按钮样式 — 大圆角胶囊按钮 */
const prefFilterTriggerStyle = {
padding: '0.1rem 0.28rem',
borderRadius: '0.24rem',
fontSize: '0.14rem',
background: '#F5FAF9',
border: '1px solid #e0e0e0',
minWidth: '0.9rem',
justifyContent: 'center',
height: '0.38rem',
boxSizing: 'border-box',
}
/** 第2步:筛选组件显示文字样式 */
const prefFilterDisplayStyle = {
maxWidth: 'none',
}
/** 第2步:地区选择变更 */
function onStep2RegionChange(codes: string[]) {
store.dispatch('saveJobIntention', { ...store.state.jobIntention, regionCodes: codes })
}
/** 第2步:岗位选择变更 */
function onStep2CategoryChange(ids: number[]) {
store.dispatch('saveJobIntention', { ...store.state.jobIntention, categoryIds: ids })
}
/** 第2步:行业选择变更 */
function onStep2IndustryChange(ids: number[]) {
store.dispatch('saveJobIntention', { ...store.state.jobIntention, industryIds: ids })
}
/** 第2步:简历列表 */
const resumeList = ref<ResumeListItem[]>([])
/** 选中的简历 ID */
const selectedResumeId = ref('')
/** 自动优化简历开关 */
const autoOptimizeSwitch = ref(true)
/** 岗位匹配分析开关 */
const autoMatchAnalysisSwitch = ref(true)
/** 加载简历列表 */
async function loadResumeList() {
try {
const intention = store.state.jobIntention
const res = await fetchJobList({ pageNum: 1, pageSize: 30, regionCodes: intention.regionCodes?.length ? intention.regionCodes : undefined, categoryIds: intention.categoryIds?.length ? intention.categoryIds : undefined, industryIds: intention.industryIds?.length ? intention.industryIds : undefined, recruitCategory: intention.recruitCategory ?? undefined })
if (res.code === '0' && res.data && res.data.list.length > 0) {
const shuffled = [...res.data.list].sort(() => Math.random() - 0.5)
matchedJobs.value = shuffled.slice(0, 3).map(item => ({ ...item, feedback: '' }))
const res = await fetchResumeList()
if (res.code === '0' && res.data) {
resumeList.value = res.data
const defaultResume = res.data.find(r => r.isDefault === 1)
if (defaultResume && defaultResume.id) {
selectedResumeId.value = defaultResume.id
} else if (res.data.length > 0 && res.data[0].id) {
selectedResumeId.value = res.data[0].id
}
}
} catch (e) { console.error('[AgentSetupWizard] 加载匹配岗位失败', e) }
finally { loadingMatchJobs.value = false }
} catch { console.error('[AgentSetupWizard] 加载简历列表失败') }
}
function handleDislike(index: number) {
const job = matchedJobs.value[index]
if (job.feedback === 'dislike') { job.feedback = ''; showDislikeInput.value = false }
else { job.feedback = 'dislike'; showDislikeInput.value = true }
}
/** 进入第2步时加载简历列表 */
watch(currentStep, (val) => { if (val === 2) loadResumeList() })
// ==================== 第3步:网申常见问题 ====================
const isInternship = computed(() => store.state.jobIntention.recruitCategory === 2)
const step3Sub = ref(1)
const step3Form = reactive({
acceptDeptTransfer: '', acceptLocationTransfer: '',
interviewType: [] as string[],
languages: [{ language: '', proficiency: '' }] as Array<{ language: string; proficiency: string }>,
availableDate: '', internDaysPerWeek: '', internDuration: '',
/** 选择简历后设为默认简历 */
watch(selectedResumeId, async (newId, oldId) => {
// 仅在用户主动切换时调用接口(非初始化加载)
if (!newId || !oldId) return
try {
await setDefaultResume(newId)
} catch { console.error('[AgentSetupWizard] 设置默认简历失败') }
})
/** 监听初始配置数据 — 填充第3步表单 */
watch(() => props.initialConfig, (cfg) => {
if (!cfg) return
step3Form.acceptDeptTransfer = cfg.acceptDeptTransfer || ''
step3Form.acceptLocationTransfer = cfg.acceptLocationTransfer || ''
step3Form.interviewType = cfg.interviewType ? [...cfg.interviewType] : []
step3Form.languages = cfg.languages?.length ? cfg.languages.map(l => ({ language: l.language || '', proficiency: l.proficiency || '' })) : [{ language: '', proficiency: '' }]
step3Form.availableDate = cfg.availableDate || ''
step3Form.internDaysPerWeek = cfg.internDaysPerWeek || ''
step3Form.internDuration = cfg.internDuration || ''
}, { immediate: true })
const languageOptions = ['英语', '日语', '法语', '德语', '韩语', '西班牙语', '俄语']
const proficiencyOptions = ['入门', '日常会话', '商务会话', '无障碍沟通', '母语']
const availableDateOptions = ['一周以内', '两周以内', '一个月以内', '一个月以上']
const internDaysOptions = ['3天及以上', '4天及以上', '5天及以上']
const internDurationOptions = ['3个月', '4个月', '5个月', '6个月及以上']
function toggleInterviewType(type: string) { const idx = step3Form.interviewType.indexOf(type); idx >= 0 ? step3Form.interviewType.splice(idx, 1) : step3Form.interviewType.push(type) }
// ==================== 第3步下半部分:插件安装 ====================
// ==================== 第3步:安装插件 ====================
/** 插件下载地址 */
const extensionDownloadUrl = 'https://offerpie.oss-cn-guangzhou.aliyuncs.com/extension/chrome-mv3-prod.rar'
const browserList = [{ key: 'chrome', label: 'Chrome浏览器' }, { key: 'edge', label: 'Edge浏览器' }, { key: 'safari', label: 'Safari浏览器' }, { key: '360', label: '360浏览器' }, { key: 'qq', label: 'QQ浏览器' }]
const browserImageCount: Record<string, number> = { chrome: 3, edge: 3, safari: 3, '360': 3, qq: 3 }
const guidanceImageBase = 'http://offerpie.oss-cn-guangzhou.aliyuncs.com/extension/guidance_image'
const showBrowserGuide = ref(false)
const currentBrowserKey = ref('')
const currentBrowserLabel = computed(() => browserList.find(b => b.key === currentBrowserKey.value)?.label || '')
const currentBrowserImages = computed(() => { const key = currentBrowserKey.value; if (!key) return []; const count = browserImageCount[key] || 3; return Array.from({ length: count }, (_, i) => `${guidanceImageBase}/guidance-image-${key}-${String(i + 1).padStart(2, '0')}.png`) })
function openBrowserGuide(key: string) { currentBrowserKey.value = key; showBrowserGuide.value = true }
/** 下载插件 */
function downloadExtension() { window.open(extensionDownloadUrl, '_blank') }
// ==================== 第4步:配置求职助手 ====================
const settingsPanelRef = ref<InstanceType<typeof AgentSettingsPanel> | null>(null)
const setupComplete = ref(false)
/** 设置完成 — 收集第3步+第4步数据,通过 emit 传回父组件 */
function handleStep4Complete() {
const step4Data = settingsPanelRef.value?.getData()
const allSettings = {
jobType: store.state.jobIntention.recruitCategory === 2 ? 1 : 2,
agentMode: step4Data?.agentMode ?? 1,
weeklyTarget: step4Data?.weeklyTarget ?? 2,
autoOptimizeResume: step4Data?.autoOptimizeResume ?? 1,
acceptDeptTransfer: step3Form.acceptDeptTransfer,
acceptLocationTransfer: step3Form.acceptLocationTransfer,
interviewType: [...step3Form.interviewType],
languages: step3Form.languages.filter(l => l.language),
availableDate: step3Form.availableDate,
internDaysPerWeek: step3Form.internDaysPerWeek,
internDuration: step3Form.internDuration,
status:1,
}
emit('complete', allSettings)
setupComplete.value = true
/** 复制文本到剪贴板 */
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制到剪贴板')
}).catch(() => {
ElMessage.error('复制失败,请手动复制')
})
}
/** 启用求职助手 */
function handleLaunchAgent() { emit('launch') }
// ==================== 第4步:配置求职助手 ====================
/** 启用求职助手 — 收集第2步数据,通过 emit 传回父组件并触发 launch */
function handleLaunchAgent() {
const allSettings = {
jobType: store.state.jobIntention.recruitCategory === 2 ? 1 : 2,
agentMode: 1,
weeklyTarget: 2,
autoOptimizeResume: autoOptimizeSwitch.value ? 1 : 0,
autoMatchAnalysis: autoMatchAnalysisSwitch.value ? 1 : 0,
defaultResumeId: selectedResumeId.value,
status: 1,
}
emit('complete', allSettings)
emit('launch')
}
</script>
<style lang="scss">
+31 -5
View File
@@ -90,7 +90,7 @@
<!-- 用户评价 -->
<div class="member-dialog__testimonial">
<div class="member-dialog__testimonial-avatar">
<img src="@/assets/images/home/avatar-temporary.png" alt="用户头像" />
<img src="@/assets/images/home/alex-avatar.png" alt="用户头像" />
</div>
<div class="member-dialog__testimonial-content">
<div class="member-dialog__testimonial-stars"></div>
@@ -107,7 +107,7 @@
<div v-else-if="currentView === 'order1'" class="member-dialog__order">
<!-- 顶部导航 -->
<div class="member-dialog__order-header">
<span class="member-dialog__order-back" @click="currentView = 'intro2'"> 返回会员介绍</span>
<span class="melmber-dialog__order-back" @click="currentView = 'intro2'"> 返回会员介绍</span>
<!-- 步骤条 二维码弹窗打开时显示第2步 -->
<div class="member-dialog__steps">
<div class="member-dialog__step" :class="showQrCode ? 'member-dialog__step--done' : 'member-dialog__step--active'">
@@ -340,15 +340,23 @@
<h3 class="member-dialog__qrcode-subtitle">{{ selectedPayment === 'alipay' ? '扫码完成支付' : '扫码完成支付' }}</h3>
<p class="member-dialog__qrcode-desc">{{ selectedPayment === 'alipay' ? '请在支付宝页面完成支付,完成后点击下方按钮确认。' : '请使用微信 App 扫描二维码完成支付,完成后此窗口会自动关闭。' }}</p>
<!-- 支付宝iframe 渲染支付表单 -->
<div class="member-dialog__qrcode-image">
<div v-if="selectedPayment === 'alipay' && paymentFormHtml" class="member-dialog__qrcode-image">
<iframe
v-if="paymentFormHtml"
:srcdoc="paymentFormHtml"
frameborder="0"
class="member-dialog__payment-iframe"
scrolling="auto"
></iframe>
</div>
<!-- 微信使用 qrcode.js 渲染二维码 -->
<div v-if="selectedPayment === 'wechat' && wechatCodeUrl" class="member-dialog__qrcode-image">
<iframe
:srcdoc="wechatQrHtml"
frameborder="0"
class="member-dialog__wechat-qr-iframe"
scrolling="no"
></iframe>
</div>
<!-- 金额 -->
<div class="member-dialog__qrcode-amount">¥{{ currentPlan.price }}</div>
</div>
@@ -428,6 +436,15 @@ const showQrCode = ref(false)
/** 支付宝表单 HTML(用于 iframe 渲染) */
const paymentFormHtml = ref('')
/** 微信支付 code_url(用于生成二维码) */
const wechatCodeUrl = ref('')
/** 微信二维码 iframe 的 srcdoc HTML */
const wechatQrHtml = computed(() => {
if (!wechatCodeUrl.value) return ''
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"><\/script><style>body{display:flex;align-items:center;justify-content:center;margin:0;height:100%;}</style></head><body><div id="qrcode"></div><script>new QRCode(document.getElementById("qrcode"),{text:"${wechatCodeUrl.value}",width:200,height:200,colorDark:"#000000",colorLight:"#ffffff",correctLevel:QRCode.CorrectLevel.H});<\/script></body></html>`
})
/** 当前订单ID(用于后续轮询支付状态) */
const orderId = ref('')
@@ -442,6 +459,7 @@ watch(() => props.modelValue, (val) => {
showQrCode.value = false
agreeProtocol.value = false
paymentFormHtml.value = ''
wechatCodeUrl.value = ''
orderId.value = ''
// 清除轮询计时器
if (pollTimer) {
@@ -586,7 +604,15 @@ async function handleShowQrCode() {
})
if (res.data) {
orderId.value = String(res.data.orderId)
paymentFormHtml.value = res.data.payData
if (selectedPayment.value === 'wechat') {
// 微信支付 — payData 为 code_url,用于生成二维码
wechatCodeUrl.value = res.data.payData
paymentFormHtml.value = ''
} else {
// 支付宝 — payData 为 HTML 表单
paymentFormHtml.value = res.data.payData
wechatCodeUrl.value = ''
}
showQrCode.value = true
// 创建订单成功后立即开始轮询支付状态
confirmPayment()
File diff suppressed because it is too large Load Diff
+361 -202
View File
@@ -1,232 +1,317 @@
<template>
<div class="profile-page-content">
<!-- 个人信息 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">个人信息</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('info')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div class="profile-page-content__info-name">{{ profile.name }}</div>
<div class="profile-page-content__info-contacts">
<span class="profile-page-content__contact-item">📱 {{ profile.phone }}</span>
<span class="profile-page-content__contact-item"> {{ profile.email }}</span>
<span class="profile-page-content__contact-item">📍 {{ resolveRegionName(profile.regionCode) }}</span>
</div>
<div class="profile-page-content__info-contacts" v-if="profile.wechat">
<span class="profile-page-content__contact-item">💬 {{ profile.wechat }}</span>
</div>
</div>
<!-- 顶部 Tab 导航栏固定不动 -->
<div class="profile-page-content__tabs">
<span
v-for="tab in tabList"
:key="tab.key"
class="profile-page-content__tab-item"
:class="{ 'profile-page-content__tab-item--active': activeTab === tab.key }"
@click="scrollToSection(tab.key)"
>{{ tab.label }}</span>
</div>
<!-- 作品集 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">作品集</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('portfolio')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<!-- 有链接时显示可点击链接 -->
<a
v-if="profile.portfolioUrl"
:href="profile.portfolioUrl"
target="_blank"
rel="noopener noreferrer"
class="profile-page-content__portfolio-link"
>{{ profile.portfolioUrl }}</a>
<!-- 无链接时显示占位提示 -->
<span v-else class="profile-page-content__portfolio-empty">暂未添加作品集链接</span>
<!-- 内容滚动区域 -->
<div ref="scrollContainerRef" class="profile-page-content__body">
<!-- 个人信息 -->
<div ref="infoRef" class="profile-page-content__card p10" :class="{ 'profile-page-content__card-hover': editingModule !== 'info' }">
<template v-if="editingModule !== 'info'">
<div class="profile-page-content__card-header pl0">
<h3 class="profile-page-content__card-title">个人信息</h3>
<button class="profile-page-content__edit-btn-inline" @click="startEdit('info')">
<el-icon><EditPen /></el-icon><span>编辑</span>
</button>
</div>
<div class="profile-page-content__info-name">{{ profile.name }}</div>
<div class="profile-page-content__info-contacts">
<span class="profile-page-content__contact-item">📱 {{ profile.phone }}</span>
<span class="profile-page-content__contact-item"> {{ profile.email }}</span>
<span class="profile-page-content__contact-item">📍 {{ resolveRegionName(profile.regionCode) }}</span>
</div>
<div class="profile-page-content__info-contacts" v-if="profile.wechat">
<span class="profile-page-content__contact-item">💬 {{ profile.wechat }}</span>
</div>
<!-- 作品集链接显示在个人信息卡片内 -->
<div v-if="profile.portfolioUrl" class="profile-page-content__info-contacts">
<span class="profile-page-content__contact-item">🔗 <a :href="profile.portfolioUrl" target="_blank" rel="noopener noreferrer" class="profile-page-content__portfolio-link">{{ profile.portfolioUrl }}</a></span>
</div>
</template>
<!-- 编辑模式 -->
<ProfileEditDrawer
v-if="editingModule === 'info'"
v-model="editVisible"
module="info"
:initial-data="editInitialData"
@save="handleSave"
@cancel="cancelEdit"
/>
</div>
<!-- 教育经历 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">教育经历</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('education')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div v-for="(edu, i) in profile.education" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ edu.school }}</div>
<div class="profile-page-content__item-sub">{{ getDegreeText(edu.studyType, edu.degree) }} · {{ edu.major }}</div>
<div class="profile-page-content__item-date">{{ edu.startDate }} - {{ edu.endDate }}</div>
</div>
<!-- 教育经历 -->
<div ref="educationRef" class="profile-page-content__card">
<div class="profile-page-content__card-header profile-page-content__header-hover">
<h3 class="profile-page-content__card-title">教育经历</h3>
<button class="profile-page-content__edit-btn-inline" @click="startAdd('education')">
<el-icon><Plus /></el-icon><span>添加</span>
</button>
</div>
<!-- 新增 -->
<ResumeExpInlineEdit v-if="addingModule === 'education'" module-type="education" @save="handleExpAdd('education', $event)" @cancel="addingModule = ''" />
<div v-for="(edu, i) in profile.education" :key="i" class="profile-page-content__section-item">
<template v-if="editingExpIndex !== `education_${i}`">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ edu.school }}</div>
<div class="profile-page-content__item-sub">{{ getDegreeText(edu.studyType, edu.degree) }} · {{ edu.major }}</div>
<div class="profile-page-content__item-date">{{ edu.startDate }} - {{ edu.endDate }}</div>
</div>
<ul class="profile-page-content__item-list" v-if="edu.description?.length && edu.description[0]?.text">
<li v-for="desc in edu.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 工作经历 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">工作经历</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('work')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
<button class="profile-page-content__edit-btn-inline profile-page-content__item-edit-btn" @click="startEditExp('education', i, edu)">
<el-icon><EditPen /></el-icon><span>编辑</span>
</button>
</div>
<div v-for="(exp, i) in profile.works" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ exp.companyName }}</div>
<div class="profile-page-content__item-sub">{{ exp.position }}</div>
</div>
<div class="profile-page-content__item-period">{{ exp.startDate }} - {{ exp.endDate || '至今' }}</div>
<ul class="profile-page-content__item-list" v-if="edu.description?.length && edu.description[0]?.text">
<li v-for="desc in edu.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</template>
<ResumeExpInlineEdit v-else module-type="education" :initial-data="edu" @save="handleExpUpdate('education', i, $event)" @cancel="editingExpIndex = ''" />
</div>
</div>
<!-- 工作经历 -->
<div ref="workRef" class="profile-page-content__card">
<div class="profile-page-content__card-header profile-page-content__header-hover">
<h3 class="profile-page-content__card-title">工作经历</h3>
<button class="profile-page-content__edit-btn-inline" @click="startAdd('work')">
<el-icon><Plus /></el-icon><span>添加</span>
</button>
</div>
<ResumeExpInlineEdit v-if="addingModule === 'work'" module-type="work" @save="handleExpAdd('work', $event)" @cancel="addingModule = ''" />
<div v-for="(exp, i) in profile.works" :key="i" class="profile-page-content__section-item">
<template v-if="editingExpIndex !== `work_${i}`">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ exp.companyName }}</div>
<div class="profile-page-content__item-sub">{{ exp.position }}</div>
</div>
<ul class="profile-page-content__item-list">
<li v-for="desc in exp.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 实习经历 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">实习经历</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('internship')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
<div class="profile-page-content__item-period">{{ exp.startDate }} - {{ exp.endDate || '至今' }}</div>
<button class="profile-page-content__edit-btn-inline profile-page-content__item-edit-btn" @click="startEditExp('work', i, exp)">
<el-icon><EditPen /></el-icon><span>编辑</span>
</button>
</div>
<div v-for="(exp, i) in profile.internships" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ exp.companyName }}</div>
<div class="profile-page-content__item-sub">{{ exp.position }}</div>
</div>
<div class="profile-page-content__item-period">{{ exp.startDate }} - {{ exp.endDate }}</div>
<ul class="profile-page-content__item-list">
<li v-for="desc in exp.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</template>
<ResumeExpInlineEdit v-else module-type="work" :initial-data="exp" @save="handleExpUpdate('work', i, $event)" @cancel="editingExpIndex = ''" />
</div>
</div>
<!-- 实习经历 -->
<div ref="internshipRef" class="profile-page-content__card">
<div class="profile-page-content__card-header profile-page-content__header-hover">
<h3 class="profile-page-content__card-title">实习经历</h3>
<button class="profile-page-content__edit-btn-inline" @click="startAdd('internship')">
<el-icon><Plus /></el-icon><span>添加</span>
</button>
</div>
<ResumeExpInlineEdit v-if="addingModule === 'internship'" module-type="internship" @save="handleExpAdd('internship', $event)" @cancel="addingModule = ''" />
<div v-for="(exp, i) in profile.internships" :key="i" class="profile-page-content__section-item">
<template v-if="editingExpIndex !== `internship_${i}`">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ exp.companyName }}</div>
<div class="profile-page-content__item-sub">{{ exp.position }}</div>
</div>
<ul class="profile-page-content__item-list">
<li v-for="desc in exp.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 项目经历 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">项目经历</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('project')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
<div class="profile-page-content__item-period">{{ exp.startDate }} - {{ exp.endDate }}</div>
<button class="profile-page-content__edit-btn-inline profile-page-content__item-edit-btn" @click="startEditExp('internship', i, exp)">
<el-icon><EditPen /></el-icon><span>编辑</span>
</button>
</div>
<div v-for="(proj, i) in profile.projects" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ proj.projectName }}</div>
<div class="profile-page-content__item-sub">{{ proj.role }}</div>
</div>
<div class="profile-page-content__item-period">{{ proj.startDate }} - {{ proj.endDate }}</div>
<ul class="profile-page-content__item-list">
<li v-for="desc in exp.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</template>
<ResumeExpInlineEdit v-else module-type="internship" :initial-data="exp" @save="handleExpUpdate('internship', i, $event)" @cancel="editingExpIndex = ''" />
</div>
</div>
<!-- 项目经历 -->
<div ref="projectRef" class="profile-page-content__card">
<div class="profile-page-content__card-header profile-page-content__header-hover">
<h3 class="profile-page-content__card-title">项目经历</h3>
<button class="profile-page-content__edit-btn-inline" @click="startAdd('project')">
<el-icon><Plus /></el-icon><span>添加</span>
</button>
</div>
<ResumeExpInlineEdit v-if="addingModule === 'project'" module-type="project" @save="handleExpAdd('project', $event)" @cancel="addingModule = ''" />
<div v-for="(proj, i) in profile.projects" :key="i" class="profile-page-content__section-item">
<template v-if="editingExpIndex !== `project_${i}`">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ proj.projectName }}</div>
<div class="profile-page-content__item-sub">{{ proj.role }}</div>
</div>
<ul class="profile-page-content__item-list">
<li v-for="desc in proj.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 技能 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">技能</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('skills')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
<div class="profile-page-content__item-period">{{ proj.startDate }} - {{ proj.endDate }}</div>
<button class="profile-page-content__edit-btn-inline profile-page-content__item-edit-btn" @click="startEditExp('project', i, proj)">
<el-icon><EditPen /></el-icon><span>编辑</span>
</button>
</div>
<div class="profile-page-content__tags">
<span v-for="(skill, i) in profile.skills" :key="i" class="profile-page-content__tag">{{ skill }}</span>
</div>
</div>
<ul class="profile-page-content__item-list">
<li v-for="desc in proj.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</template>
<ResumeExpInlineEdit v-else module-type="project" :initial-data="proj" @save="handleExpUpdate('project', i, $event)" @cancel="editingExpIndex = ''" />
</div>
</div>
<!-- 竞赛 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">竞赛</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('competition')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div v-for="(comp, i) in profile.competitions" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ comp.competitionName }} · {{ comp.award }}</div>
</div>
<div class="profile-page-content__item-period">{{ comp.awardDate }}</div>
<!-- 竞赛 -->
<div ref="competitionRef" class="profile-page-content__card">
<div class="profile-page-content__card-header profile-page-content__header-hover">
<h3 class="profile-page-content__card-title">竞赛</h3>
<button class="profile-page-content__edit-btn-inline" @click="startAdd('competition')">
<el-icon><Plus /></el-icon><span>添加</span>
</button>
</div>
<ResumeExpInlineEdit v-if="addingModule === 'competition'" module-type="competition" @save="handleExpAdd('competition', $event)" @cancel="addingModule = ''" />
<div v-for="(comp, i) in profile.competitions" :key="i" class="profile-page-content__section-item">
<template v-if="editingExpIndex !== `competition_${i}`">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ comp.competitionName }} · {{ comp.award }}</div>
</div>
<ul class="profile-page-content__item-list" v-if="comp.description[0]?.text">
<li v-for="desc in comp.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 证书 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">证书</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('certificate')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
<div class="profile-page-content__item-period">{{ comp.awardDate }}</div>
<button class="profile-page-content__edit-btn-inline profile-page-content__item-edit-btn" @click="startEditExp('competition', i, comp)">
<el-icon><EditPen /></el-icon><span>编辑</span>
</button>
</div>
<div class="profile-page-content__tags">
<span v-for="(cert, i) in profile.certificates" :key="i" class="profile-page-content__tag">{{ cert }}</span>
</div>
<ul class="profile-page-content__item-list" v-if="comp.description[0]?.text">
<li v-for="desc in comp.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</template>
<ResumeExpInlineEdit v-else module-type="competition" :initial-data="comp" @save="handleExpUpdate('competition', i, $event)" @cancel="editingExpIndex = ''" />
</div>
</div>
<!-- 技能 -->
<div ref="skillsRef" class="profile-page-content__card" :class="{ 'profile-page-content__card-hover': editingModule !== 'skills' }">
<template v-if="editingModule !== 'skills'">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">技能</h3>
<button class="profile-page-content__edit-btn-inline" @click="startEdit('skills')">
<el-icon><EditPen /></el-icon><span>编辑</span>
</button>
</div>
<div class="profile-page-content__tags">
<span v-for="(skill, i) in profile.skills" :key="i" class="profile-page-content__tag">{{ skill }}</span>
</div>
</template>
<ProfileEditDrawer
v-if="editingModule === 'skills'"
v-model="editVisible"
module="skills"
:initial-data="editInitialData"
@save="handleSave"
@cancel="cancelEdit"
/>
</div>
<!-- 证书 -->
<div ref="certificateRef" class="profile-page-content__card" :class="{ 'profile-page-content__card-hover': editingModule !== 'certificate' }">
<template v-if="editingModule !== 'certificate'">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">证书</h3>
<button class="profile-page-content__edit-btn-inline" @click="startEdit('certificate')">
<el-icon><EditPen /></el-icon><span>编辑</span>
</button>
</div>
<div class="profile-page-content__tags">
<span v-for="(cert, i) in profile.certificates" :key="i" class="profile-page-content__tag">{{ cert }}</span>
</div>
</template>
<ProfileEditDrawer
v-if="editingModule === 'certificate'"
v-model="editVisible"
module="certificate"
:initial-data="editInitialData"
@save="handleSave"
@cancel="cancelEdit"
/>
</div>
</div><!-- 内容滚动区域结束 -->
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { EditPen, Plus } from '@element-plus/icons-vue'
import { resolveRegionName } from '@/utils/region'
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
import ResumeExpInlineEdit from '@/components/ResumeExpInlineEdit.vue'
/** Tab 导航列表 */
const tabList = [
{ key: 'info', label: '个人信息' },
{ key: 'education', label: '教育经历' },
{ key: 'work', label: '工作经历' },
{ key: 'internship', label: '实习经历' },
{ key: 'project', label: '项目经历' },
{ key: 'skills', label: '技能' },
{ key: 'competition', label: '竞赛' },
{ key: 'certificate', label: '证书' },
]
/** 当前激活的 tab */
const activeTab = ref('info')
/** 各模块的 DOM 引用 */
const scrollContainerRef = ref<HTMLElement | null>(null)
const infoRef = ref<HTMLElement | null>(null)
const educationRef = ref<HTMLElement | null>(null)
const workRef = ref<HTMLElement | null>(null)
const internshipRef = ref<HTMLElement | null>(null)
const projectRef = ref<HTMLElement | null>(null)
const skillsRef = ref<HTMLElement | null>(null)
const competitionRef = ref<HTMLElement | null>(null)
const certificateRef = ref<HTMLElement | null>(null)
/** 模块 key 与 ref 的映射 */
const sectionRefMap: Record<string, typeof infoRef> = {
info: infoRef,
education: educationRef,
work: workRef,
internship: internshipRef,
project: projectRef,
skills: skillsRef,
competition: competitionRef,
certificate: certificateRef,
}
/** 点击 tab 滚动到对应模块 */
function scrollToSection(key: string) {
activeTab.value = key
const targetRef = sectionRefMap[key]
const container = scrollContainerRef.value
if (targetRef?.value && container) {
const offsetTop = targetRef.value.offsetTop - container.offsetTop
container.scrollTo({ top: offsetTop, behavior: 'smooth' })
}
}
/** 个人资料数据结构 */
interface ProfileData {
name: string
phone: string
email: string
idNumber: string
/** 所在城市编码 — 对应接口字段 regionCode */
regionCode: string
/** 作品集链接 — 对应接口字段 portfolioUrl */
portfolioUrl: string
wechat?: string
education: Array<{
school: string
major: string
studyType: number
degree: number
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>
works: Array<{
companyName: string
position: string
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>
internships: Array<{
companyName: string
position: string
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>
projects: Array<{
projectName: string
companyName: string
role: string
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>
education: Array<{ school: string; major: string; studyType: number; degree: number; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>
works: Array<{ companyName: string; position: string; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>
internships: Array<{ companyName: string; position: string; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>
projects: Array<{ projectName: string; companyName: string; role: string; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>
skills: string[]
competitions: Array<{
competitionName: string
award: string
awardDate: string
description: Array<{ id: string; text: string }>
}>
competitions: Array<{ competitionName: string; award: string; awardDate: string; description: Array<{ id: string; text: string }> }>
certificates: string[]
}
@@ -235,23 +320,97 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
edit: [section: string]
/** 模块编辑保存事件 */
(e: 'edit', section: string): void
/** 内联保存事件(模块名+数据) */
(e: 'save', section: string, data: Record<string, any>): void
/** 单条经历更新事件 */
(e: 'expUpdate', moduleType: string, index: number, data: Record<string, any>): void
/** 新增经历事件 */
(e: 'expAdd', moduleType: string, data: Record<string, any>): void
}>()
/** 获取学历文本 — 根据学历类型和学历代码生成显示文本 */
/** 当前编辑的模块(info/skills/certificate */
const editingModule = ref('')
/** 编辑可见状态 */
const editVisible = ref(false)
/** 编辑初始数据 */
const editInitialData = ref<Record<string, any>>({})
/** 当前编辑的经历索引 key(格式:moduleType_index */
const editingExpIndex = ref('')
/** 当前添加经历的模块 */
const addingModule = ref('')
/** 获取学历文本 */
function getDegreeText(studyType: number, degree: number): string {
const studyTypeText = studyType === 0 ? '全日制' : '非全日制'
const degreeMap: Record<number, string> = {
1: '大专',
2: '本科',
3: '硕士',
4: '博士',
}
const degreeMap: Record<number, string> = { 1: '大专', 2: '本科', 3: '硕士', 4: '博士' }
return `${studyTypeText}${degreeMap[degree] || ''}`
}
function handleEdit(section: string) {
emit('edit', section)
/** 打开模块编辑(个人信息/技能/证书) */
function startEdit(module: string) {
editingModule.value = module
editVisible.value = true
editingExpIndex.value = ''
addingModule.value = ''
if (module === 'info') {
editInitialData.value = {
name: props.profile.name || '',
email: props.profile.email || '',
phone: props.profile.phone || '',
location: props.profile.regionCode || '',
wechat: props.profile.wechat || '',
portfolioUrl: props.profile.portfolioUrl || '',
}
} else if (module === 'skills') {
editInitialData.value = { skills: [...(props.profile.skills || [])] }
} else if (module === 'certificate') {
editInitialData.value = { certificates: [...(props.profile.certificates || [])] }
}
}
/** 取消编辑 */
function cancelEdit() {
editingModule.value = ''
editVisible.value = false
}
/** 保存编辑(个人信息/技能/证书) */
function handleSave(data: Record<string, any>) {
emit('save', editingModule.value, data)
editingModule.value = ''
editVisible.value = false
}
/** 开始添加经历 */
function startAdd(moduleType: string) {
addingModule.value = moduleType
editingExpIndex.value = ''
editingModule.value = ''
}
/** 开始编辑单条经历 */
function startEditExp(_moduleType: string, index: number, _record: any) {
editingExpIndex.value = `${_moduleType}_${index}`
addingModule.value = ''
editingModule.value = ''
}
/** 保存经历更新 */
function handleExpUpdate(moduleType: string, index: number, data: Record<string, any>) {
emit('expUpdate', moduleType, index, data)
editingExpIndex.value = ''
}
/** 保存新增经历 */
function handleExpAdd(moduleType: string, data: Record<string, any>) {
emit('expAdd', moduleType, data)
addingModule.value = ''
}
</script>
+43 -46
View File
@@ -1,54 +1,51 @@
<template>
<!-- 编辑简历名称与目标岗位弹窗 -->
<el-dialog
v-model="visible"
title=""
width="4.6rem"
:show-close="true"
:close-on-click-modal="false"
class="resume-edit-name-dialog"
@close="handleClose"
>
<div class="resume-edit-name-dialog__body">
<!-- 简历名称 -->
<div class="resume-edit-name-dialog__field">
<label class="resume-edit-name-dialog__label">
<span class="resume-edit-name-dialog__required">*</span>简历名称
</label>
<el-input
v-model="form.resumeName"
placeholder="请输入简历名称"
maxlength="50"
class="resume-edit-name-dialog__input"
/>
</div>
<!-- 编辑简历名称与目标岗位弹窗 Teleport body 避免缩放影响 -->
<Teleport to="body">
<div v-if="visible" class="resume-edit-name-overlay" @click.self="handleClose">
<div class="resume-edit-name-dialog">
<!-- 关闭按钮 -->
<button class="resume-edit-name-dialog__close" @click="handleClose"></button>
<!-- 目标岗位 -->
<div class="resume-edit-name-dialog__field">
<label class="resume-edit-name-dialog__label">目标岗位</label>
<el-input
v-model="form.targetPosition"
placeholder="请输入岗位名称"
maxlength="50"
class="resume-edit-name-dialog__input"
/>
</div>
<!-- 简历名称 -->
<div class="resume-edit-name-dialog__field">
<label class="resume-edit-name-dialog__label">
<span class="resume-edit-name-dialog__required">*</span>简历名称
</label>
<input
v-model="form.resumeName"
placeholder="请输入简历名称"
maxlength="50"
class="resume-edit-name-dialog__input"
/>
</div>
<!-- 按钮区域 -->
<div class="resume-edit-name-dialog__footer">
<button class="resume-edit-name-dialog__btn resume-edit-name-dialog__btn--cancel" @click="handleClose">
取消
</button>
<button
class="resume-edit-name-dialog__btn resume-edit-name-dialog__btn--save"
:disabled="saving"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存' }}
</button>
<!-- 目标岗位 -->
<div class="resume-edit-name-dialog__field">
<label class="resume-edit-name-dialog__label">目标岗位</label>
<input
v-model="form.targetPosition"
placeholder="请输入岗位名称"
maxlength="50"
class="resume-edit-name-dialog__input"
/>
</div>
<!-- 按钮区域 -->
<div class="resume-edit-name-dialog__footer">
<button class="resume-edit-name-dialog__btn resume-edit-name-dialog__btn--cancel" @click="handleClose">
取消
</button>
<button
class="resume-edit-name-dialog__btn resume-edit-name-dialog__btn--save"
:disabled="saving"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</el-dialog>
</Teleport>
</template>
<script setup lang="ts">
+228
View File
@@ -0,0 +1,228 @@
<template>
<!-- 单条经历内联编辑组件 -->
<div class="profile-edit-inline">
<div class="profile-edit-inline__body">
<!-- 教育经历 -->
<template v-if="moduleType === 'education'">
<div class="profile-edit-inline__fields-row">
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">学校</label><input class="profile-edit-inline__input" placeholder="请输入学校名称" v-model="form.school" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">专业</label><input class="profile-edit-inline__input" placeholder="请输入专业名称" v-model="form.major" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">学历</label><select class="profile-edit-inline__input" v-model="form.degree"><option value="大专">大专</option><option value="本科">本科</option><option value="硕士">硕士</option><option value="博士">博士</option></select></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">学历类型</label><select class="profile-edit-inline__input" v-model="form.studyType"><option value="全日制">全日制</option><option value="非全日制">非全日制</option></select></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">就读时间</label>
<div class="profile-edit-inline__date-range">
<el-date-picker v-model="form.startDate" type="month" placeholder="开始" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" />
<span class="profile-edit-inline__date-sep">-</span>
<el-date-picker v-model="form.endDate" type="month" placeholder="结束" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" />
</div>
</div>
</div>
</template>
<!-- 工作经历 -->
<template v-else-if="moduleType === 'work'">
<div class="profile-edit-inline__fields-row">
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">公司名称</label><input class="profile-edit-inline__input" placeholder="请输入公司名称" v-model="form.companyName" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">职位</label><input class="profile-edit-inline__input" placeholder="请输入职位" v-model="form.position" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">时间</label>
<div class="profile-edit-inline__date-range">
<el-date-picker v-model="form.startDate" type="month" placeholder="开始" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" />
<span class="profile-edit-inline__date-sep">-</span>
<el-date-picker v-model="form.endDate" type="month" placeholder="结束" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" />
</div>
</div>
</div>
</template>
<!-- 实习经历 -->
<template v-else-if="moduleType === 'internship'">
<div class="profile-edit-inline__fields-row">
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">公司名称</label><input class="profile-edit-inline__input" placeholder="请输入公司名称" v-model="form.companyName" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">职位</label><input class="profile-edit-inline__input" placeholder="请输入职位" v-model="form.position" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">时间</label>
<div class="profile-edit-inline__date-range">
<el-date-picker v-model="form.startDate" type="month" placeholder="开始" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" />
<span class="profile-edit-inline__date-sep">-</span>
<el-date-picker v-model="form.endDate" type="month" placeholder="结束" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" />
</div>
</div>
</div>
</template>
<!-- 项目经历 -->
<template v-else-if="moduleType === 'project'">
<div class="profile-edit-inline__fields-row">
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">项目名称</label><input class="profile-edit-inline__input" placeholder="请输入项目名称" v-model="form.projectName" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">项目角色</label><input class="profile-edit-inline__input" placeholder="请输入项目角色" v-model="form.role" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">时间</label>
<div class="profile-edit-inline__date-range">
<el-date-picker v-model="form.startDate" type="month" placeholder="开始" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" />
<span class="profile-edit-inline__date-sep">-</span>
<el-date-picker v-model="form.endDate" type="month" placeholder="结束" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" />
</div>
</div>
</div>
</template>
<!-- 竞赛经历 -->
<template v-else-if="moduleType === 'competition'">
<div class="profile-edit-inline__fields-row">
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">竞赛名称</label><input class="profile-edit-inline__input" placeholder="请输入竞赛名称" v-model="form.competitionName" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">获奖名次</label><input class="profile-edit-inline__input" placeholder="请输入获奖名次" v-model="form.award" /></div>
<div class="profile-edit-inline__field-item"><label class="profile-edit-inline__label">获奖时间</label><el-date-picker v-model="form.awardDate" type="month" placeholder="请选择" format="YYYY-MM" value-format="YYYY-MM" class="profile-edit-inline__date-picker" :teleported="true" popper-class="profile-drawer-date-popper" /></div>
</div>
</template>
<!-- 经历描述 + AI润色 -->
<div class="profile-edit-inline__summary-header">
<label class="profile-edit-inline__label">经历描述</label>
<button class="profile-edit-inline__ai-polish-btn" @click="handleAiPolish" :disabled="aiPolishing">
<span> AI润色</span>
</button>
</div>
<div class="profile-edit-inline__field">
<textarea class="profile-edit-inline__textarea" placeholder="请输入经历描述" v-model="descriptionText" rows="4"></textarea>
</div>
</div>
<!-- 底部操作按钮 -->
<div class="profile-edit-inline__footer">
<button class="profile-edit-inline__save-btn" @click="handleSave">保存</button>
<button class="profile-edit-inline__cancel-btn" @click="handleCancel">取消</button>
</div>
<!-- AI润色结果保存取消下方 -->
<div v-if="aiPolishResult" class="profile-edit-inline__ai-result">
<p class="profile-edit-inline__ai-result-label"> AI润色结果</p>
<div class="profile-edit-inline__ai-result-card">
<p class="profile-edit-inline__ai-result-text">{{ aiPolishResult }}</p>
</div>
<div class="profile-edit-inline__ai-result-actions">
<button class="profile-edit-inline__ai-replace-btn" @click="handleReplaceWithAi">+ 替换为此内容</button>
<button class="profile-edit-inline__ai-regenerate-btn" @click="handleAiPolish" :disabled="aiPolishing"> 重新生成</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage, ElLoading } from 'element-plus'
import { polishDiagnosisIssue } from '@/api/resume'
/** 描述段落结构 */
interface DescriptionParagraph {
id: string
text: string
}
/** Props */
const props = defineProps<{
/** 经历模块类型 */
moduleType: string
/** 初始数据(编辑已有经历时传入,新增时为空) */
initialData?: Record<string, any>
}>()
/** Emits */
const emit = defineEmits<{
(e: 'save', data: Record<string, any>): void
(e: 'cancel'): void
}>()
/** 表单数据 */
const form = ref<Record<string, any>>({})
/** 经历描述文本(多段用换行拼接) */
const descriptionText = ref('')
/** AI润色结果 */
const aiPolishResult = ref('')
/** AI润色中 */
const aiPolishing = ref(false)
/** 生成短ID */
const generateId = (): string => Math.random().toString(36).substring(2, 8)
/** 初始化表单 */
function initForm() {
aiPolishResult.value = ''
if (props.initialData) {
form.value = { ...props.initialData }
// 将 description 数组拼接为文本
if (props.initialData.description?.length) {
descriptionText.value = props.initialData.description.map((d: DescriptionParagraph) => d.text).join('\n')
} else {
descriptionText.value = ''
}
} else {
// 新增模式 — 空表单
form.value = {}
descriptionText.value = ''
}
}
// 立即初始化
initForm()
// 监听 initialData 变化
watch(() => props.initialData, () => { initForm() }, { deep: true })
/** AI润色 */
async function handleAiPolish() {
const text = descriptionText.value.trim()
if (!text) {
ElMessage.warning('请先输入经历描述')
return
}
aiPolishing.value = true
const loading = ElLoading.service({ lock: true, text: 'AI润色中…', background: 'rgba(0,0,0,0.5)' })
try {
const content = text.split('\n').filter(s => s.trim())
const res = await polishDiagnosisIssue(content)
if (res.code === 0 && res.data?.content) {
aiPolishResult.value = res.data.content.join('\n')
ElMessage.success('AI润色完成')
} else {
ElMessage.error(res.msg || '润色失败')
}
} catch {
ElMessage.error('请求失败')
} finally {
aiPolishing.value = false
loading.close()
}
}
/** 替换为AI润色内容 */
function handleReplaceWithAi() {
if (aiPolishResult.value) {
descriptionText.value = aiPolishResult.value
}
}
/** 保存 */
function handleSave() {
// 将描述文本按换行拆分为 description 数组
const lines = descriptionText.value.split('\n').filter(s => s.trim())
const description: DescriptionParagraph[] = lines.map(text => ({
id: generateId(),
text,
}))
// 如果有原始 description,保留原有的 id
if (props.initialData?.description?.length) {
props.initialData.description.forEach((d: DescriptionParagraph, i: number) => {
if (i < description.length) {
description[i].id = d.id
}
})
}
emit('save', { ...form.value, description })
}
/** 取消 */
function handleCancel() {
emit('cancel')
}
</script>
+403
View File
@@ -0,0 +1,403 @@
<template>
<!-- 一键修复抽屉 从右侧滑入展示所有模块的诊断修复 -->
<Teleport to="body">
<!-- 遮罩层 -->
<Transition name="fix-all-drawer-overlay">
<div
v-if="modelValue"
class="fix-all-drawer-overlay"
@click="handleClose"
/>
</Transition>
<!-- 抽屉主体 -->
<Transition name="fix-all-drawer-slide">
<div v-if="modelValue" class="fix-all-drawer" @click.stop>
<!-- 顶部栏 -->
<div class="fix-all-drawer__header">
<button class="fix-all-drawer__close-btn" @click="handleClose" aria-label="收起">
<svg viewBox="0 0 16 16" fill="none" class="fix-all-drawer__close-icon">
<path d="M10 3l-5 5 5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h3 class="fix-all-drawer__title">一键修复</h3>
</div>
<!-- 可滚动内容区 -->
<div class="fix-all-drawer__body">
<!-- 遍历每个有诊断数据的模块 -->
<template v-for="module in moduleDataList" :key="module.moduleType">
<!-- 模块标题 -->
<h4 class="fix-all-drawer__module-title">{{ moduleTitleMap[module.moduleType] }}</h4>
<!-- 遍历该模块下每条经历/记录 -->
<div
v-for="record in module.records"
:key="record.issueId"
class="fix-all-drawer__record"
>
<!-- 经历标识经历类模块显示标题summary不显示 -->
<p v-if="record.recordLabel" class="fix-all-drawer__record-label">{{ record.recordLabel }}</p>
<!-- 原文编辑区 -->
<div class="fix-all-drawer__card">
<textarea
v-model="record.editText"
class="fix-all-drawer__textarea"
placeholder="原文内容…"
@input="autoResizeTextarea($event)"
/>
</div>
<!-- AI 润色结果区 -->
<div class="fix-all-drawer__ai-section">
<p class="fix-all-drawer__ai-label"> AI润色结果</p>
<div class="fix-all-drawer__card fix-all-drawer__card--ai">
<p class="fix-all-drawer__ai-text">{{ record.polishText }}</p>
</div>
<!-- 操作按钮 -->
<div class="fix-all-drawer__ai-actions">
<button class="fix-all-drawer__action-btn fix-all-drawer__action-btn--replace" @click="handleReplaceToOriginal(record)">+替换为此内容</button>
<button class="fix-all-drawer__action-btn fix-all-drawer__action-btn--regenerate" @click="handleRegenerate(record)"> 重新生成</button>
</div>
</div>
</div>
</template>
</div>
<!-- 底部按钮区 -->
<div class="fix-all-drawer__footer">
<button class="fix-all-drawer__footer-btn fix-all-drawer__footer-btn--use-ai" @click="handleUseAllAi">一键使用AI润色结果</button>
<button class="fix-all-drawer__footer-btn fix-all-drawer__footer-btn--save" @click="handleSaveCurrent">保存当前修改</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import type { DiagnosisIssue, DescriptionParagraph } from '@/api/resume'
import { polishDiagnosisIssue, resolveDiagnosisIssue } from '@/api/resume'
// ==================== Props ====================
/** 单条经历记录的原始数据(父组件传入) */
export interface OriginalRecordData {
/** 模块类型 */
moduleType: string
/** 经历记录 IDsummary 时为 resumeId */
recordId: string
/** 经历显示标题(如"北京大学-经历描述" */
recordLabel: string
/** 原文内容:summary 为字符串,经历为 DescriptionParagraph[] */
originalContent: string | DescriptionParagraph[]
}
const props = defineProps<{
/** 控制抽屉显示/隐藏 */
modelValue: boolean
/** 所有诊断问题列表 */
issues: DiagnosisIssue[]
/** 所有模块的原始经历数据 */
originalRecords: OriginalRecordData[]
}>()
/** 提交结果的单条数据结构 */
export interface FixAllSubmitItem {
/** 模块类型 */
moduleType: string
/** 经历记录 ID */
recordId: string
/** 新的内容:summary 为字符串,经历为 DescriptionParagraph[] */
newContent: string | DescriptionParagraph[]
/** 关联的 issue ID */
issueId: string
}
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
/** 保存当前修改:携带有变化的模块数据 */
(e: 'save', items: FixAllSubmitItem[]): void
/** 一键使用AI润色结果:携带所有模块数据(强制更新) */
(e: 'useAllAi', items: FixAllSubmitItem[]): void
}>()
// ==================== 内部数据结构 ====================
/** 单条记录的内部状态 */
interface RecordState {
/** 关联的 issue ID */
issueId: string
/** 模块类型 */
moduleType: string
/** 经历记录 ID */
recordId: string
/** 经历显示标题 */
recordLabel: string
/** 原文编辑框内容(用户可编辑) */
editText: string
/** AI 润色结果文本 */
polishText: string
/** 原始内容(用于对比是否有变化) */
originalText: string
}
/** 模块分组数据 */
interface ModuleGroup {
/** 模块类型 */
moduleType: string
/** 该模块下所有记录 */
records: RecordState[]
}
// ==================== 模块标题映射 ====================
/** 模块类型到中文标题的映射 */
const moduleTitleMap: Record<string, string> = {
summary: '个人概述',
education: '教育背景',
work: '工作经历',
internship: '实习经历',
project: '项目经历',
competition: '竞赛经历',
}
/** 模块顺序 */
const moduleOrder = ['summary', 'education', 'work', 'internship', 'project', 'competition']
// ==================== 响应式数据 ====================
/** 所有模块分组后的数据列表 */
const moduleDataList = ref<ModuleGroup[]>([])
// ==================== 工具方法 ====================
/** 生成 8 位随机字母数字 ID */
function generateId(): string {
return Math.random().toString(36).substring(2, 10)
}
/** 将 DescriptionParagraph[] 拼接为字符串(每段文字后加换行) */
function descriptionToText(desc: DescriptionParagraph[]): string {
return desc.map(d => d.text || '').join('\n')
}
/** 将文本按换行拆分为 DescriptionParagraph[](每行生成新 id */
function textToDescription(text: string): DescriptionParagraph[] {
const lines = text.split('\n').filter(line => line.trim() !== '')
return lines.map(line => ({ id: generateId(), text: line }))
}
/** 获取 issue 的 optimizedContent 转为文本 */
function getOptimizedText(issue: DiagnosisIssue): string {
const oc = issue.optimizedContent
if (!oc) return ''
// summaryoptimizedContent 是字符串
if (typeof oc === 'string') return oc
// 经历模块:数组对象,取每个子元素的 optimizedContent 或 text 拼接
if (Array.isArray(oc)) {
return oc.map((item: any) => item.optimizedContent || item.text || '').join('\n')
}
return ''
}
// ==================== 初始化逻辑 ====================
/** 抽屉打开时初始化数据 */
watch(() => props.modelValue, (val) => {
if (val) {
initData()
}
})
/** 根据 props 中的 issues 和 originalRecords 初始化内部数据 */
function initData() {
const groupMap: Record<string, RecordState[]> = {}
// 遍历每个 issue,找到对应的原始记录数据
for (const issue of props.issues) {
if (!issue.id || !issue.moduleType || issue.status !== 0) continue
const moduleType = issue.moduleType
const recordId = issue.moduleRecordId || ''
// 找到对应的原始记录
const originalRecord = props.originalRecords.find(
r => r.moduleType === moduleType && r.recordId === recordId
)
// 原文转文本
let originalText = ''
if (originalRecord) {
if (typeof originalRecord.originalContent === 'string') {
originalText = originalRecord.originalContent
} else if (Array.isArray(originalRecord.originalContent)) {
originalText = descriptionToText(originalRecord.originalContent)
}
}
// AI 润色结果:初始用 optimizedContent
const polishText = getOptimizedText(issue)
const state: RecordState = {
issueId: issue.id,
moduleType,
recordId,
recordLabel: originalRecord?.recordLabel || '',
editText: originalText,
polishText,
originalText,
}
if (!groupMap[moduleType]) {
groupMap[moduleType] = []
}
groupMap[moduleType].push(state)
}
// 按模块顺序排列
moduleDataList.value = moduleOrder
.filter(mt => groupMap[mt]?.length)
.map(mt => ({ moduleType: mt, records: groupMap[mt] }))
// 等 DOM 渲染完成后自动调整 textarea 高度
nextTick(() => {
const textareas = document.querySelectorAll('.fix-all-drawer__textarea')
textareas.forEach(el => {
const ta = el as HTMLTextAreaElement
ta.style.height = 'auto'
ta.style.height = ta.scrollHeight + 'px'
})
})
}
// ==================== 事件处理 ====================
/** textarea 自适应高度 */
function autoResizeTextarea(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
/** 替换为此内容 — 将 AI 润色结果替换到原文编辑框 */
function handleReplaceToOriginal(record: RecordState) {
record.editText = record.polishText
// 触发 textarea 重新计算高度
nextTick(() => {
const textareas = document.querySelectorAll('.fix-all-drawer__textarea')
textareas.forEach(el => {
const ta = el as HTMLTextAreaElement
ta.style.height = 'auto'
ta.style.height = ta.scrollHeight + 'px'
})
})
}
/** 重新生成 — 调用 AI 润色接口获取新结果 */
async function handleRegenerate(record: RecordState) {
if (!record.issueId) return
// 收集当前原文编辑框内容作为输入
let content: string[]
if (record.moduleType === 'summary') {
content = [record.editText.trim()]
} else {
content = record.editText.split('\n').filter(line => line.trim() !== '')
}
if (!content.length) {
ElMessage.warning('原文内容为空,无法生成')
return
}
const loading = ElLoading.service({
lock: true,
text: 'AI 重新生成中…',
background: 'rgba(0, 0, 0, 0.5)',
customClass: 'resume-upload-loading',
})
try {
const res = await polishDiagnosisIssue(content)
if (res.code === 0 && res.data?.content) {
// 将返回的数组内容用换行拼接
record.polishText = res.data.content.join('\n')
ElMessage.success('重新生成完成')
} else {
ElMessage.error(res.msg || '生成失败,请稍后重试')
}
} catch {
ElMessage.error('请求失败,请稍后重试')
} finally {
loading.close()
}
}
/** 一键使用AI润色结果 — 所有模块强制使用 AI 润色结果更新 */
function handleUseAllAi() {
const items: FixAllSubmitItem[] = []
for (const module of moduleDataList.value) {
for (const record of module.records) {
let newContent: string | DescriptionParagraph[]
if (record.moduleType === 'summary') {
// summary 直接用润色文本
newContent = record.polishText
} else {
// 经历模块:按换行拆分为 DescriptionParagraph[]
newContent = textToDescription(record.polishText)
}
items.push({
moduleType: record.moduleType,
recordId: record.recordId,
newContent,
issueId: record.issueId,
})
}
}
emit('useAllAi', items)
emit('update:modelValue', false)
}
/** 保存当前修改 — 只提交内容有变化的模块 */
function handleSaveCurrent() {
const items: FixAllSubmitItem[] = []
for (const module of moduleDataList.value) {
for (const record of module.records) {
// 对比当前编辑内容和原始内容是否有变化
if (record.editText.trim() === record.originalText.trim()) continue
let newContent: string | DescriptionParagraph[]
if (record.moduleType === 'summary') {
newContent = record.editText
} else {
newContent = textToDescription(record.editText)
}
items.push({
moduleType: record.moduleType,
recordId: record.recordId,
newContent,
issueId: record.issueId,
})
}
}
if (!items.length) {
ElMessage.info('没有检测到内容变化')
return
}
emit('save', items)
emit('update:modelValue', false)
}
/** 关闭抽屉 */
function handleClose() {
emit('update:modelValue', false)
}
</script>
+1 -1
View File
@@ -290,7 +290,7 @@ async function handleAiPolish() {
})
try {
const res = await polishDiagnosisIssue(issueId, content)
const res = await polishDiagnosisIssue(content)
if (res.code === 0 && res.data?.content) {
polishResult.value = res.data.content
ElMessage.success('润色完成')