个人资料,简历

This commit is contained in:
2026-07-08 18:58:27 +08:00
parent 851310a901
commit c94a96d14e
19 changed files with 1142 additions and 183 deletions
+58 -12
View File
@@ -345,6 +345,17 @@
</div>
<!-- 全屏加载遮罩 -->
<FullscreenLoading :visible="fullLoading" :text="fullLoadingText" title="Nova 正在帮您努力处理~" />
<!-- 简历定制步骤进度遮罩 -->
<StepProgressOverlay
v-if="showCustomStepProgress"
:title="customStepTitle"
:subtitle="customStepSubtitle"
:steps="customStepNames"
:durations="customStepDurations"
:done="customStepDone"
@finished="handleCustomStepFinished"
@close="handleCustomStepClose"
/>
</template>
<script setup lang="ts">
@@ -359,6 +370,7 @@ import { fetchCustomizeResume, generateCustomizeResume, aiEditResume, rollbackCu
import type { CustomizeResumeData, AiEditChatMessage } from '@/api/jobs'
import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue'
import FullscreenLoading from '@/components/FullscreenLoading.vue'
import StepProgressOverlay from '@/components/StepProgressOverlay.vue'
// ==================== 类型定义 ====================
@@ -428,6 +440,38 @@ const fullLoading = ref(false)
/** 全屏加载文案 */
const fullLoadingText = ref('')
// ==================== 简历定制步骤进度遮罩 ====================
/** 步骤进度遮罩是否显示 */
const showCustomStepProgress = ref(false)
/** 接口是否已完成 */
const customStepDone = ref(false)
/** 大标题 */
const customStepTitle = '简历定制中,请耐心等待'
/** 副标题 */
const customStepSubtitle = '根据STAR法则,快速定制补齐你的简历缺失部分'
/** 4个步骤名称 */
const customStepNames = ['个人概述优化...', '岗位技能优化...', '工作经验优化...', '最终简历生成中...']
/** 每步时长 */
const customStepDurations = [2000, 2000, 2000, 2000]
/** 缓存的定制简历数据(等进度走完后填充) */
const pendingCustomResumeData = ref<any>(null)
/** 步骤进度走完后填充数据并跳转预览 */
function handleCustomStepFinished() {
showCustomStepProgress.value = false
if (pendingCustomResumeData.value) {
fillCustomResumeData(pendingCustomResumeData.value)
currentStep.value = 4
pendingCustomResumeData.value = null
}
}
/** 关闭步骤进度遮罩 */
function handleCustomStepClose() {
showCustomStepProgress.value = false
}
/** 当前步骤:2-差距分析(右侧抽屉) 3-定制简历 4-预览 */
const currentStep = ref(2)
@@ -504,17 +548,15 @@ async function handleDrawerNext() {
* 流程:先 GET 查询 → 有数据直接用 → 无数据则 POST 生成 → 再 GET 查询
*/
async function fetchAndLoadCustomResume() {
fullLoadingText.value = '正在生成定制简历...'
fullLoading.value = true
// 显示步骤进度遮罩
showCustomStepProgress.value = true
customStepDone.value = false
// 缓存数据用于进度完成后填充
let resumeData: CustomizeResumeData | null = null
try {
// 第一步:查询是否已有定制简历
let queryRes = await fetchCustomizeResume(props.jobId)
// if (queryRes.code === 0 && queryRes.data) {
// // 已有定制简历,直接填充数据并跳转预览
// fillCustomResumeData(queryRes.data)
// currentStep.value = 4
// return
// }
// 第二步:没有定制简历,调用生成接口
const genRes = await generateCustomizeResume({
@@ -525,6 +567,7 @@ async function fetchAndLoadCustomResume() {
})
if (genRes.code !== 0 || !genRes.data?.success) {
showCustomStepProgress.value = false
ElMessage.error('生成定制简历失败,请稍后重试')
return
}
@@ -532,16 +575,19 @@ async function fetchAndLoadCustomResume() {
// 第三步:生成成功后再次查询获取简历数据
queryRes = await fetchCustomizeResume(props.jobId)
if (queryRes.code === 0 && queryRes.data) {
fillCustomResumeData(queryRes.data)
currentStep.value = 4
resumeData = queryRes.data
// 通知步骤进度组件接口已完成
customStepDone.value = true
// 缓存数据,等进度走完后再填充
pendingCustomResumeData.value = resumeData
} else {
showCustomStepProgress.value = false
ElMessage.error('获取定制简历数据失败')
}
} catch (e) {
console.error('[JobResumeCustomDialog] 定制简历流程失败', e)
showCustomStepProgress.value = false
ElMessage.error('定制简历失败,请稍后重试')
} finally {
fullLoading.value = false
}
}
+7 -26
View File
@@ -52,10 +52,10 @@
<div class="member-dialog__plan-price">
<span class="member-dialog__plan-price-symbol">¥</span>
<span class="member-dialog__plan-price-num">{{ plan.priceInt }}</span>
<span v-if="plan.originalPrice" class="member-dialog__plan-original-price">¥{{ plan.originalPrice }}</span>
<span v-if="plan.originalPrice" class="member-dialog__plan-original-price">{{ plan.originalPrice }}</span>
</div>
<!-- 每日价格 -->
<div class="member-dialog__plan-daily">{{ plan.dailyDesc }}</div>
<!-- 标签描述接口tag字段 -->
<div class="member-dialog__plan-daily mt4">{{ plan.tag }}</div>
</div>
</div>
@@ -211,8 +211,8 @@ interface PlanItem {
priceInt: string
/** 划线价(元) */
originalPrice: string
/** 每日价格描述 */
dailyDesc: string
/** 标签描述(接口tag字段,如"轻量体验"、"低至¥0.56/天" */
tag: string
/** 是否推荐 */
recommend?: boolean
/** 后端原始数据,下单时使用 */
@@ -269,7 +269,7 @@ watch(() => props.modelValue, (val) => {
/** 当前选中的套餐对象 */
const currentPlan = computed(() => {
return plans.value.find(p => p.key === selectedPlan.value) || plans.value[0] || { priceInt: '0', name: '', key: '', originalPrice: '', dailyDesc: '', raw: {} as MemberProduct }
return plans.value.find(p => p.key === selectedPlan.value) || plans.value[0] || { priceInt: '0', name: '', key: '', originalPrice: '', tag: '', raw: {} as MemberProduct }
})
// ==================== 数据 ====================
@@ -284,25 +284,6 @@ function mapProductToPlan(product: MemberProduct): PlanItem {
const originalPriceYuan = product.originalPrice ? product.originalPrice / 100 : 0
// 直接使用接口返回的 productName 作为套餐名称
const name = product.productName
// 根据天数计算每日价格描述
let dailyDesc = '轻量体验'
if (product.durationDays <= 7) {
dailyDesc = '轻量体验'
} else if (product.durationDays <= 31) {
// 月卡除以30天,四舍五入到小数点后2位
const dailyPrice = (priceYuan / 30).toFixed(2)
dailyDesc = `低至 ¥${dailyPrice} /天`
} else if (product.durationDays <= 93) {
// 季卡除以90天,四舍五入到小数点后2位
const dailyPrice = (priceYuan / 90).toFixed(2)
dailyDesc = `低至 ¥${dailyPrice} /天`
} else if (product.durationDays <= 186) {
const dailyPrice = (priceYuan / 180).toFixed(2)
dailyDesc = `低至 ¥${dailyPrice} /天`
} else {
const dailyPrice = (priceYuan / 365).toFixed(2)
dailyDesc = `低至 ¥${dailyPrice} /天`
}
// 价格显示:如果是整数就不显示小数,否则保留实际小数
const priceDisplay = Number.isInteger(priceYuan) ? String(priceYuan) : priceYuan.toFixed(2)
@@ -315,7 +296,7 @@ function mapProductToPlan(product: MemberProduct): PlanItem {
name,
priceInt: priceDisplay,
originalPrice: originalDisplay,
dailyDesc,
tag: product.tag || '',
recommend: product.isFeatured === 1,
raw: product,
}
+3 -3
View File
@@ -16,13 +16,13 @@
<!-- 个人信息 -->
<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>
<div class="profile-page-content__card-header pl0 mb-20">
<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 dflex-start aliite-c"><span>{{ profile.name }}</span><span class="fs13 color-6 ml10 dflex-start aliite-c"><svg viewBox="0 0 16 16" fill="none" class="resume-detail__contact-icon">
<div class="profile-page-content__info-name dflex-start aliite-e"><span class="fs28">{{ profile.name }}</span><span class="fs13 color-6 ml10 dflex-start aliite-c pb5"><svg viewBox="0 0 16 16" fill="none" class="resume-detail__contact-icon">
<circle cx="8" cy="6.5" r="2.5" stroke="currentColor" stroke-width="1.2"/>
<path d="M8 14s-5-4-5-7.5a5 5 0 0110 0C13 10 8 14 8 14z" stroke="currentColor" stroke-width="1.2"/>
</svg> {{ resolveRegionName(profile.regionCode) }}</span></div>
+23 -7
View File
@@ -61,13 +61,14 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useStore } from 'vuex'
import { ElMessage } from 'element-plus'
import MemberDialog from './MemberDialog.vue'
import { fetchInviteStats } from '@/api/member'
/** 组件 Props */
defineProps<{ modelValue: boolean }>()
const props = defineProps<{ modelValue: boolean }>()
/** 组件 Emits */
defineEmits<{
@@ -79,17 +80,32 @@ const store = useStore()
/** 会员弹窗显示状态 */
const showMemberDialog = ref(false)
/** 累计获得会员天数(暂用模拟数据) */
const totalDays = ref(7)
/** 累计获得会员天数 */
const totalDays = ref(0)
/** 累计邀请好友数(暂用模拟数据) */
const totalFriends = ref(1)
/** 累计邀请好友数 */
const totalFriends = ref(0)
/**
* 弹窗打开时请求邀请统计接口
*/
watch(() => props.modelValue, async (val) => {
if (val) {
try {
const res = await fetchInviteStats()
totalFriends.value = res.data?.inviteCount ?? 0
totalDays.value = res.data?.rewardDays ?? 0
} catch (e) {
console.error('获取邀请统计失败', e)
}
}
})
/** 用户邀请码 — 从全局 store 读取 */
const inviteCode = computed(() => store.state.userInfo?.inviteCode || 'NOVA2026')
/** 邀请链接地址 */
const inviteUrl = computed(() => `https://offerpai.com/invite?code=${inviteCode.value}`)
const inviteUrl = computed(() => `https://www.offerpai.com.cn/invite?code=${inviteCode.value}`)
/** 复制链接到剪贴板 */
async function handleCopy() {
+34 -4
View File
@@ -42,14 +42,14 @@
<!-- 邀请进度行 -->
<div class="side-nav__referral-row">
<span class="side-nav__referral-label">邀请进度</span>
<span class="side-nav__referral-value">12/30</span>
<span class="side-nav__referral-value">{{ milestoneProgress }}/{{ milestoneTarget }}</span>
</div>
<!-- 进度条 -->
<div class="side-nav__referral-track">
<div class="side-nav__referral-fill" style="width: 40%"></div>
<div class="side-nav__referral-fill" :style="{ width: inviteProgressPercent + '%' }"></div>
</div>
<!-- 提示文字 -->
<div class="side-nav__referral-tip">再邀请 <strong>18</strong> 可获得年会员</div>
<div class="side-nav__referral-tip">再邀请 <strong>{{ inviteRemaining }}</strong> 可获得年会员</div>
<!-- 分隔线 -->
<div class="side-nav__referral-divider"></div>
<!-- 去邀请按钮 -->
@@ -78,7 +78,7 @@
</div>
<!-- 用户信息卡片点击弹出/关闭账户弹窗 -->
<div class="side-nav__user-info" @click.stop="showAccountPopup = !showAccountPopup">
<div class="side-nav__user-info" :class="{ 'side-nav__user-info--active': showAccountPopup }" @click.stop="showAccountPopup = !showAccountPopup">
<div class="side-nav__user-left">
<!-- 手机号脱敏显示 -->
<div class="side-nav__user-phone">{{ maskedPhone }}</div>
@@ -230,6 +230,7 @@ import MemberDialog from '@/components/MemberDialog.vue'
import { checkLogin } from '@/api/auth'
import { logout } from '@/api/auth'
import { fetchMessageList, fetchUnreadCount, markMessageRead } from '@/api/message'
import { fetchInviteStats } from '@/api/member'
import { timestampToLocalDateTime } from '@/utils/time'
/**
@@ -368,6 +369,34 @@ import SettingsInviteDialog from "@/components/SettingsInviteDialog.vue";
// ==================== 站内信相关 ====================
/** 未读消息数量 */
const unreadCount = ref(0)
// ==================== 邀请统计相关 ====================
/** 当前周期已邀请人数 */
const milestoneProgress = ref(0)
/** 里程碑目标总人数(从接口获取) */
const milestoneTarget = ref(30)
/** 进度条百分比 */
const inviteProgressPercent = computed(() => {
if (milestoneTarget.value <= 0) return 0
return Math.min(Math.round((milestoneProgress.value / milestoneTarget.value) * 100), 100)
})
/** 还需邀请人数 */
const inviteRemaining = computed(() => {
return Math.max(milestoneTarget.value - milestoneProgress.value, 0)
})
/**
* 获取邀请统计数据
*/
async function loadInviteStats() {
try {
const res = await fetchInviteStats()
milestoneProgress.value = res.data?.milestoneProgress ?? 0
milestoneTarget.value = res.data?.milestoneTarget ?? 30
} catch (e) {
console.error('获取邀请统计失败', e)
}
}
/** 消息列表数据 */
const messageList = ref<MessageDto[]>([])
/** 当前选中的消息索引 */
@@ -650,6 +679,7 @@ function handleGlobalClick() {
onMounted(() => {
loadUnreadCount()
loadInviteStats()
document.addEventListener('click', handleGlobalClick)
})
+229
View File
@@ -0,0 +1,229 @@
<template>
<!-- 步骤进度加载遮罩组件 居中面板 + 半透明遮罩 -->
<div class="step-progress-overlay">
<!-- 半透明遮罩背景 -->
<div class="step-progress-overlay__mask"></div>
<!-- 内容面板 -->
<div class="step-progress-overlay__panel">
<!-- 顶部标题行 -->
<div class="step-progress-overlay__header">
<div class="step-progress-overlay__titles">
<h2 class="step-progress-overlay__title">{{ title }}</h2>
<p class="step-progress-overlay__subtitle">{{ subtitle }}</p>
</div>
<!-- 关闭按钮 -->
<button class="step-progress-overlay__close" @click="handleClose" aria-label="关闭">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L13 13M13 1L1 13" stroke="#6A7282" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- 进度面板 -->
<div class="step-progress-overlay__progress-box">
<!-- 进度条头部 -->
<div class="step-progress-overlay__progress-header">
<span class="step-progress-overlay__progress-label">处理进度</span>
<span class="step-progress-overlay__progress-percent">{{ percent }}%</span>
</div>
<!-- 进度轨道 -->
<div class="step-progress-overlay__progress-rail">
<div class="step-progress-overlay__progress-fill" :style="{ width: percent + '%' }"></div>
</div>
<!-- 步骤列表 -->
<div class="step-progress-overlay__steps">
<div v-for="(item, idx) in stepList" :key="idx" class="step-progress-overlay__step-item">
<!-- 左侧圆点 + 连接线 -->
<div class="step-progress-overlay__step-left">
<!-- 完成状态对勾圆 -->
<div v-if="item.status === 'done'" class="step-progress-overlay__dot step-progress-overlay__dot--done">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 5.2L4.2 6.8L7.5 3.5" stroke="#fff" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- 进行中光晕 + 呼吸内圆 -->
<div v-else-if="item.status === 'active'" class="step-progress-overlay__dot step-progress-overlay__dot--active">
<div class="step-progress-overlay__dot-pulse"></div>
</div>
<!-- 等待状态浅色圆 -->
<div v-else class="step-progress-overlay__dot step-progress-overlay__dot--pending"></div>
<!-- 连接线最后一项不显示 -->
<div v-if="idx < stepList.length - 1" class="step-progress-overlay__connector"></div>
</div>
<!-- 步骤名称 -->
<span class="step-progress-overlay__step-text" :class="{ 'step-progress-overlay__step-text--pending': item.status === 'pending' }">{{ item.label }}</span>
<!-- 步骤状态文字 -->
<span class="step-progress-overlay__step-state" :class="{
'step-progress-overlay__step-state--done': item.status === 'done',
'step-progress-overlay__step-state--active': item.status === 'active',
'step-progress-overlay__step-state--pending': item.status === 'pending'
}">{{ item.stateText }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
// ==================== Props ====================
interface Props {
/** 大标题 */
title: string
/** 副标题 */
subtitle: string
/** 4个步骤名称 */
steps: string[]
/** 4个步骤对应的模拟时长(毫秒) */
durations: number[]
/** 接口是否已完成,父组件设为 true 后走完剩余进度 */
done: boolean
}
const props = withDefaults(defineProps<Props>(), {
done: false,
})
// ==================== Emits ====================
const emit = defineEmits<{
/** 进度走完100%后通知父组件 */
(e: 'finished'): void
/** 点击关闭按钮 */
(e: 'close'): void
}>()
// ==================== 内部状态 ====================
/** 步骤项接口 */
interface StepItem {
label: string
status: 'pending' | 'active' | 'done'
stateText: string
}
/** 当前进度百分比 */
const percent = ref(0)
/** 步骤列表 */
const stepList = ref<StepItem[]>(
props.steps.map((label) => ({ label, status: 'pending' as const, stateText: '等待' }))
)
/** 进度动画定时器 */
let progressTimer: ReturnType<typeof setInterval> | null = null
/** 标记接口是否已返回 */
let uploadDone = false
/** 标记动画是否已完成(防止重复 emit) */
let hasFinished = false
// ==================== 核心动画逻辑 ====================
/** 每个步骤对应的进度百分比段 */
function getStepPercents(): number[] {
const total = props.steps.length
// 均分到95%,如4步则为 [23, 47, 71, 95]
return props.steps.map((_, i) => Math.floor(((i + 1) / total) * 95))
}
/** 开始步骤动画 */
function startAnimation() {
const totalSteps = stepList.value.length
const stepPercents = getStepPercents()
let currentStep = 0
// 第一个步骤立即进入进行中
stepList.value[0].status = 'active'
stepList.value[0].stateText = '进行中'
let targetPercent = stepPercents[0]
let lastStepTime = Date.now()
progressTimer = setInterval(() => {
const elapsed = Date.now() - lastStepTime
const stepDuration = props.durations[currentStep] || 2000
if (elapsed >= stepDuration && currentStep < totalSteps - 1) {
// 当前步骤完成
stepList.value[currentStep].status = 'done'
stepList.value[currentStep].stateText = '完成'
currentStep++
// 下一个步骤进入进行中
stepList.value[currentStep].status = 'active'
stepList.value[currentStep].stateText = '进行中'
targetPercent = stepPercents[currentStep]
lastStepTime = Date.now()
} else if (currentStep >= totalSteps - 1 && elapsed >= stepDuration) {
// 最后一个步骤时间到
if (uploadDone) {
// 接口已返回,完成最后一步
stepList.value[currentStep].status = 'done'
stepList.value[currentStep].stateText = '完成'
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
// 进度条跳到95-99再走完到100
const finalPercent = Math.floor(95 + Math.random() * 4)
percent.value = finalPercent
setTimeout(() => {
percent.value = 100
setTimeout(() => {
if (!hasFinished) { hasFinished = true; emit('finished') }
}, 600)
}, 1000)
return
}
// 接口未返回,缓慢爬进度,上限94%
percent.value = Math.min(percent.value + 0.1, 94)
return
}
// 平滑增长进度
const progress = Math.min(elapsed / stepDuration, 1)
const prevPercent = currentStep > 0 ? stepPercents[currentStep - 1] : 0
percent.value = Math.floor(prevPercent + (targetPercent - prevPercent) * progress)
}, 50)
}
/** 接口返回后完成动画(定时器已停时手动触发) */
function finishAnimation() {
if (!progressTimer) {
const lastIdx = stepList.value.length - 1
stepList.value[lastIdx].status = 'done'
stepList.value[lastIdx].stateText = '完成'
const finalPercent = Math.floor(95 + Math.random() * 4)
percent.value = finalPercent
setTimeout(() => {
percent.value = 100
setTimeout(() => {
if (!hasFinished) { hasFinished = true; emit('finished') }
}, 600)
}, 1000)
}
}
/** 点击关闭按钮 */
function handleClose() {
emit('close')
}
// ==================== 监听 done prop ====================
watch(() => props.done, (val) => {
if (val) {
uploadDone = true
finishAnimation()
}
})
// ==================== 生命周期 ====================
// 组件挂载后立即开始动画
startAnimation()
onBeforeUnmount(() => {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
})
</script>