AI助手和Nova助手

This commit is contained in:
2026-05-06 15:07:44 +08:00
parent 1c91818494
commit f341408254
38 changed files with 4759 additions and 657 deletions
+91
View File
@@ -0,0 +1,91 @@
<template>
<!-- Agent会话岗位列表组件 聊天区域中显示推荐岗位卡片 -->
<div class="agent-chat-job-list">
<!-- 推荐说明文字 -->
<div class="agent-chat-job-list__summary">{{ summary }}</div>
<!-- 岗位列表只显示前3个 -->
<div
v-for="job in displayJobs"
:key="job.id"
class="agent-chat-job-list__item"
>
<!-- 左侧公司图标 + 岗位信息 -->
<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" />
</svg>
</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__tags">
<span v-if="job.regionName" class="agent-chat-job-list__tag">{{ job.regionName }}</span>
<span v-if="job.categoryName" class="agent-chat-job-list__tag">{{ job.categoryName }}</span>
<span v-for="tag in job.tags?.slice(0, 2)" :key="tag" class="agent-chat-job-list__tag">{{ tag }}</span>
</div>
</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>
</div>
<!-- 查看全部岗位按钮 -->
<div class="agent-chat-job-list__footer">
<button class="agent-chat-job-list__view-all-btn" @click="handleViewAll">查看全部岗位</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AgentRecommendJob } from '@/api/agent'
/** 组件 Props */
const props = defineProps<{
/** 推荐说明文字 */
summary: string
/** 完整岗位列表数据 */
jobs: AgentRecommendJob[]
}>()
/** 事件 */
const emit = defineEmits<{
/** 点击查看全部岗位 */
(e: 'viewAll'): void
}>()
/** 只显示前3个岗位 */
const displayJobs = computed(() => props.jobs.slice(0, 3))
/** 点击查看全部岗位 */
function handleViewAll() {
emit('viewAll')
}
</script>
<style scoped lang="scss">
@use '../assets/styles/components/agent-chat-job-list';
</style>
+134
View File
@@ -0,0 +1,134 @@
<template>
<!-- Agent匹配岗位添加组件 右侧面板显示全部推荐岗位 -->
<div class="agent-match-job-add" v-loading="panelLoading" element-loading-text="加载中...">
<!-- 顶部标题栏 -->
<div class="agent-match-job-add__header">
<span class="agent-match-job-add__title">匹配岗位</span>
<div class="agent-match-job-add__actions">
<!-- 全部添加按钮 -->
<button class="agent-match-job-add__add-all-btn" @click="handleAddAll">
<span>+ 全部添加</span>
</button>
<!-- 关闭按钮 -->
<button class="agent-match-job-add__close-btn" @click="emit('close')">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="#1A1A2E" />
<path d="M15 9l-6 6M9 9l6 6" stroke="#fff" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
<!-- 岗位列表 -->
<div class="agent-match-job-add__list">
<div
v-for="job in jobs"
:key="job.id"
class="agent-match-job-add__item"
>
<!-- 左侧公司图标 + 岗位信息 -->
<div class="agent-match-job-add__info">
<!-- 公司 Logo -->
<div class="agent-match-job-add__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" />
</svg>
</div>
<!-- 岗位详情 -->
<div class="agent-match-job-add__detail">
<div class="agent-match-job-add__company">{{ job.companyShortName || job.companyName }}</div>
<div class="agent-match-job-add__position">{{ job.title }}</div>
<!-- 标签 -->
<div class="agent-match-job-add__tags">
<span v-if="job.regionName" class="agent-match-job-add__tag">{{ job.regionName }}</span>
<span v-if="job.categoryName" class="agent-match-job-add__tag">{{ job.categoryName }}</span>
<span v-for="tag in job.tags?.slice(0, 2)" :key="tag" class="agent-match-job-add__tag">{{ tag }}</span>
</div>
</div>
</div>
<!-- 右侧匹配度 + 操作按钮 -->
<div class="agent-match-job-add__right">
<!-- 匹配度环形 -->
<div class="agent-match-job-add__score">
<svg class="agent-match-job-add__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-match-job-add__score-text">{{ job.matchScore || 0 }}%</span>
</div>
<!-- 添加按钮 applicationStatus === null 时显示 -->
<button
v-if="job.applicationStatus === null || job.applicationStatus === undefined"
class="agent-match-job-add__action-btn"
:disabled="loadingJobIds.includes(job.id)"
@click="handleToggle(job)"
>+ 添加</button>
<!-- 移除按钮 applicationStatus === -1 时显示 -->
<button
v-else-if="job.applicationStatus === -1"
class="agent-match-job-add__action-btn agent-match-job-add__action-btn--remove"
:disabled="loadingJobIds.includes(job.id)"
@click="handleToggle(job)"
>移出</button>
</div>
</div>
</div>
<!-- 底部查看更多 -->
<div class="agent-match-job-add__footer">
<button class="agent-match-job-add__more-btn" @click="emit('viewMore')">查看更多岗位</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { AgentRecommendJob } from '@/api/agent'
/** 组件 Props */
const props = defineProps<{
/** 完整岗位列表数据 */
jobs: AgentRecommendJob[]
/** 正在请求中的岗位 ID 列表(父组件传入,控制按钮 loading) */
loadingJobIds: number[]
/** 面板整体加载状态 */
panelLoading: boolean
}>()
/** 事件 — 组件只负责通知父组件,不直接调接口 */
const emit = defineEmits<{
/** 关闭面板 */
(e: 'close'): void
/** 查看更多岗位 */
(e: 'viewMore'): void
/** 单个岗位添加/移除操作 */
(e: 'toggle', job: AgentRecommendJob): void
/** 全部添加操作 */
(e: 'addAll'): void
}>()
/** 点击添加/移除 — 通知父组件处理 */
function handleToggle(job: AgentRecommendJob) {
emit('toggle', job)
}
/** 全部添加 — 通知父组件处理(只传 applicationStatus 为 null 的岗位) */
function handleAddAll() {
emit('addAll')
}
</script>
<style scoped lang="scss">
@use '../assets/styles/components/agent-match-job-add';
</style>
+177
View File
@@ -0,0 +1,177 @@
<template>
<!-- Agent设置面板 第4步配置求职助手 -->
<div class="agent-settings-panel">
<!-- Agent模式 -->
<div class="agent-settings-panel__group">
<div class="agent-settings-panel__label">
Agent模式
<el-tooltip content="协作模式:每次投递前需要你确认;托管模式:全自动投递无需确认" placement="top">
<span class="agent-settings-panel__tip"></span>
</el-tooltip>
</div>
<div class="agent-settings-panel__options">
<button
class="agent-settings-panel__option agent-settings-panel__option--lg"
:class="{ 'agent-settings-panel__option--active': agentMode === 1 }"
@click="agentMode = 1"
>协作模式</button>
<button
class="agent-settings-panel__option agent-settings-panel__option--lg"
:class="{ 'agent-settings-panel__option--active': agentMode === 2 }"
@click="agentMode = 2"
>托管模式</button>
</div>
</div>
<!-- 投递目标 -->
<div class="agent-settings-panel__group">
<div class="agent-settings-panel__label">
投递目标
<el-tooltip content="每周自动投递的岗位数量目标" placement="top">
<span class="agent-settings-panel__tip"></span>
</el-tooltip>
</div>
<div class="agent-settings-panel__options">
<button
v-for="goal in weeklyTargetOptions"
:key="goal.value"
class="agent-settings-panel__option agent-settings-panel__option--lg"
:class="{ 'agent-settings-panel__option--active': weeklyTarget === goal.value }"
@click="weeklyTarget = goal.value"
>{{ goal.label }}</button>
</div>
</div>
<!-- 简历设置 -->
<div class="agent-settings-panel__group">
<div class="agent-settings-panel__label">
简历设置
<el-tooltip content="选择投递时使用的默认简历" placement="top">
<span class="agent-settings-panel__tip"></span>
</el-tooltip>
</div>
<!-- 设置默认简历 -->
<div class="agent-settings-panel__sub-label">设置默认简历</div>
<div class="agent-settings-panel__resume-select">
<span class="agent-settings-panel__resume-icon">
<svg viewBox="0 0 16 16" fill="none"><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>
</span>
<el-select
v-model="selectedResumeId"
placeholder="请选择简历"
class="agent-settings-panel__select"
>
<el-option
v-for="r in resumeList"
:key="r.id"
:label="r.resumeName"
:value="r.id || ''"
/>
</el-select>
</div>
<!-- 自动优化简历开关 -->
<div class="agent-settings-panel__switch-row">
<div class="agent-settings-panel__switch-text">
<span>在投递时帮我针对岗位自动优化简历</span>
<el-tooltip content="MVP只补充缺少技能" placement="top">
<span class="agent-settings-panel__tip"></span>
</el-tooltip>
<br/>
<span class="agent-settings-panel__switch-sub">MVP只补充缺少技能</span>
</div>
<el-switch v-model="autoOptimizeSwitch" :active-color="accentColor" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { fetchResumeList } from '@/api/resume'
import type { ResumeListItem } from '@/api/resume'
import type { AgentConfig } from '@/api/agent'
/** 组件 Props — 接收初始配置 */
const props = defineProps<{
initialConfig?: AgentConfig | null
}>()
/** 强调色 — 传给 el-switch 的 active-color */
const accentColor = '#4FC2C9'
/** Agent模式:1=协作模式 2=托管模式 */
const agentMode = ref(1)
/** 投递目标选项 — value 对应接口 weeklyTarget 字段 */
const weeklyTargetOptions = [
{ label: '< 20 个/周', value: 1 },
{ label: '20-50 个/周', value: 2 },
{ label: '> 50 个/周', value: 3 },
]
/** 当前选中的投递目标 */
const weeklyTarget = ref(2)
/** 简历列表 */
const resumeList = ref<ResumeListItem[]>([])
/** 当前选中的简历 ID */
const selectedResumeId = ref('')
/** 自动优化简历开关(布尔值,用于 el-switch) */
const autoOptimizeSwitch = ref(true)
/** 自动优化简历 — 转为接口需要的 integer(0=关闭 1=开启) */
const autoOptimizeResume = computed(() => autoOptimizeSwitch.value ? 1 : 0)
/** 监听初始配置 — 填充第4步表单 */
watch(() => props.initialConfig, (cfg) => {
if (!cfg) return
if (cfg.agentMode) agentMode.value = cfg.agentMode
if (cfg.weeklyTarget) weeklyTarget.value = cfg.weeklyTarget
if (cfg.autoOptimizeResume !== undefined) autoOptimizeSwitch.value = cfg.autoOptimizeResume === 1
}, { immediate: true })
/** 获取当前设置数据 — 供父组件调用 */
function getData() {
return {
agentMode: agentMode.value,
weeklyTarget: weeklyTarget.value,
autoOptimizeResume: autoOptimizeResume.value,
defaultResumeId: selectedResumeId.value,
}
}
/** 暴露给父组件 */
defineExpose({ getData })
/** 页面挂载时加载简历列表 */
onMounted(async () => {
await loadResumeList()
})
/** 加载简历列表 */
async function loadResumeList() {
try {
const res = await fetchResumeList()
if (res.code === '0' && res.data) {
resumeList.value = res.data
// 默认选中 isDefault=1 的简历,否则选第一个
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('[AgentSettingsPanel] 加载简历列表失败')
}
}
</script>
<style scoped lang="scss">
@use '../assets/styles/components/agent-settings-panel';
</style>
+472
View File
@@ -0,0 +1,472 @@
<template>
<!-- 求职助手准备向导 第1步到第4步 -->
<div class="agent-page__wizard">
<!-- 个人资料编辑抽屉 -->
<ProfileEditDrawer
v-model="showEditDrawer"
:module="editModule"
:initial-data="editInitialData"
@save="handleSaveEdit"
/>
<!-- 顶部步骤导航条 -->
<div class="agent-page__steps">
<template v-for="(step, index) in steps" :key="index">
<div
class="agent-page__step"
:class="{ 'agent-page__step--active': currentStep === index + 1 }"
>
<span class="agent-page__step-number">{{ index + 1 }}</span>
<span class="agent-page__step-label">{{ step }}</span>
</div>
<span v-if="index < steps.length - 1" class="agent-page__step-arrow">
<svg viewBox="0 0 16 16" fill="none">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</template>
</div>
<!-- 主体内容区域 -->
<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>
</template>
<!-- ========== 第2步确认目标 ========== -->
<template v-if="currentStep === 2">
<div class="agent-page__step2">
<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>
</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>
</div>
<div v-if="matchedJobs.length > 0" class="agent-page__step2-footer"><button class="agent-page__optimize-btn" @click="loadMatchedJobs">优化我的岗位匹配</button></div>
</div>
</div>
</template>
<!-- ========== 第3步网申常见问题 + 插件安装 ========== -->
<template v-if="currentStep === 3">
<!-- 上半部分网申常见问题 -->
<div v-if="step3Sub === 1" class="agent-page__step3">
<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">
<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>
</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">
<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>
</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" />
</div>
</div>
</div>
<div v-else 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>
<button class="agent-page__complete-btn" @click="handleLaunchAgent">启用求职助手开始投递</button>
</div>
</template>
</div>
<!-- 求职目标设置弹窗 -->
<JobGoalDialog v-model="showJobGoalDialog" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useStore } from 'vuex'
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 { 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 type { AgentConfig } from '@/api/agent'
const store = useStore()
/** 组件 Props — 接收父组件传入的初始配置数据 */
const props = defineProps<{
/** 从接口查询到的初始配置,可能为空 */
initialConfig?: AgentConfig | null
}>()
/** 组件事件 — complete: 设置完成时传回完整配置数据;launch: 点击启用按钮 */
const emit = defineEmits<{
(e: 'complete', data: Record<string, any>): void
(e: 'launch'): void
}>()
// ==================== 步骤导航 ====================
const steps = ['确认个人资料', '确认目标', '开启自动申请', '配置求职助手']
const currentStep = ref(1)
/** 进入下一步 */
function handleNext() {
if (currentStep.value < steps.length) currentStep.value++
}
// ==================== 编辑抽屉状态 ====================
const showEditDrawer = ref(false)
const editModule = ref('info')
const editInitialData = ref<Record<string, any>>({})
// ==================== 个人档案数据 ====================
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()
})
async function loadProfile() {
try {
const res = await fetchProfile()
if (res.code === '0' && res.data) {
const d = res.data
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('[AgentSetupWizard] 加载个人资料失败') }
}
async function loadEducation() {
try {
const res = await fetchEducation()
if (res.code === '0' && res.data) {
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 || '' })) }))
}
} catch { console.error('[AgentSetupWizard] 加载教育经历失败') }
}
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('[AgentSetupWizard] 加载工作经历失败') }
}
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('[AgentSetupWizard] 加载实习经历失败') }
}
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('[AgentSetupWizard] 加载项目经历失败') }
}
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('[AgentSetupWizard] 加载竞赛经历失败') }
}
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 }> }>,
})
// ==================== 编辑抽屉事件处理 ====================
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 = {} }
showEditDrawer.value = true
}
/** 保存中的加载状态 */
const saving = ref(false)
/** 保存编辑数据 — 调用接口持久化 */
async function handleSaveEdit(data: Record<string, any>) {
if (editModule.value === 'info') {
try { saving.value = true; 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 }
} else if (editModule.value === 'education') {
try { saving.value = true; 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('教育经历保存成功') } catch { ElMessage.error('教育经历保存失败,请重试') } finally { saving.value = false }
} else if (editModule.value === 'work') {
try { saving.value = true; 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('工作经历保存成功') } catch { ElMessage.error('工作经历保存失败,请重试') } finally { saving.value = false }
} else if (editModule.value === 'internship') {
try { saving.value = true; 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('实习经历保存成功') } catch { ElMessage.error('实习经历保存失败,请重试') } finally { saving.value = false }
} else if (editModule.value === 'project') {
try { saving.value = true; 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('项目经历保存成功') } catch { ElMessage.error('项目经历保存失败,请重试') } finally { saving.value = false }
} else if (editModule.value === 'competition') {
try { saving.value = true; 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('竞赛经历保存成功') } catch { ElMessage.error('竞赛经历保存失败,请重试') } finally { saving.value = false }
} else if (editModule.value === 'portfolio') {
try { saving.value = true; 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('作品集保存成功') } catch { ElMessage.error('作品集保存失败,请重试') } finally { saving.value = false }
} else if (editModule.value === 'skills') {
try { saving.value = true; 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('技能保存成功') } catch { ElMessage.error('技能保存失败,请重试') } finally { saving.value = false }
} else if (editModule.value === 'certificate') {
try { saving.value = true; 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('证书保存成功') } catch { ElMessage.error('证书保存失败,请重试') } finally { saving.value = false }
}
}
// ==================== 第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(() => store.state.jobIntention.employmentType === 1 ? '实习' : '全职')
interface MatchedJobItem extends JobListItem { feedback: string }
const matchedJobs = ref<MatchedJobItem[]>([])
const loadingMatchJobs = ref(false)
const showDislikeInput = ref(false)
const dislikeReason = ref('')
watch(showJobGoalDialog, (n, o) => { if (o === true && n === false) loadMatchedJobs() })
watch(currentStep, (val) => { if (val === 2) loadMatchedJobs() })
async function loadMatchedJobs() {
loadingMatchJobs.value = true; matchedJobs.value = []
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, employmentType: intention.employmentType ?? 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: '' }))
}
} catch (e) { console.error('[AgentSetupWizard] 加载匹配岗位失败', e) }
finally { loadingMatchJobs.value = false }
}
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 }
}
// ==================== 第3步:网申常见问题 ====================
const isInternship = computed(() => store.state.jobIntention.employmentType === 1)
const step3Sub = ref(1)
const step3Form = reactive({
acceptDeptTransfer: '', acceptLocationTransfer: '',
interviewType: [] as string[],
languages: [{ language: '', proficiency: '' }] as Array<{ language: string; proficiency: string }>,
availableDate: '', internDaysPerWeek: '', internDuration: '',
})
/** 监听初始配置数据 — 填充第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步下半部分:插件安装 ====================
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.employmentType === 1 ? 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 handleLaunchAgent() { emit('launch') }
</script>
<style lang="scss">
@use '../assets/styles/pages/agent';
</style>
+182 -28
View File
@@ -25,27 +25,30 @@
</div>
</div>
<!-- 动态消息列表 -->
<div
v-for="(msg, i) in messages"
:key="i"
class="ai-chat__msg"
:class="msg.role === 'ai' ? 'ai-chat__msg--ai' : 'ai-chat__msg--user'"
>
<div class="ai-chat__msg-bubble">{{ msg.content }}</div>
</div>
<!-- 快捷问题 -->
<div class="ai-chat__quick-questions">
<!-- 快捷问题仅岗位详情页显示3个用户提问靠右 -->
<div v-if="userQuestions.length > 0" class="ai-chat__quick-questions">
<div
v-for="(q, i) in quickQuestions"
v-for="(q, i) in userQuestions"
:key="i"
class="ai-chat__quick-item"
class="ai-chat__quick-item ai-chat__quick-item--user"
@click="sendQuickQuestion(q)"
>
{{ q }}
</div>
</div>
<!-- 动态消息列表 -->
<div
v-for="(msg, i) in messages"
:key="i"
class="ai-chat__msg"
:class="msg.role === 'assistant' ? 'ai-chat__msg--ai' : 'ai-chat__msg--user'"
>
<div class="ai-chat__msg-bubble" v-html="msg.content"></div>
</div>
<!-- AI 正在思考中加载指示器 -->
<AiThinkingIndicator v-if="aiLoading" text="AI正在思考中" />
</div>
<!-- 底部输入框 -->
@@ -55,8 +58,9 @@
class="ai-chat__input"
placeholder="搜索职位、公司或关键词..."
@keyup.enter="sendMessage"
:disabled="aiLoading"
/>
<button class="ai-chat__send-btn" @click="sendMessage">
<button class="ai-chat__send-btn" @click="sendMessage" :disabled="aiLoading">
<span></span>
</button>
</div>
@@ -67,8 +71,26 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'
import MemberDialog from '@/components/MemberDialog.vue'
import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue'
import { sendNovaChat } from '@/utils/aiRequest'
import type { NovaChatHistoryItem } from '@/utils/aiRequest'
import { fetchResumeList } from '@/api/resume'
// ==================== Props ====================
/** 组件属性 */
const props = defineProps<{
/** 岗位 ID,由父页面传入 */
jobId?: string
}>()
/** 当前路由(用于判断是否在岗位详情页) */
const currentRoute = useRoute()
const store = useStore()
// ==================== 状态 ====================
@@ -81,26 +103,102 @@ const inputText = ref('')
/** 消息列表容器 DOM 引用(用于滚动控制) */
const messagesRef = ref<HTMLElement>()
/** AI 是否正在请求中 */
const aiLoading = ref(false)
/** 默认简历 ID */
const defaultResumeId = ref<string>('')
// ==================== 类型定义 ====================
/** 聊天消息类型 */
interface ChatMsg {
role: 'ai' | 'user'
/** 角色:assistant-AI助手 user-用户 */
role: 'assistant' | 'user'
/** 消息内容 */
content: string
}
// ==================== 本地缓存 ====================
/** 获取当前用户的缓存 key */
function getCacheKey(): string {
// 用 sessionStorage 中的登录状态 + 一个简单标识区分用户
// 如果有更精确的用户 ID 可以替换
const userId = sessionStorage.getItem('userId') || 'anonymous'
return `nova_chat_${userId}`
}
/** 从 localStorage 加载聊天记录 */
function loadChatFromCache(): ChatMsg[] {
try {
const key = getCacheKey()
const cached = localStorage.getItem(key)
if (cached) {
return JSON.parse(cached)
}
} catch {
// 解析失败忽略
}
return []
}
/** 保存聊天记录到 localStorage */
function saveChatToCache() {
try {
const key = getCacheKey()
localStorage.setItem(key, JSON.stringify(messages.value))
} catch {
// 存储失败忽略
}
}
// ==================== 数据 ====================
/** 聊天消息列表 */
const messages = ref<ChatMsg[]>([])
/** 快捷问题列表(点击后自动发送) */
const quickQuestions = [
'你想知道关于这个岗位的什么信息?',
'告诉我这个工作为什么适合我?',
'我想修改这个岗位,怎么优化简历?',
'帮我针对这个岗位生成一份面试攻略',
]
/** 是否在岗位详情页 */
const isJobDetailPage = computed(() => currentRoute.name === 'JobDetail' || currentRoute.path.startsWith('/jobs/'))
/** 快捷问题列表(仅岗位详情页显示的 3 个用户提问) */
const quickQuestions = computed(() => {
if (!isJobDetailPage.value) return []
return [
'告诉我这个工作为什么适合我?',
'我想修改这个岗位,怎么优化简历?',
'帮我针对这个岗位生成一份面试攻略',
]
})
/** 用户提问列表(等同于 quickQuestions */
const userQuestions = computed(() => quickQuestions.value)
// ==================== 滚动控制 ====================
/** 滚动到聊天区域底部 */
function scrollToBottom() {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}
// ==================== 加载默认简历 ID ====================
/** 加载默认简历 ID(用于 Nova 对话接口) */
async function loadDefaultResumeId() {
try {
const res = await fetchResumeList()
if (res.code === '0' && res.data && res.data.length > 0) {
const defaultResume = res.data.find(r => r.isDefault === 1)
defaultResumeId.value = defaultResume?.id || res.data[0]?.id || ''
}
} catch {
// 加载失败忽略
}
}
// ==================== 事件处理 ====================
@@ -111,10 +209,66 @@ function sendQuickQuestion(question: string) {
}
/** 发送消息(回车或点击发送按钮触发) */
function sendMessage() {
if (!inputText.value.trim()) return
messages.value.push({ role: 'user', content: inputText.value.trim() })
// TODO: 接入AI聊天接口
async function sendMessage() {
if (!inputText.value.trim() || aiLoading.value) return
const userText = inputText.value.trim()
inputText.value = ''
// 添加用户消息
messages.value.push({ role: 'user', content: userText })
saveChatToCache()
scrollToBottom()
// 构建历史对话(最近 20 条)
const history: NovaChatHistoryItem[] = messages.value
.slice(-20)
.map(m => ({ role: m.role, content: m.content }))
// 调用 Nova 对话接口
aiLoading.value = true
scrollToBottom()
try {
const res = await sendNovaChat({
message: userText,
resumeId: defaultResumeId.value || 0,
jobId: props.jobId || undefined,
history,
})
// 添加 AI 回复消息
const aiReply = res?.data?.message || res?.message || '抱歉,我暂时无法回答这个问题。'
messages.value.push({ role: 'assistant', content: aiReply })
} catch (e) {
console.error('Nova 对话请求失败', e)
messages.value.push({ role: 'assistant', content: '网络异常,请稍后重试。' })
} finally {
aiLoading.value = false
saveChatToCache()
scrollToBottom()
}
}
// ==================== 生命周期 ====================
onMounted(() => {
// 从缓存加载聊天记录
messages.value = loadChatFromCache()
if (messages.value.length > 0) {
scrollToBottom()
}
// 加载默认简历 ID
if (store.state.isAuthenticated) {
loadDefaultResumeId()
}
})
// 监听 jobId 变化(Jobs 页面点击"问助手"按钮时触发),自动插入 AI 提问消息
watch(() => props.jobId, (newId, oldId) => {
if (newId && newId !== oldId && !isJobDetailPage.value) {
// Jobs 页面:以 AI 身份插入提问到对话记录
messages.value.push({ role: 'assistant', content: '你想知道关于这个岗位的什么信息?' })
saveChatToCache()
scrollToBottom()
}
})
</script>
+23 -80
View File
@@ -286,9 +286,7 @@
</div>
</div>
<!-- AI正在回复的加载指示器 -->
<div v-if="aiLoading" class="job-resume-custom-dialog__ai-msg job-resume-custom-dialog__ai-msg--ai">
<div class="job-resume-custom-dialog__ai-msg-bubble job-resume-custom-dialog__ai-msg-bubble--loading">AI正在思考中...</div>
</div>
<AiThinkingIndicator v-if="aiLoading" text="AI正在思考中" />
</div>
<!-- AI输入框 -->
<div class="job-resume-custom-dialog__ai-input-area">
@@ -307,7 +305,7 @@
</div>
<!-- 编辑内容 折叠手风琴式编辑面板 -->
<div v-if="previewTab === 'edit'" class="job-resume-custom-dialog__preview-edit">
<JobResumeCustomEditPanel :resumeData="customResumeRawData" @update="onEditPanelUpdate" />
<JobResumeCustomEditPanel :resumeData="customResumeRawData" :jobId="jobId" @update="onEditPanelUpdate" />
</div>
</div>
</div>
@@ -318,7 +316,7 @@
<button class="job-resume-custom-dialog__primary-btn" @click="handleDrawerNext">立即定制简历</button>
</div>
<!-- 步骤四专属底部下载简历 + 立即去投递 -->
<div v-if="currentStep === 4" class="job-resume-custom-dialog__preview-footer">
<div v-if="currentStep === 4" class="job-resume-custom-dialog__preview-footer mt10 pt30">
<!-- 左侧下载简历按钮带下拉 -->
<div class="job-resume-custom-dialog__download-wrap">
<button class="job-resume-custom-dialog__download-btn" @click="toggleDownloadMenu">下载简历</button>
@@ -347,14 +345,15 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import html2pdf from 'html2pdf.js'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import JobResumeCustomEditPanel from '@/components/JobResumeCustomEditPanel.vue'
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
import { exportResumePdf, exportResumeWord } from '@/utils/resumeExport'
import { fetchResumeList } from '@/api/resume'
import type { ResumeListItem } from '@/api/resume'
import { fetchCustomizeResume, generateCustomizeResume, aiEditResume, rollbackCustomizeResume } from '@/api/jobs'
import type { CustomizeResumeData, AiEditChatMessage } from '@/api/jobs'
import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue'
// ==================== 类型定义 ====================
@@ -458,7 +457,7 @@ async function fetchAndLoadCustomResume() {
})
try {
// 第一步:查询是否已有定制简历
let queryRes = await fetchCustomizeResume()
let queryRes = await fetchCustomizeResume(props.jobId)
// if (queryRes.code === 0 && queryRes.data) {
// // 已有定制简历,直接填充数据并跳转预览
// fillCustomResumeData(queryRes.data)
@@ -480,7 +479,7 @@ async function fetchAndLoadCustomResume() {
}
// 第三步:生成成功后再次查询获取简历数据
queryRes = await fetchCustomizeResume()
queryRes = await fetchCustomizeResume(props.jobId)
if (queryRes.code === 0 && queryRes.data) {
fillCustomResumeData(queryRes.data)
currentStep.value = 4
@@ -845,7 +844,7 @@ async function sendAiMessage(text: string) {
oldResumeTemplateData.value = JSON.parse(JSON.stringify(resumeTemplateData.value))
// 重新查询定制简历数据来刷新简历预览
const queryRes = await fetchCustomizeResume()
const queryRes = await fetchCustomizeResume(props.jobId)
if (queryRes.code === 0 && queryRes.data) {
fillCustomResumeData(queryRes.data)
// 开启差异对比模式
@@ -894,14 +893,14 @@ async function confirmRollback() {
if (idx < 0) return
try {
const res = await rollbackCustomizeResume()
const res = await rollbackCustomizeResume(props.jobId)
if (res.code === 0) {
// 标记该消息为已撤销
aiMessages.value[idx].rollbackStatus = 'done'
// 关闭差异对比模式
isShowDiff.value = false
// 重新查询简历数据刷新预览
const queryRes = await fetchCustomizeResume()
const queryRes = await fetchCustomizeResume(props.jobId)
if (queryRes.code === 0 && queryRes.data) {
fillCustomResumeData(queryRes.data)
}
@@ -929,78 +928,22 @@ function toggleDownloadMenu() {
async function handleDownload(type: 'pdf' | 'word') {
showDownloadMenu.value = false
if (type === 'pdf') {
// 通过 JobResumeTemplate 组件暴露的 resumeRef 获取简历DOM
const element = resumeTemplateRef.value?.resumeRef
if (!element) {
console.error('[下载简历] 无法获取简历模板DOM')
return
}
const element = resumeTemplateRef.value?.resumeRef
if (!element) {
console.error('[下载简历] 无法获取简历模板DOM')
return
}
// html2pdf 配置选项
const options = {
margin: [10, 10, 10, 10] as [number, number, number, number],
filename: `${resumeTemplateData.value.name || '简历'}_定制简历.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' as const },
}
const fileName = (resumeTemplateData.value.name || '简历') + '_定制简历'
try {
await html2pdf().set(options).from(element).save()
} catch (err) {
console.error('[下载简历] PDF生成失败', err)
}
} else {
// 将简历HTML转为Word文档并下载(使用HTML格式的.doc文件,Word可正常打开)
const element = resumeTemplateRef.value?.resumeRef
if (!element) {
console.error('[下载简历] 无法获取简历模板DOM')
return
}
try {
// 获取页面样式表内容
const styleSheets = Array.from(document.styleSheets)
let cssText = ''
styleSheets.forEach((sheet) => {
try {
Array.from(sheet.cssRules).forEach((rule) => {
cssText += rule.cssText + '\n'
})
} catch {
// 跨域样式表无法读取,跳过
}
})
// 组装完整HTML文档(Word可识别的HTML格式)
const fullHtml = `
<html xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:w="urn:schemas-microsoft-com:office:word"
xmlns="http://www.w3.org/TR/REC-html40">
<head>
<meta charset="utf-8">
<meta name="ProgId" content="Word.Document">
<meta name="Generator" content="Microsoft Word 15">
<style>${cssText}</style>
</head>
<body>${element.outerHTML}</body>
</html>
`
// 生成Blob并触发下载
const blob = new Blob([fullHtml], { type: 'application/msword' })
const fileName = `${resumeTemplateData.value.name || '简历'}_定制简历.doc`
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
} catch (err) {
console.error('[下载简历] Word生成失败', err)
try {
if (type === 'pdf') {
await exportResumePdf(element, fileName)
} else {
exportResumeWord(element, fileName)
}
} catch (err) {
console.error('[下载简历] 导出失败', err)
}
}
+3 -1
View File
@@ -247,6 +247,8 @@ interface EditFormData {
const props = defineProps<{
/** 定制简历数据(从父组件传入) */
resumeData: CustomizeResumeData
/** 岗位ID(父组件传入,用于自动保存接口调用) */
jobId: string
}>()
/** 数据变更时通知父组件同步更新简历模板预览 */
@@ -406,7 +408,7 @@ function autoSave() {
// 通知父组件同步更新简历模板预览
emit('update', payload)
try {
await updateCustomizeResume(payload)
await updateCustomizeResume(payload,props.jobId)
} catch (e) {
console.error('[JobResumeCustomEditPanel] 自动保存失败', e)
}
+80 -8
View File
@@ -45,7 +45,18 @@
<div v-for="(edu, idx) in resumeData.educations" :key="'edu-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<div class="resume-html__item-left">
<span class="resume-html__item-main">{{ edu.school }}{{ edu.major }}{{ degreeText(edu.degree) }}</span>
<span class="resume-html__item-main">
<!-- 教育经历标题差异对比 -->
<template v-if="showDiff">
<template v-for="(seg, si) in diffText(
oldResumeData?.educations?.[idx] ? (oldResumeData.educations[idx].school + '' + oldResumeData.educations[idx].major + '' + degreeText(oldResumeData.educations[idx].degree)) : '',
edu.school + '' + edu.major + '' + degreeText(edu.degree)
)" :key="'eduh-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ edu.school }}{{ edu.major }}{{ degreeText(edu.degree) }}</template>
</span>
<span v-if="edu.description && edu.description.length" class="resume-html__item-desc">
<!-- 教育经历描述差异对比 -->
<template v-if="showDiff">
@@ -70,7 +81,18 @@
<div class="resume-html__divider"></div>
<div v-for="(work, idx) in resumeData.workExperiences" :key="'work-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<span class="resume-html__item-main">{{ work.companyName }}{{ work.position }}</span>
<span class="resume-html__item-main">
<!-- 工作经历标题差异对比 -->
<template v-if="showDiff">
<template v-for="(seg, si) in diffText(
oldResumeData?.workExperiences?.[idx] ? (oldResumeData.workExperiences[idx].companyName + '' + oldResumeData.workExperiences[idx].position) : '',
work.companyName + '' + work.position
)" :key="'workh-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ work.companyName }}{{ work.position }}</template>
</span>
<div class="resume-html__item-right">
<span class="resume-html__item-location" v-if="work.location">{{ work.location }}</span>
<span class="resume-html__item-date">{{ work.startDate }} — {{ work.endDate || '至今' }}</span>
@@ -96,7 +118,18 @@
<div class="resume-html__divider"></div>
<div v-for="(intern, idx) in resumeData.internships" :key="'intern-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<span class="resume-html__item-main">{{ intern.companyName }}{{ intern.position }}</span>
<span class="resume-html__item-main">
<!-- 实习经历标题差异对比 -->
<template v-if="showDiff">
<template v-for="(seg, si) in diffText(
oldResumeData?.internships?.[idx] ? (oldResumeData.internships[idx].companyName + '' + oldResumeData.internships[idx].position) : '',
intern.companyName + '' + intern.position
)" :key="'internh-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ intern.companyName }}{{ intern.position }}</template>
</span>
<div class="resume-html__item-right">
<span class="resume-html__item-location" v-if="intern.location">{{ intern.location }}</span>
<span class="resume-html__item-date">{{ intern.startDate }} — {{ intern.endDate || '至今' }}</span>
@@ -122,9 +155,28 @@
<div class="resume-html__divider"></div>
<div v-for="(proj, idx) in resumeData.projects" :key="'proj-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<span class="resume-html__item-main">{{ proj.projectName }}{{ proj.role ? '' + proj.role : '' }}</span>
<span class="resume-html__item-main">
<!-- 项目经历标题差异对比 -->
<template v-if="showDiff">
<template v-for="(seg, si) in diffText(
oldResumeData?.projects?.[idx] ? (oldResumeData.projects[idx].projectName + (oldResumeData.projects[idx].role ? '' + oldResumeData.projects[idx].role : '')) : '',
proj.projectName + (proj.role ? '' + proj.role : '')
)" :key="'projh-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ proj.projectName }}{{ proj.role ? '' + proj.role : '' }}</template>
</span>
<div class="resume-html__item-right">
<span class="resume-html__item-location" v-if="proj.companyName">{{ proj.companyName }}</span>
<span class="resume-html__item-location" v-if="proj.companyName">
<!-- 项目所属公司差异对比 -->
<template v-if="showDiff">
<template v-for="(seg, si) in diffText(getOldFieldText(oldResumeData?.projects, idx, 'companyName'), proj.companyName)" :key="'projc-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ proj.companyName }}</template>
</span>
<span class="resume-html__item-date">{{ proj.startDate }} — {{ proj.endDate || '至今' }}</span>
</div>
</div>
@@ -148,7 +200,18 @@
<div class="resume-html__divider"></div>
<div v-for="(comp, idx) in resumeData.competitions" :key="'comp-' + idx" class="resume-html__item">
<div class="resume-html__item-header">
<span class="resume-html__item-main">{{ comp.competitionName }}{{ comp.award ? '' + comp.award : '' }}</span>
<span class="resume-html__item-main">
<!-- 竞赛经历标题差异对比 -->
<template v-if="showDiff">
<template v-for="(seg, si) in diffText(
oldResumeData?.competitions?.[idx] ? (oldResumeData.competitions[idx].competitionName + (oldResumeData.competitions[idx].award ? '' + oldResumeData.competitions[idx].award : '')) : '',
comp.competitionName + (comp.award ? '' + comp.award : '')
)" :key="'comph-' + si">
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template>
</template>
</template>
<template v-else>{{ comp.competitionName }}{{ comp.award ? '' + comp.award : '' }}</template>
</span>
<span class="resume-html__item-date" v-if="comp.awardDate">{{ comp.awardDate }}</span>
</div>
<ul v-if="comp.description && comp.description.length" class="resume-html__desc-list">
@@ -325,14 +388,23 @@ function diffText(oldText?: string, newText?: string): DiffSegment[] {
return computeDiff(oldText, newText)
}
/**
* 从旧数据列表中取出指定索引项的某个字段值
* @param oldList 旧数据列表
* @param itemIdx 项索引
* @param field 字段名
*/
function getOldFieldText(oldList: any[] | undefined, itemIdx: number, field: string): string {
if (!oldList || !oldList[itemIdx]) return ''
return oldList[itemIdx][field] || ''
}
/**
* 对比带description数组的经历列表中某一项描述文本的差异
* 适用于工作经历、实习经历等有description数组的结构
* @param oldList 旧数据列表
* @param itemIdx 经历项索引
* @param descIdx 描述段落索引
* @param newText 新文本
* @param type 类型标识(用于区分projects和competitions等不同结构)
*/
function diffListText(oldList: any[] | undefined, itemIdx: number, descIdx: number, newText: string, _type?: string): DiffSegment[] {
if (!oldList || !oldList[itemIdx] || !oldList[itemIdx].description || !oldList[itemIdx].description[descIdx]) {
+10
View File
@@ -773,6 +773,8 @@ import RegionSelector from '@/components/tools/RegionSelector.vue'
/** 教育经历单条数据结构 — 对应数据库 bg_user_profile_education */
interface EducationItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 学校名称 */
school: string
/** 专业 */
@@ -791,6 +793,8 @@ interface EducationItem {
/** 实习经历单条数据结构 — 对应数据库 bg_user_profile_internship */
interface InternshipItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 公司名称 */
companyName: string
/** 职位 */
@@ -805,6 +809,8 @@ interface InternshipItem {
/** 工作经历单条数据结构 — 对应数据库 bg_user_profile_work */
interface WorkItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 公司名称 */
companyName: string
/** 职位 */
@@ -827,6 +833,8 @@ interface DescriptionParagraph {
/** 项目经历单条数据结构 — 对应数据库 bg_user_profile_project */
interface ProjectItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 项目名称 */
projectName: string
/** 所属公司 */
@@ -843,6 +851,8 @@ interface ProjectItem {
/** 竞赛经历单条数据结构 — 对应数据库 bg_user_profile_competition */
interface CompetitionItem {
/** 记录ID(已有经历从后端获取,新增经历无此字段) */
id?: string
/** 竞赛名称 */
competitionName: string
/** 获奖情况 */
+141
View File
@@ -0,0 +1,141 @@
<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>
<!-- 目标岗位 -->
<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__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>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { saveResumeMain } from '@/api/resume'
import { ElMessage } from 'element-plus'
// ==================== Props & Emits ====================
const props = defineProps<{
/** 控制弹窗显隐 */
modelValue: boolean
/** 简历 ID */
resumeId: string
/** 当前简历名称 */
resumeName?: string
/** 当前目标岗位 */
targetPosition?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
/** 保存成功后通知父组件刷新 */
(e: 'saved'): void
}>()
// ==================== 弹窗显隐 ====================
/** 弹窗可见状态 */
const visible = ref(false)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
// 打开时回填当前值
form.resumeName = props.resumeName || ''
form.targetPosition = props.targetPosition || ''
}
})
watch(visible, (val) => {
if (!val) emit('update:modelValue', false)
})
// ==================== 表单数据 ====================
/** 编辑表单 */
const form = reactive({
resumeName: '',
targetPosition: '',
})
/** 保存中状态 */
const saving = ref(false)
// ==================== 事件处理 ====================
/** 关闭弹窗 */
function handleClose() {
visible.value = false
}
/** 保存简历名称和目标岗位 */
async function handleSave() {
if (!form.resumeName.trim()) {
ElMessage.warning('请输入简历名称')
return
}
saving.value = true
try {
const res = await saveResumeMain({
resumeId: props.resumeId,
resumeName: form.resumeName.trim(),
targetPosition: form.targetPosition.trim(),
})
if (res.code === '0') {
ElMessage.success('保存成功')
emit('saved')
handleClose()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch {
ElMessage.error('保存失败,请稍后重试')
} finally {
saving.value = false
}
}
</script>
@@ -0,0 +1,18 @@
<template>
<!-- AI正在思考中的加载指示器 -->
<div class="ai-thinking-indicator">
<div class="ai-thinking-indicator__bubble">
{{ text }}<el-icon class="ai-thinking-indicator__icon is-loading"><Loading /></el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { Loading } from '@element-plus/icons-vue'
/** 组件属性 */
defineProps<{
/** 提示文字,默认"AI正在思考中" */
text?: string
}>()
</script>