个人资料,简历
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user