Files
offerpai_web/src/views/Resume.vue
T

411 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="resume-page dflex">
<SideNav />
<div class="resume-page__content">
<!-- 页面标题 + 上传按钮 -->
<div class="resume-page__header">
<h2 class="resume-page__title">我的简历</h2>
<div class="dflex-end">
<!-- 从个人资料创建按钮 -->
<button class="resume-page__create-btn mr16" :disabled="creatingFromProfile" @click="handleCreateFromProfile">
<svg viewBox="0 0 16 16" fill="none" class="resume-page__upload-icon">
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
从个人资料创建
</button>
<button class="resume-page__upload-btn" @click="handleUpload">
<svg viewBox="0 0 16 16" fill="none" class="resume-page__upload-icon">
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
上传简历
</button>
</div>
</div>
<!-- 简历表格 -->
<div class="resume-page__table-wrap">
<!-- 空状态提示 -->
<div v-if="resumeList.length === 0" class="resume-page__empty">
<p>暂无简历请点击上方按钮上传简历</p>
</div>
<table v-else class="resume-page__table">
<thead>
<tr>
<th class="resume-page__th">简历</th>
<th class="resume-page__th">目标岗位</th>
<th class="resume-page__th">最近修改</th>
<th class="resume-page__th resume-page__th--action"></th>
</tr>
</thead>
<tbody>
<tr
v-for="item in resumeList"
:key="item.id"
class="resume-page__row"
@click="goDetail(item.id)"
>
<td class="resume-page__td">
<div class="resume-page__name-cell">
<span class="resume-page__name">{{ item.name }}</span>
<span v-if="item.isDefault" class="resume-page__default-tag">默认</span>
</div>
</td>
<td class="resume-page__td resume-page__td--ellipsis">{{ item.targetJob }}</td>
<td class="resume-page__td">{{ item.updatedAt }}</td>
<!-- 操作按钮列 -->
<td class="resume-page__td resume-page__td--action">
<div class="resume-page__actions">
<button
class="resume-page__action-btn"
@click.stop="handleEditName(item)"
>
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0523 1.82016C11.7781 1.54295 11.4366 1.34358 11.062 1.24212C10.6875 1.14065 10.2931 1.14065 9.91851 1.24212C9.54396 1.34358 9.2024 1.54295 8.92822 1.82016L8.29348 2.46298C8.2617 2.4886 8.23269 2.51753 8.20693 2.54932L1.90565 8.92043C1.82508 9.00168 1.77008 9.10517 1.74754 9.21793L1.16589 12.1346C1.14691 12.2288 1.1513 12.3264 1.17863 12.4185C1.20598 12.5107 1.25544 12.5945 1.32261 12.6627C1.38979 12.7308 1.47258 12.7811 1.56364 12.809C1.65458 12.837 1.75127 12.8417 1.84449 12.8229L4.7343 12.2396C4.84585 12.2168 4.94823 12.1612 5.02859 12.0797L12.0523 4.97947C12.3265 4.70229 12.5237 4.35703 12.6241 3.97838C12.7245 3.59974 12.7245 3.20105 12.6241 2.8224C12.5237 2.44375 12.3265 2.09849 12.0523 1.82133" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.3353 11.1303L2.46814 11.5083L2.84319 9.62298L8.65699 3.74536L10.1491 5.25385L4.3353 11.1303ZM9.7439 2.64628C9.8742 2.51131 10.0376 2.41368 10.2174 2.36336C10.3972 2.31304 10.587 2.31183 10.7675 2.35985C10.9479 2.40787 11.1125 2.5034 11.2445 2.63669C11.3765 2.76999 11.4711 2.93629 11.5187 3.11863C11.5664 3.30096 11.5653 3.4928 11.5157 3.67459C11.466 3.85638 11.3696 4.0216 11.2362 4.15341L10.9661 4.42871L9.47384 2.91925L9.7439 2.64628Z" fill="white"/>
</svg>
<span class="fs12 color-middle ml3 color-dark-ho">修改简历信息</span>
</button>
<button
v-if="!item.isDefault"
class="resume-page__action-btn"
@click.stop="handleSetDefault(item.id)"
>设为默认</button>
<button
class="resume-page__action-btn"
@click.stop="handleExport(item)"
>导出</button>
<button
class="resume-page__action-btn resume-page__action-btn--delete"
@click.stop="handleDelete(item.id, item.isDefault)"
>删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 编辑简历名称弹窗 -->
<ResumeEditNameDialog
v-model="editNameVisible"
:resume-id="editResumeId"
:resume-name="editResumeName"
:target-position="editTargetPosition"
@saved="loadResumeList"
/>
<!-- 导出简历弹窗组件 -->
<ResumeExportDialog
v-model="exportDialogVisible"
:resume-id="exportResumeId"
:resume-name="exportResumeName"
/>
<!-- 上传简历弹窗组件 -->
<ResumeUploadDialog
v-model="uploadDialogVisible"
@confirm="handleUploadConfirm"
/>
<!-- 全屏加载遮罩 -->
<FullscreenLoading :visible="fullLoading" :text="fullLoadingText" title="Nova 正在帮您努力处理~" />
<!-- 简历解析步骤进度遮罩 -->
<StepProgressOverlay
v-if="showUploadProgress"
:title="uploadProgressTitle"
:subtitle="uploadProgressSubtitle"
:steps="uploadProgressSteps"
:durations="uploadProgressDurations"
:done="uploadProgressDone"
@finished="handleUploadProgressFinished"
@close="handleUploadProgressClose"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { EditPen } from '@element-plus/icons-vue'
import SideNav from '@/components/SideNav.vue'
import ResumeEditNameDialog from '@/components/ResumeEditNameDialog.vue'
import ResumeExportDialog from '@/components/ResumeExportDialog.vue'
import ResumeUploadDialog from '@/components/ResumeUploadDialog.vue'
import { uploadResume } from '@/utils/aiRequest'
import {
fetchResumeList, deleteResume, setDefaultResume, createResumeFromProfile, type ResumeListItem,
} from '@/api/resume'
import { ElMessage, ElMessageBox } from 'element-plus'
// 命令式调用的组件,按需引入插件不会自动加载其样式,需手动引入
import 'element-plus/es/components/message-box/style/css'
import FullscreenLoading from '@/components/FullscreenLoading.vue'
import StepProgressOverlay from '@/components/StepProgressOverlay.vue'
const router = useRouter()
// ==================== 全屏加载状态 ====================
/** 全屏加载是否显示 */
const fullLoading = ref(false)
/** 全屏加载文案 */
const fullLoadingText = ref('')
// ==================== 头像颜色池 ====================
/** 头像背景色列表,按索引循环取色 */
const avatarColors = ['#1A1A2E', '#4FC2C9', '#F2994A', '#6C5CE7', '#E17055', '#00B894', '#FDCB6E']
// ==================== 类型定义 ====================
/** 简历列表项(前端展示用) */
interface ResumeItem {
id: string
name: string
avatarLetter: string
avatarColor: string
isDefault: boolean
targetJob: string
updatedAt: string
createdAt: string
}
// ==================== 工具方法 ====================
/**
* 将毫秒时间戳转为友好的相对时间文案
* @param timestamp 后端返回的毫秒级时间戳
*/
function formatTime(timestamp?: number): string {
if (!timestamp) return '-'
const date = new Date(timestamp)
const now = Date.now()
const diff = now - date.getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}天前`
const months = Math.floor(days / 30)
if (months < 12) return `${months}个月前`
return `${Math.floor(months / 12)}年前`
}
/**
* 将后端简历数据转为前端展示结构
*/
function mapResumeItem(item: ResumeListItem, index: number): ResumeItem {
const name = item.resumeName || '未命名简历'
return {
id: String(item.id ?? ''),
name,
avatarLetter: name.charAt(0) || '?',
avatarColor: avatarColors[index % avatarColors.length],
isDefault: item.isDefault === 1,
targetJob: item.targetPosition || '',
updatedAt: formatTime(item.updateTime),
createdAt: formatTime(item.createTime),
}
}
// ==================== 数据 ====================
/** 简历列表 */
const resumeList = ref<ResumeItem[]>([])
/** 加载简历列表 */
async function loadResumeList() {
try {
const res = await fetchResumeList()
if (res.code === '0' && res.data) {
resumeList.value = res.data.map(mapResumeItem)
}
} catch (e) {
console.error('获取简历列表失败', e)
}
}
// ==================== 生命周期 ====================
onMounted(() => {
loadResumeList()
})
// ==================== 从个人资料创建简历 ====================
/** 防抖锁:防止多次点击 */
const creatingFromProfile = ref(false)
/**
* 从个人资料创建简历(带防抖)
*/
async function handleCreateFromProfile() {
if (creatingFromProfile.value) return
creatingFromProfile.value = true
try {
const res = await createResumeFromProfile()
if (res.code === '0') {
ElMessage.success('创建成功')
await loadResumeList()
}
} catch (e) {
console.error('从个人资料创建简历失败', e)
} finally {
creatingFromProfile.value = false
}
}
// ==================== 页面状态 ====================
// ==================== 编辑简历名称弹窗状态 ====================
/** 编辑弹窗是否可见 */
const editNameVisible = ref(false)
/** 当前编辑的简历 ID */
const editResumeId = ref('')
/** 当前编辑的简历名称 */
const editResumeName = ref('')
/** 当前编辑的目标岗位 */
const editTargetPosition = ref('')
// ==================== 导出简历弹窗状态 ====================
/** 导出弹窗是否可见 */
const exportDialogVisible = ref(false)
/** 当前导出的简历 ID */
const exportResumeId = ref('')
/** 当前导出的简历名称(用于文件名) */
const exportResumeName = ref('')
// ==================== 上传简历弹窗状态 ====================
/** 上传弹窗是否可见 */
const uploadDialogVisible = ref(false)
// ==================== 事件处理 ====================
/** 点击编辑图标 — 打开编辑名称岗位弹窗 */
function handleEditName(item: ResumeItem) {
editResumeId.value = item.id
editResumeName.value = item.name
editTargetPosition.value = item.targetJob
editNameVisible.value = true
}
/** 设为默认简历 */
async function handleSetDefault(id: string) {
try {
const res = await setDefaultResume(id)
if (res.code === '0') {
ElMessage.success('已设为默认简历')
loadResumeList()
} else {
ElMessage.error(res.msg || '设置失败')
}
} catch {
ElMessage.error('设置失败,请稍后重试')
}
}
/** 导出简历 */
function handleExport(item: ResumeItem) {
exportResumeId.value = item.id
exportResumeName.value = item.name || '简历'
exportDialogVisible.value = true
}
/** 删除简历 */
async function handleDelete(id: string, isDefault: boolean) {
try {
await ElMessageBox.confirm('确定要删除这份简历吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
if(resumeList.value.length<=1||isDefault){
ElMessage.warning('默认简历不可删除')
return
}
const res = await deleteResume(id)
if (res.code === '0') {
ElMessage.success('删除成功')
loadResumeList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败,请稍后重试')
}
}
}
/** 上传简历 — 打开上传弹窗 */
function handleUpload() {
uploadDialogVisible.value = true
}
/** 上传弹窗确认回调 — 接收 File 对象,调用接口上传 */
async function handleUploadConfirm(file: File) {
// 显示步骤进度遮罩
showUploadProgress.value = true
uploadProgressDone.value = false
pendingResumeId.value = ''
try {
const res = await uploadResume(file)
if (res.code === 0) {
// 上传成功,刷新列表
loadResumeList()
// 缓存简历ID,等进度走完后跳转
pendingResumeId.value = res.data.resumeId
// 通知进度组件接口已完成
uploadProgressDone.value = true
} else {
showUploadProgress.value = false
ElMessage.error(res.msg || '上传失败')
}
} catch {
showUploadProgress.value = false
ElMessage.error('上传失败,请稍后重试')
}
}
// ==================== 简历解析步骤进度遮罩 ====================
/** 解析步骤进度遮罩是否显示 */
const showUploadProgress = ref(false)
/** 解析接口是否已完成 */
const uploadProgressDone = ref(false)
/** 大标题 */
const uploadProgressTitle = '正在解析简历'
/** 副标题 */
const uploadProgressSubtitle = '正在识别教育背景、工作经历与技能关键词'
/** 4个步骤名称 */
const uploadProgressSteps = ['教育背景解析...', '工作经历解析...', '技能特长解析...', '个人资料最终确认']
/** 每步时长4600毫秒 */
const uploadProgressDurations = [4200, 4200, 4200, 4200]
/** 缓存的简历ID(等进度走完后跳转) */
const pendingResumeId = ref('')
/** 解析进度走完后跳转详情页 */
function handleUploadProgressFinished() {
showUploadProgress.value = false
if (pendingResumeId.value) {
goDetail(pendingResumeId.value)
pendingResumeId.value = ''
}
}
/** 关闭解析进度遮罩 */
function handleUploadProgressClose() {
showUploadProgress.value = false
}
/** 跳转到简历详情页 */
function goDetail(id: string) {
router.push(`/resume/${id}`)
}
</script>