简历优化和岗位简历
@@ -31,12 +31,15 @@ declare module 'vue' {
|
|||||||
JobGoalDialog: typeof import('./src/components/JobGoalDialog.vue')['default']
|
JobGoalDialog: typeof import('./src/components/JobGoalDialog.vue')['default']
|
||||||
JobPageHeader: typeof import('./src/components/JobPageHeader.vue')['default']
|
JobPageHeader: typeof import('./src/components/JobPageHeader.vue')['default']
|
||||||
JobResumeCustomDialog: typeof import('./src/components/JobResumeCustomDialog.vue')['default']
|
JobResumeCustomDialog: typeof import('./src/components/JobResumeCustomDialog.vue')['default']
|
||||||
|
JobResumeCustomEditPanel: typeof import('./src/components/JobResumeCustomEditPanel.vue')['default']
|
||||||
JobResumeTemplate: typeof import('./src/components/JobResumeTemplate.vue')['default']
|
JobResumeTemplate: typeof import('./src/components/JobResumeTemplate.vue')['default']
|
||||||
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
|
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
|
||||||
MemberDialog: typeof import('./src/components/MemberDialog.vue')['default']
|
MemberDialog: typeof import('./src/components/MemberDialog.vue')['default']
|
||||||
ProfileEditDrawer: typeof import('./src/components/ProfileEditDrawer.vue')['default']
|
ProfileEditDrawer: typeof import('./src/components/ProfileEditDrawer.vue')['default']
|
||||||
ProfilePageContent: typeof import('./src/components/ProfilePageContent.vue')['default']
|
ProfilePageContent: typeof import('./src/components/ProfilePageContent.vue')['default']
|
||||||
RegionSelector: typeof import('./src/components/tools/RegionSelector.vue')['default']
|
RegionSelector: typeof import('./src/components/tools/RegionSelector.vue')['default']
|
||||||
|
ResumeAnalysisReportDrawer: typeof import('./src/components/ResumeAnalysisReportDrawer.vue')['default']
|
||||||
|
ResumeIssueFixDrawer: typeof import('./src/components/ResumeIssueFixDrawer.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SettingsDialog: typeof import('./src/components/SettingsDialog.vue')['default']
|
SettingsDialog: typeof import('./src/components/SettingsDialog.vue')['default']
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
import aiService from '@/utils/aiRequest'
|
||||||
import type { ApiResult } from '@/api/auth'
|
import type { ApiResult } from '@/api/auth'
|
||||||
|
import type { AiResult } from '@/utils/aiRequest'
|
||||||
|
|
||||||
// ==================== 匹配详情 ====================
|
// ==================== 匹配详情 ====================
|
||||||
|
|
||||||
@@ -363,3 +365,254 @@ export function fetchJobDetail(jobId: string) {
|
|||||||
params: { jobId },
|
params: { jobId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 技能差距分析(AI 接口) ====================
|
||||||
|
|
||||||
|
/** 技能差距分析 — 岗位信息 */
|
||||||
|
export interface SkillGapJob {
|
||||||
|
/** 岗位 ID */
|
||||||
|
jobId: string
|
||||||
|
/** 岗位标题 */
|
||||||
|
title: string
|
||||||
|
/** 技能标签列表 */
|
||||||
|
skillTags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 技能差距分析 — 简历信息 */
|
||||||
|
export interface SkillGapResume {
|
||||||
|
/** 简历 ID */
|
||||||
|
resumeId: string
|
||||||
|
/** 简历名称 */
|
||||||
|
resumeName: string
|
||||||
|
/** 目标岗位 */
|
||||||
|
targetPosition: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 技能差距分析返回数据 */
|
||||||
|
export interface SkillGapData {
|
||||||
|
/** 匹配度分数 */
|
||||||
|
score: number
|
||||||
|
/** 岗位信息 */
|
||||||
|
job: SkillGapJob
|
||||||
|
/** 简历信息 */
|
||||||
|
resume: SkillGapResume
|
||||||
|
/** 缺少的技能列表 */
|
||||||
|
missingSkills: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 技能差距分析(AI 接口)
|
||||||
|
* POST /job/skill-gap
|
||||||
|
* @param jobId 岗位 ID(字符串,避免大整数精度丢失)
|
||||||
|
*/
|
||||||
|
export function fetchSkillGap(jobId: string) {
|
||||||
|
// jobId 作为字符串发送,避免 JS 大整数精度丢失
|
||||||
|
return aiService.post<any, { data: AiResult<SkillGapData> }>('/job/skill-gap', { jobId }, {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 定制简历(AI 接口) ====================
|
||||||
|
|
||||||
|
/** 定制简历接口返回的简历数据 */
|
||||||
|
export interface CustomizeResumeData {
|
||||||
|
/** 简历基本信息 */
|
||||||
|
resume: {
|
||||||
|
avatarUrl?: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
mobileNumber?: string
|
||||||
|
city?: string
|
||||||
|
wechatNumber?: string
|
||||||
|
portfolioUrl?: string
|
||||||
|
skills?: string[]
|
||||||
|
certificates?: string[]
|
||||||
|
summary?: string
|
||||||
|
}
|
||||||
|
/** 教育经历 */
|
||||||
|
education?: Array<{
|
||||||
|
id?: string
|
||||||
|
school?: string
|
||||||
|
major?: string
|
||||||
|
degree?: string
|
||||||
|
studyType?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
description?: Array<{ id?: string; text?: string }>
|
||||||
|
}>
|
||||||
|
/** 工作经历 */
|
||||||
|
work?: Array<{
|
||||||
|
id?: string
|
||||||
|
companyName?: string
|
||||||
|
position?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
description?: Array<{ id?: string; text?: string }>
|
||||||
|
}>
|
||||||
|
/** 实习经历 */
|
||||||
|
internship?: Array<{
|
||||||
|
id?: string
|
||||||
|
companyName?: string
|
||||||
|
position?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
description?: Array<{ id?: string; text?: string }>
|
||||||
|
}>
|
||||||
|
/** 项目经历 */
|
||||||
|
project?: Array<{
|
||||||
|
id?: string
|
||||||
|
projectName?: string
|
||||||
|
companyName?: string
|
||||||
|
role?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
description?: Array<{ id?: string; text?: string }>
|
||||||
|
}>
|
||||||
|
/** 竞赛经历 */
|
||||||
|
competition?: Array<{
|
||||||
|
id?: string
|
||||||
|
competitionName?: string
|
||||||
|
award?: string
|
||||||
|
awardDate?: string
|
||||||
|
description?: Array<{ id?: string; text?: string }>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询定制简历结果(AI 接口)
|
||||||
|
* GET /job/customize-resume
|
||||||
|
*/
|
||||||
|
export function fetchCustomizeResume() {
|
||||||
|
return aiService.get<any, { data: AiResult<CustomizeResumeData | null> }>('/job/customize-resume', {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成定制简历请求参数 */
|
||||||
|
export interface GenerateCustomizeResumeParams {
|
||||||
|
/** 岗位 ID(字符串,避免大整数精度丢失) */
|
||||||
|
jobId: string
|
||||||
|
/** 简历 ID(字符串,避免大整数精度丢失) */
|
||||||
|
resumeId: string
|
||||||
|
/** 要优化的模块列表 */
|
||||||
|
optimizeModules: string[]
|
||||||
|
/** 要新增的技能关键词 */
|
||||||
|
addSkills?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成定制简历(AI 接口)
|
||||||
|
* POST /job/customize-resume
|
||||||
|
* @param params 生成参数
|
||||||
|
*/
|
||||||
|
export function generateCustomizeResume(params: GenerateCustomizeResumeParams) {
|
||||||
|
return aiService.post<any, { data: AiResult<{ success: boolean }> }>('/job/customize-resume', params, {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== AI对话编辑简历(AI 接口) ====================
|
||||||
|
|
||||||
|
/** AI对话编辑简历的聊天记录项 */
|
||||||
|
export interface AiEditChatMessage {
|
||||||
|
/** 角色:user-用户 assistant-AI助手 */
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
/** 消息内容 */
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI对话编辑简历请求参数 */
|
||||||
|
export interface AiEditResumeParams {
|
||||||
|
/** 岗位 ID(字符串,避免大整数精度丢失) */
|
||||||
|
jobId: string
|
||||||
|
/** 用户输入的指令 */
|
||||||
|
instruction: string
|
||||||
|
/** 对话历史记录 */
|
||||||
|
chatHistory?: AiEditChatMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI对话编辑简历返回数据 */
|
||||||
|
export interface AiEditResumeResponse {
|
||||||
|
/** 返回类型:message-对话消息 updated-简历已更新 */
|
||||||
|
type: 'message' | 'updated'
|
||||||
|
/** AI回复的消息内容 */
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI对话编辑简历(AI 接口)
|
||||||
|
* POST /job/customize-resume/ai-edit
|
||||||
|
* @param params 请求参数
|
||||||
|
*/
|
||||||
|
export function aiEditResume(params: AiEditResumeParams) {
|
||||||
|
// 强制确保 jobId 为字符串,避免大整数精度丢失
|
||||||
|
const safeParams = { ...params, jobId: String(params.jobId) }
|
||||||
|
return aiService.post<any, { data: AiResult<AiEditResumeResponse> }>('/job/customize-resume/ai-edit', safeParams, {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销AI对话编辑简历的修改(AI 接口)
|
||||||
|
* POST /job/customize-resume/rollback
|
||||||
|
*/
|
||||||
|
export function rollbackCustomizeResume() {
|
||||||
|
return aiService.post<any, { data: AiResult<null> }>('/job/customize-resume/rollback', null, {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改定制简历(AI 接口)
|
||||||
|
* PUT /job/customize-resume
|
||||||
|
* 输入框失焦或选择器选中后自动调用
|
||||||
|
* @param data 定制简历完整数据
|
||||||
|
*/
|
||||||
|
export function updateCustomizeResume(data: CustomizeResumeData) {
|
||||||
|
return aiService.put<any, { data: AiResult<null> }>('/job/customize-resume', data, {
|
||||||
|
transformResponse: [(raw: string) => {
|
||||||
|
try {
|
||||||
|
const processed = raw.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export interface MenuItemRaw {
|
|||||||
export async function fetchUserRoutes(): Promise<MenuItemRaw[]> {
|
export async function fetchUserRoutes(): Promise<MenuItemRaw[]> {
|
||||||
// TODO: 替换为真实接口 → return axios.get('/api/user/menus').then(res => res.data)
|
// TODO: 替换为真实接口 → return axios.get('/api/user/menus').then(res => res.data)
|
||||||
return [
|
return [
|
||||||
{ path: '/agent', name: 'Agent', component: 'Agent', meta: { label: 'AI助手', icon: 'nav-agent-icon', badge: 'NEW' } },
|
|
||||||
{ path: '/profile', name: 'Profile', component: 'Profile', meta: { label: '个人资料', icon: 'nav-profile-icon' } },
|
|
||||||
{ path: '/resume', name: 'Resume', component: 'Resume', meta: { label: '简历', icon: 'nav-resume-icon' } },
|
{ path: '/resume', name: 'Resume', component: 'Resume', meta: { label: '简历', icon: 'nav-resume-icon' } },
|
||||||
|
{ path: '/profile', name: 'Profile', component: 'Profile', meta: { label: '个人资料', icon: 'nav-profile-icon' } },
|
||||||
|
{ path: '/agent', name: 'Agent', component: 'Agent', meta: { label: 'AI助手', icon: 'nav-agent-icon', badge: 'NEW' } },
|
||||||
{ path: '/settings', name: 'Settings', component: 'Settings', meta: { label: '设置', icon: 'nav-setting-icon', position: 'footer' } },
|
{ path: '/settings', name: 'Settings', component: 'Settings', meta: { label: '设置', icon: 'nav-setting-icon', position: 'footer' } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
import aiService from '@/utils/aiRequest'
|
||||||
import type { ApiResult } from '@/api/auth'
|
import type { ApiResult } from '@/api/auth'
|
||||||
|
import type { AiResult } from '@/utils/aiRequest'
|
||||||
|
|
||||||
// ==================== 简历列表相关 ====================
|
// ==================== 简历列表相关 ====================
|
||||||
|
|
||||||
@@ -370,3 +372,185 @@ export interface SaveResumeCompetitionItem {
|
|||||||
export function saveResumeCompetition(resumeId: string, data: SaveResumeCompetitionItem[]) {
|
export function saveResumeCompetition(resumeId: string, data: SaveResumeCompetitionItem[]) {
|
||||||
return request.post<any, ApiResult>('/resume/competition', { resumeId, items: data })
|
return request.post<any, ApiResult>('/resume/competition', { resumeId, items: data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除简历
|
||||||
|
* POST /resume/delete
|
||||||
|
*/
|
||||||
|
export function deleteResume(resumeId: string) {
|
||||||
|
return request.post<any, ApiResult>('/resume/delete', null, {
|
||||||
|
params: { resumeId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 简历诊断相关 ====================
|
||||||
|
|
||||||
|
/** 诊断报告数据 */
|
||||||
|
export interface DiagnosisReport {
|
||||||
|
/** 报告 ID */
|
||||||
|
id?: string
|
||||||
|
/** 简历 ID */
|
||||||
|
resumeId?: string
|
||||||
|
/** 评级 A/B/C/D */
|
||||||
|
grade?: string
|
||||||
|
/** AI 整体评价 */
|
||||||
|
summary?: string
|
||||||
|
/** 紧急修复总数 */
|
||||||
|
urgentTotal?: number
|
||||||
|
/** 重点优化总数 */
|
||||||
|
importantTotal?: number
|
||||||
|
/** 表达提升总数 */
|
||||||
|
expressionTotal?: number
|
||||||
|
/** 创建时间 */
|
||||||
|
createTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 诊断问题子类型计数 */
|
||||||
|
export interface IssueSubCounts {
|
||||||
|
[key: string]: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 诊断问题项 */
|
||||||
|
export interface DiagnosisIssue {
|
||||||
|
/** 问题 ID */
|
||||||
|
id?: string
|
||||||
|
/** 模块类型: summary/education/work/internship/project/competition */
|
||||||
|
moduleType?: string
|
||||||
|
/** 模块记录 ID,summary 时为 resume_id */
|
||||||
|
moduleRecordId?: string
|
||||||
|
/** 诊断发现 */
|
||||||
|
finding?: string
|
||||||
|
/** 为什么重要 */
|
||||||
|
importance?: string
|
||||||
|
/** 改进建议 */
|
||||||
|
suggestion?: string
|
||||||
|
/** 紧急修复子类型计数 */
|
||||||
|
urgentIssues?: IssueSubCounts
|
||||||
|
/** 重点优化子类型计数 */
|
||||||
|
importantIssues?: IssueSubCounts
|
||||||
|
/** 表达提升子类型计数 */
|
||||||
|
expressionIssues?: IssueSubCounts
|
||||||
|
/** AI 改写后的内容 */
|
||||||
|
optimizedContent?: any
|
||||||
|
/** 0=待处理 1=已处理 */
|
||||||
|
status?: number
|
||||||
|
/** 0=未评价 1=符合 2=不符合 */
|
||||||
|
userFeedback?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 诊断接口返回的 data 结构 */
|
||||||
|
export interface DiagnosisData {
|
||||||
|
/** 诊断报告 */
|
||||||
|
report: DiagnosisReport
|
||||||
|
/** 诊断问题列表 */
|
||||||
|
issues: DiagnosisIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询简历诊断报告(AI 接口)
|
||||||
|
* GET /resume/diagnose/{resume_id}
|
||||||
|
*/
|
||||||
|
export function fetchResumeDiagnosis(resumeId: string) {
|
||||||
|
return aiService.get<AiResult<DiagnosisData | null>>(`/resume/diagnose/${resumeId}`, {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
// 大整数安全解析
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发诊断返回的 data 结构 */
|
||||||
|
export interface TriggerDiagnosisData {
|
||||||
|
/** 诊断报告 ID */
|
||||||
|
reportId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发简历诊断(AI 接口)
|
||||||
|
* POST /resume/diagnose
|
||||||
|
*/
|
||||||
|
export function triggerResumeDiagnosis(resumeId: string) {
|
||||||
|
return aiService.post<AiResult<TriggerDiagnosisData>>('/resume/diagnose', { resumeId }, {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 问题润色(AI 优化用户改写内容) ====================
|
||||||
|
|
||||||
|
/** 润色接口返回的 data 结构 */
|
||||||
|
export interface PolishIssueData {
|
||||||
|
/** AI 润色后的文本段落数组 */
|
||||||
|
content: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对诊断问题进行 AI 润色(AI 接口)
|
||||||
|
* POST /resume/diagnose/issue/{issue_id}/polish
|
||||||
|
* @param issueId 问题 ID
|
||||||
|
* @param content 用户编辑后的文本段落数组
|
||||||
|
*/
|
||||||
|
export function polishDiagnosisIssue(issueId: string, content: string[]) {
|
||||||
|
return aiService.post<AiResult<PolishIssueData>>(`/resume/diagnose/issue/${issueId}/polish`, { content }, {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 问题用户评价 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交诊断问题的用户评价(AI 接口)
|
||||||
|
* PUT /resume/diagnose/issue/{issue_id}/feedback
|
||||||
|
* @param issueId 问题 ID
|
||||||
|
* @param userFeedback 1=符合(有用) 2=不符合(无用)
|
||||||
|
*/
|
||||||
|
export function feedbackDiagnosisIssue(issueId: string, userFeedback: number) {
|
||||||
|
return aiService.put<AiResult<null>>(`/resume/diagnose/issue/${issueId}/feedback`, { userFeedback }, {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 标记问题已处理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记诊断问题为已处理(AI 接口)
|
||||||
|
* PUT /resume/diagnose/issue/{issue_id}/resolve
|
||||||
|
* @param issueId 问题 ID
|
||||||
|
*/
|
||||||
|
export function resolveDiagnosisIssue(issueId: string) {
|
||||||
|
return aiService.put<AiResult<null>>(`/resume/diagnose/issue/${issueId}/resolve`, null, {
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}).then(res => res.data)
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -919,7 +919,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__ai-msg {
|
&__ai-msg {
|
||||||
margin-bottom: 0.1rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
&--ai {
|
&--ai {
|
||||||
@@ -942,6 +941,7 @@
|
|||||||
&__ai-msg--ai &__ai-msg-bubble {
|
&__ai-msg--ai &__ai-msg-bubble {
|
||||||
background: $bg-main;
|
background: $bg-main;
|
||||||
color: $text-dark;
|
color: $text-dark;
|
||||||
|
border: 1px solid $border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__ai-msg--user &__ai-msg-bubble {
|
&__ai-msg--user &__ai-msg-bubble {
|
||||||
@@ -949,6 +949,133 @@
|
|||||||
color: $bg-white;
|
color: $bg-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI正在思考中的加载气泡
|
||||||
|
&__ai-msg-bubble--loading {
|
||||||
|
color: $text-middle;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息包裹容器(含撤销气泡)
|
||||||
|
&__ai-msg-wrap {
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 撤销修改气泡区域
|
||||||
|
&__ai-rollback {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.06rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 撤销修改按钮
|
||||||
|
&__ai-rollback-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.04rem;
|
||||||
|
background: $bg-white;
|
||||||
|
border: 1px solid $accent;
|
||||||
|
border-radius: 0.14rem;
|
||||||
|
padding: 0.05rem 0.12rem;
|
||||||
|
font-size: 0.11rem;
|
||||||
|
color: $accent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $theme-color;
|
||||||
|
border-color: $accent-hover;
|
||||||
|
color: $accent-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 撤销修改图标
|
||||||
|
&__ai-rollback-icon {
|
||||||
|
width: 0.13rem;
|
||||||
|
height: 0.13rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已撤销状态文字
|
||||||
|
&__ai-rollback-done {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.04rem;
|
||||||
|
font-size: 0.11rem;
|
||||||
|
color: $text-middle;
|
||||||
|
padding: 0.05rem 0.12rem;
|
||||||
|
background: $bg-main;
|
||||||
|
border-radius: 0.14rem;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 撤销确认弹窗 ====================
|
||||||
|
&__rollback-confirm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: $overlay-bg;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rollback-confirm {
|
||||||
|
background: $bg-white;
|
||||||
|
border-radius: 0.12rem;
|
||||||
|
padding: 0.32rem 0.36rem;
|
||||||
|
min-width: 3.2rem;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0.08rem 0.24rem rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rollback-confirm-text {
|
||||||
|
font-size: 0.15rem;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 0.28rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rollback-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.12rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rollback-confirm-cancel {
|
||||||
|
flex: 1;
|
||||||
|
height: 0.4rem;
|
||||||
|
background: $bg-white;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
font-size: 0.14rem;
|
||||||
|
color: $text-dark;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $bg-main;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rollback-confirm-ok {
|
||||||
|
flex: 1;
|
||||||
|
height: 0.4rem;
|
||||||
|
background: $btn-dark;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
font-size: 0.14rem;
|
||||||
|
color: $bg-white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $btn-dark-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AI输入框区域
|
// AI输入框区域
|
||||||
&__ai-input-area {
|
&__ai-input-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1002,12 +1129,13 @@
|
|||||||
height: 0.16rem;
|
height: 0.16rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑tab占位
|
// 编辑tab内容区
|
||||||
&__preview-edit {
|
&__preview-edit {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 步骤四:底部按钮区 ====================
|
// ==================== 步骤四:底部按钮区 ====================
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
@use '../variables' as *;
|
||||||
|
@use 'sass:color';
|
||||||
|
|
||||||
|
// ==================== 定制简历编辑面板(折叠手风琴式) ====================
|
||||||
|
.job-resume-edit {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.12rem 0;
|
||||||
|
|
||||||
|
// 单个折叠模块
|
||||||
|
&__section {
|
||||||
|
margin-bottom: 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模块标题栏(可点击展开/收起)
|
||||||
|
&__section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.14rem 0.16rem;
|
||||||
|
background: $bg-main;
|
||||||
|
border-radius: 0.08rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color.adjust($bg-main, $lightness: -2%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-title {
|
||||||
|
font-size: 0.14rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开/收起箭头
|
||||||
|
&__section-arrow {
|
||||||
|
width: 0.14rem;
|
||||||
|
height: 0.14rem;
|
||||||
|
color: $text-middle;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-arrow--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模块内容区(展开时显示)
|
||||||
|
&__section-body {
|
||||||
|
padding: 0.16rem 0.12rem 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字段容器
|
||||||
|
&__field {
|
||||||
|
margin-bottom: 0.14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字段标签
|
||||||
|
&__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.12rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
margin-bottom: 0.04rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.08rem 0.12rem;
|
||||||
|
font-size: 0.12rem;
|
||||||
|
color: $text-dark;
|
||||||
|
background: $bg-main;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.06rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $accent;
|
||||||
|
background: $bg-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多行文本
|
||||||
|
&__textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.08rem 0.12rem;
|
||||||
|
font-size: 0.12rem;
|
||||||
|
color: $text-dark;
|
||||||
|
background: $bg-main;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.06rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $accent;
|
||||||
|
background: $bg-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带删除按钮时右侧留空
|
||||||
|
&--with-remove {
|
||||||
|
padding-right: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 横排字段
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field--half {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 经历卡片
|
||||||
|
&__card {
|
||||||
|
padding-bottom: 0.14rem;
|
||||||
|
margin-bottom: 0.14rem;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 经历卡片头部
|
||||||
|
&__card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-index {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.04rem;
|
||||||
|
font-size: 0.12rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-index-dot {
|
||||||
|
width: 0.08rem;
|
||||||
|
height: 0.08rem;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除按钮
|
||||||
|
&__card-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.03rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $text-light;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 0.04rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $danger;
|
||||||
|
background: rgba($danger, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-delete-icon {
|
||||||
|
width: 0.13rem;
|
||||||
|
height: 0.13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 描述段落容器
|
||||||
|
&__desc-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.06rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 描述段落删除按钮
|
||||||
|
&__desc-remove {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.06rem;
|
||||||
|
top: 0.06rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.02rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $text-light;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 0.03rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc-remove-icon {
|
||||||
|
width: 0.1rem;
|
||||||
|
height: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增按钮
|
||||||
|
&__add-btn {
|
||||||
|
text-align: center;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 0.16rem;
|
||||||
|
padding: 0.04rem 0.12rem;
|
||||||
|
font-size: 0.11rem;
|
||||||
|
color: $text-dark;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $accent;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__add-btn--full {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.06rem;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__add-icon {
|
||||||
|
font-size: 0.12rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 技能/证书标签列表
|
||||||
|
&__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.06rem;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
min-height: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.04rem;
|
||||||
|
padding: 0.04rem 0.08rem 0.04rem 0.1rem;
|
||||||
|
background: $bg-main;
|
||||||
|
border-radius: 0.12rem;
|
||||||
|
font-size: 0.11rem;
|
||||||
|
color: $text-dark;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color.adjust($bg-main, $lightness: -3%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.01rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $text-light;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag-remove-icon {
|
||||||
|
width: 0.1rem;
|
||||||
|
height: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期选择器样式覆盖
|
||||||
|
&__date-picker {
|
||||||
|
width: 100% !important;
|
||||||
|
|
||||||
|
.el-input__wrapper {
|
||||||
|
background: $bg-main !important;
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 3px 10px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
line-height: 1.5 !important;
|
||||||
|
height: auto !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $border-color !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-focus {
|
||||||
|
border-color: $accent !important;
|
||||||
|
background: $bg-white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__inner {
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: $text-dark !important;
|
||||||
|
height: auto !important;
|
||||||
|
line-height: 1.5 !important;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $text-light !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__prefix,
|
||||||
|
.el-input__suffix {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__icon {
|
||||||
|
width: 12px !important;
|
||||||
|
height: 12px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉选择器
|
||||||
|
&__select {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.08rem 0.12rem;
|
||||||
|
font-size: 0.12rem;
|
||||||
|
color: $text-dark;
|
||||||
|
background: $bg-main;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.06rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $accent;
|
||||||
|
background: $bg-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,4 +146,12 @@
|
|||||||
&__skill-label {
|
&__skill-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 差异对比高亮样式 ====================
|
||||||
|
&__diff-highlight {
|
||||||
|
background-color: #D4EDDA;
|
||||||
|
color: #155724;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
|
// ==================== 简历分析报告抽屉 ====================
|
||||||
|
|
||||||
|
// 遮罩层
|
||||||
|
.report-drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: $overlay-bg;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遮罩层过渡动画
|
||||||
|
.report-drawer-overlay-enter-active,
|
||||||
|
.report-drawer-overlay-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.report-drawer-overlay-enter-from,
|
||||||
|
.report-drawer-overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽屉主体
|
||||||
|
.report-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 8rem;
|
||||||
|
height: 100vh;
|
||||||
|
background: $bg-main;
|
||||||
|
z-index: 2001;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.08);
|
||||||
|
font-size: 0.14rem;
|
||||||
|
|
||||||
|
// ---- 顶部栏 ----
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.16rem 0.24rem;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
background: $bg-white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close-btn {
|
||||||
|
width: 0.28rem;
|
||||||
|
height: 0.28rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: $text-dark;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $bg-main;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close-icon {
|
||||||
|
width: 0.16rem;
|
||||||
|
height: 0.16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 0.16rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__update-time {
|
||||||
|
font-size: 0.12rem;
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 可滚动内容区 ----
|
||||||
|
&__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.2rem 0.24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 评分概览 ----
|
||||||
|
&__score-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: $bg-white;
|
||||||
|
border-radius: 0.1rem;
|
||||||
|
padding: 0.2rem 0.24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__score-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grade-avatar {
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
border-radius: 0.08rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-dark;
|
||||||
|
font-size: 0.24rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: $bg-main;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grade-badge {
|
||||||
|
font-size: 0.12rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $accent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.04rem;
|
||||||
|
background: $theme-color;
|
||||||
|
padding: 0.06rem 0.12rem;
|
||||||
|
border-radius: 0.08rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grade-dot {
|
||||||
|
font-size: 0.06rem;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__score-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__score-item {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__score-num {
|
||||||
|
font-size: 0.24rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__score-label {
|
||||||
|
font-size: 0.1rem;
|
||||||
|
color: $text-light;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 评级提示语 ----
|
||||||
|
&__grade-hint {
|
||||||
|
font-size: 0.12rem;
|
||||||
|
color: $text-middle;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0.12rem 0 0.2rem 0;
|
||||||
|
padding: 0 0.04rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 结论 ----
|
||||||
|
&__conclusion {
|
||||||
|
margin-bottom: 0.24rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-title {
|
||||||
|
font-size: 0.16rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 0.12rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__conclusion-text {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
color: $text-dark;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 核心问题 ----
|
||||||
|
&__issues {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__issue-card {
|
||||||
|
background: $bg-white;
|
||||||
|
border-radius: 0.1rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
margin-bottom: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__issue-title {
|
||||||
|
font-size: 0.14rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 0.08rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__issue-detail {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
color: $text-middle;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0 0 0.14rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__issue-why-label {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 0.06rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__issue-importance {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
color: $text-middle;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 底部按钮 ----
|
||||||
|
&__footer {
|
||||||
|
padding: 0.16rem 0.24rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: $bg-main;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__optimize-btn {
|
||||||
|
width: 2.4rem;
|
||||||
|
padding: 0.12rem 0;
|
||||||
|
font-size: 0.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $bg-white;
|
||||||
|
background: $btn-dark;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.24rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $btn-dark-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽屉滑入动画
|
||||||
|
.report-drawer-slide-enter-active,
|
||||||
|
.report-drawer-slide-leave-active {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.report-drawer-slide-enter-from,
|
||||||
|
.report-drawer-slide-leave-to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
|
// ==================== 简历评估问题修复抽屉 ====================
|
||||||
|
|
||||||
|
// 遮罩层
|
||||||
|
.fix-drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: $overlay-bg;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遮罩层过渡动画
|
||||||
|
.fix-drawer-overlay-enter-active,
|
||||||
|
.fix-drawer-overlay-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.fix-drawer-overlay-enter-from,
|
||||||
|
.fix-drawer-overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽屉主体
|
||||||
|
.fix-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 8rem;
|
||||||
|
height: 100vh;
|
||||||
|
background: $bg-main;
|
||||||
|
z-index: 2001;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.08);
|
||||||
|
font-size: 0.14rem;
|
||||||
|
// ---- 顶部栏 ----
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.16rem 0.24rem;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
background: $bg-white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close-btn {
|
||||||
|
width: 0.28rem;
|
||||||
|
height: 0.28rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: $text-dark;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $bg-main;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close-icon {
|
||||||
|
width: 0.16rem;
|
||||||
|
height: 0.16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 0.18rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 可滚动内容区 ----
|
||||||
|
&__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.2rem 0.24rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 通用区块 ----
|
||||||
|
&__section {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-title {
|
||||||
|
font-size: 0.14rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 0.1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
background: $bg-white;
|
||||||
|
border-radius: 0.1rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 原文 ----
|
||||||
|
&__original-text {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
color: $text-dark;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 问题检查 ----
|
||||||
|
&__label {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0.14rem 0 0.06rem 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
color: $text-dark;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AI 改进后的版本(差异高亮) ----
|
||||||
|
&__diff-text {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
color: $text-dark;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 差异高亮样式 — 品牌色背景
|
||||||
|
&__highlight {
|
||||||
|
background-color: rgba($accent, 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 自己改写 ----
|
||||||
|
&__textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0.5rem;
|
||||||
|
font-size: 0.13rem;
|
||||||
|
color: $text-dark;
|
||||||
|
line-height: 1.8;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
// 让 textarea 根据内容自动撑高
|
||||||
|
field-sizing: content;
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__textarea-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ai-btn {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $bg-white;
|
||||||
|
background: $btn-dark;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.16rem;
|
||||||
|
padding: 0.06rem 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $btn-dark-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AI 润色结果 ----
|
||||||
|
&__polish-text {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
color: $text-dark;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.08rem 0;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__replace-btn {
|
||||||
|
font-size: 0.13rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $bg-white;
|
||||||
|
background: $btn-dark;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.16rem;
|
||||||
|
padding: 0.06rem 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $btn-dark-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 评价卡片(始终显示,独立盒子) ----
|
||||||
|
&__feedback-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__feedback-label {
|
||||||
|
font-size: 0.12rem;
|
||||||
|
color: $text-middle;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__feedback-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__feedback-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.03rem;
|
||||||
|
font-size: 0.12rem;
|
||||||
|
color: $text-light;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.03rem 0.06rem;
|
||||||
|
border-radius: 0.04rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中态
|
||||||
|
&--active {
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__feedback-icon {
|
||||||
|
width: 0.14rem;
|
||||||
|
height: 0.14rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
// 无用按钮的图标翻转朝下
|
||||||
|
&--down {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 底部提交按钮 ----
|
||||||
|
&__footer {
|
||||||
|
padding: 0.16rem 0.24rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: $bg-main;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit-btn {
|
||||||
|
width: 2.4rem;
|
||||||
|
padding: 0.12rem 0;
|
||||||
|
font-size: 0.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $bg-white;
|
||||||
|
background: $btn-dark;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.24rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $btn-dark-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽屉滑入动画
|
||||||
|
.fix-drawer-slide-enter-active,
|
||||||
|
.fix-drawer-slide-leave-active {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.fix-drawer-slide-enter-from,
|
||||||
|
.fix-drawer-slide-leave-to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
@use './components/region-selector.scss';
|
@use './components/region-selector.scss';
|
||||||
@use './components/job-resume-custom-dialog.scss';
|
@use './components/job-resume-custom-dialog.scss';
|
||||||
@use './components/job-resume-template.scss';
|
@use './components/job-resume-template.scss';
|
||||||
|
@use './components/job-resume-custom-edit-panel.scss';
|
||||||
|
@use './components/resume-analysis-report-drawer.scss';
|
||||||
|
@use './components/resume-issue-fix-drawer.scss';
|
||||||
|
|
||||||
// 全局样式(优先级最高)
|
// 全局样式(优先级最高)
|
||||||
@use './auto.scss';
|
@use './auto.scss';
|
||||||
@@ -56,9 +59,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Element Plus Loading 品牌色覆盖 ====================
|
// ==================== Element Plus Loading rem 适配修正 + 品牌色覆盖 ====================
|
||||||
.el-loading-spinner {
|
.el-loading-mask {
|
||||||
.circular .path {
|
// 全屏 loading 遮罩层 z-index 确保最高
|
||||||
stroke: #4FC2C9 !important;
|
z-index: 2100 !important;
|
||||||
|
|
||||||
|
.el-loading-spinner {
|
||||||
|
// 修正 spinner 容器定位(默认 top: 50% + margin-top 用了 rem,会被 100px 放大)
|
||||||
|
top: 50% !important;
|
||||||
|
margin-top: -21px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
|
||||||
|
// 修正旋转圆圈尺寸
|
||||||
|
.circular {
|
||||||
|
width: 42px !important;
|
||||||
|
height: 42px !important;
|
||||||
|
|
||||||
|
.path {
|
||||||
|
stroke: #4FC2C9 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修正加载文字
|
||||||
|
.el-loading-text {
|
||||||
|
font-size: 14px !important;
|
||||||
|
margin: 8px 0 !important;
|
||||||
|
color: #4FC2C9 !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,18 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: $btn-dark-hover;
|
background: $btn-dark-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 开始诊断按钮 — 稍大一些,更醒目
|
||||||
|
&--start {
|
||||||
|
padding: 0.1rem 0.28rem;
|
||||||
|
font-size: 0.14rem;
|
||||||
|
border-radius: 0.08rem;
|
||||||
|
background: $gradient-bg;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $accent-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 简历主体 ----
|
// ---- 简历主体 ----
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__close-icon"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-resume-custom-dialog__tip-bar"><span>你与该岗位的匹配度较低,简历可能无法通过机筛。</span></div>
|
<div v-if="isLowMatch" class="job-resume-custom-dialog__tip-bar"><span>你与该岗位的匹配度较低,简历可能无法通过机筛。</span></div>
|
||||||
<div class="job-resume-custom-dialog__job-card">
|
<div class="job-resume-custom-dialog__job-card">
|
||||||
<div class="job-resume-custom-dialog__job-left">
|
<div class="job-resume-custom-dialog__job-left">
|
||||||
<div class="job-resume-custom-dialog__company-icon">
|
<div class="job-resume-custom-dialog__company-icon">
|
||||||
@@ -52,9 +52,9 @@
|
|||||||
<h2 class="job-resume-custom-dialog__drawer-title">生成你的岗位专属简历</h2>
|
<h2 class="job-resume-custom-dialog__drawer-title">生成你的岗位专属简历</h2>
|
||||||
</div>
|
</div>
|
||||||
<!-- 返回按钮(步骤四显示) -->
|
<!-- 返回按钮(步骤四显示) -->
|
||||||
<button v-if="currentStep === 4" class="job-resume-custom-dialog__back-btn" @click="goToStep(3)">返回</button>
|
<button v-if="currentStep === 4" class="job-resume-custom-dialog__back-btn mt10" @click="goToStep(3)">返回</button>
|
||||||
<!-- 步骤指示器 -->
|
<!-- 步骤指示器 -->
|
||||||
<div class="job-resume-custom-dialog__steps">
|
<div class="job-resume-custom-dialog__steps pt10">
|
||||||
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 2 }">
|
<div class="job-resume-custom-dialog__step" :class="{ 'job-resume-custom-dialog__step--active': currentStep === 2 }">
|
||||||
<span class="job-resume-custom-dialog__step-num">1</span><span>差距分析</span>
|
<span class="job-resume-custom-dialog__step-num">1</span><span>差距分析</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,8 +71,8 @@
|
|||||||
<template v-if="currentStep === 2">
|
<template v-if="currentStep === 2">
|
||||||
<div class="job-resume-custom-dialog__gap-header">
|
<div class="job-resume-custom-dialog__gap-header">
|
||||||
<div class="job-resume-custom-dialog__gap-left">
|
<div class="job-resume-custom-dialog__gap-left">
|
||||||
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位的匹配度较低</h3>
|
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位的匹配度{{ isLowMatch ? '较低' : '较高' }}</h3>
|
||||||
<div class="job-resume-custom-dialog__gap-warn">
|
<div v-if="isLowMatch" class="job-resume-custom-dialog__gap-warn">
|
||||||
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__warn-icon"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M8 5v3M8 10.5v.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__warn-icon"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M8 5v3M8 10.5v.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||||
<span>匹配度低于 6.0 分的简历,在筛选环节可能会被优先淘汰。我们会帮你快速优化提升。</span>
|
<span>匹配度低于 6.0 分的简历,在筛选环节可能会被优先淘汰。我们会帮你快速优化提升。</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,9 +131,9 @@
|
|||||||
<div class="job-resume-custom-dialog__gap-cell">
|
<div class="job-resume-custom-dialog__gap-cell">
|
||||||
<span class="job-resume-custom-dialog__gap-value">{{ jobInfo.title }}</span>
|
<span class="job-resume-custom-dialog__gap-value">{{ jobInfo.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-resume-custom-dialog__gap-cell">
|
<!-- <div class="job-resume-custom-dialog__gap-cell">
|
||||||
<span class="job-resume-custom-dialog__gap-value">{{ selectedResume.targetJob || '—' }}</span>
|
<span class="job-resume-custom-dialog__gap-value">{{ selectedResume.targetJob || '—' }}</span>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
<!-- 第三行:岗位关键词 -->
|
<!-- 第三行:岗位关键词 -->
|
||||||
<div class="job-resume-custom-dialog__gap-row">
|
<div class="job-resume-custom-dialog__gap-row">
|
||||||
@@ -195,9 +195,14 @@
|
|||||||
<!-- 步骤四:预览 -->
|
<!-- 步骤四:预览 -->
|
||||||
<template v-if="currentStep === 4">
|
<template v-if="currentStep === 4">
|
||||||
<div class="job-resume-custom-dialog__preview">
|
<div class="job-resume-custom-dialog__preview">
|
||||||
<!-- 左侧:简历模板预览 -->
|
<!-- 左侧:简历模板预览(支持差异对比模式) -->
|
||||||
<div class="job-resume-custom-dialog__preview-left">
|
<div class="job-resume-custom-dialog__preview-left">
|
||||||
<JobResumeTemplate :resumeData="resumeTemplateData" ref="resumeTemplateRef" />
|
<JobResumeTemplate
|
||||||
|
:resumeData="resumeTemplateData"
|
||||||
|
:showDiff="isShowDiff"
|
||||||
|
:oldResumeData="oldResumeTemplateData"
|
||||||
|
ref="resumeTemplateRef"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 右侧:AI帮写 / 编辑 tab -->
|
<!-- 右侧:AI帮写 / 编辑 tab -->
|
||||||
<div class="job-resume-custom-dialog__preview-right">
|
<div class="job-resume-custom-dialog__preview-right">
|
||||||
@@ -252,10 +257,37 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(msg, i) in aiMessages"
|
v-for="(msg, i) in aiMessages"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="job-resume-custom-dialog__ai-msg"
|
class="job-resume-custom-dialog__ai-msg-wrap"
|
||||||
:class="msg.role === 'ai' ? 'job-resume-custom-dialog__ai-msg--ai' : 'job-resume-custom-dialog__ai-msg--user'"
|
|
||||||
>
|
>
|
||||||
<div class="job-resume-custom-dialog__ai-msg-bubble">{{ msg.content }}</div>
|
<div
|
||||||
|
class="job-resume-custom-dialog__ai-msg"
|
||||||
|
:class="msg.role === 'assistant' ? 'job-resume-custom-dialog__ai-msg--ai' : 'job-resume-custom-dialog__ai-msg--user'"
|
||||||
|
>
|
||||||
|
<div class="job-resume-custom-dialog__ai-msg-bubble">{{ msg.content }}</div>
|
||||||
|
</div>
|
||||||
|
<!-- 撤销修改气泡(仅 type=updated 的 assistant 消息显示) -->
|
||||||
|
<div v-if="msg.canRollback" class="job-resume-custom-dialog__ai-rollback">
|
||||||
|
<button
|
||||||
|
v-if="msg.rollbackStatus !== 'done'"
|
||||||
|
class="job-resume-custom-dialog__ai-rollback-btn"
|
||||||
|
@click="handleRollbackClick(i)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__ai-rollback-icon">
|
||||||
|
<path d="M3 8h7a3 3 0 010 6H8M3 8l3-3M3 8l3 3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
撤销修改
|
||||||
|
</button>
|
||||||
|
<span v-else class="job-resume-custom-dialog__ai-rollback-done">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__ai-rollback-icon">
|
||||||
|
<path d="M3 8.5l3 3 7-7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
已撤销此次简历修改
|
||||||
|
</span>
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- AI输入框 -->
|
<!-- AI输入框 -->
|
||||||
@@ -273,9 +305,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 编辑内容(占位) -->
|
<!-- 编辑内容 — 折叠手风琴式编辑面板 -->
|
||||||
<div v-if="previewTab === 'edit'" class="job-resume-custom-dialog__preview-edit">
|
<div v-if="previewTab === 'edit'" class="job-resume-custom-dialog__preview-edit">
|
||||||
<div class="job-resume-custom-dialog__placeholder">编辑功能(待开发)</div>
|
<JobResumeCustomEditPanel :resumeData="customResumeRawData" @update="onEditPanelUpdate" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,15 +331,30 @@
|
|||||||
<button class="job-resume-custom-dialog__submit-btn" @click="handleSubmit">立即去投递</button>
|
<button class="job-resume-custom-dialog__submit-btn" @click="handleSubmit">立即去投递</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 撤销修改确认弹窗 -->
|
||||||
|
<div v-if="showRollbackConfirm" class="job-resume-custom-dialog__rollback-confirm-overlay">
|
||||||
|
<div class="job-resume-custom-dialog__rollback-confirm">
|
||||||
|
<p class="job-resume-custom-dialog__rollback-confirm-text">确定要撤销AI助手对简历的修改吗?</p>
|
||||||
|
<div class="job-resume-custom-dialog__rollback-confirm-actions">
|
||||||
|
<button class="job-resume-custom-dialog__rollback-confirm-cancel" @click="cancelRollback">取消</button>
|
||||||
|
<button class="job-resume-custom-dialog__rollback-confirm-ok" @click="confirmRollback">确定撤销</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick } from 'vue'
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
import html2pdf from 'html2pdf.js'
|
import html2pdf from 'html2pdf.js'
|
||||||
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
|
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
|
||||||
|
import JobResumeCustomEditPanel from '@/components/JobResumeCustomEditPanel.vue'
|
||||||
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
|
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
|
||||||
import { fetchProfile, fetchEducation, fetchWork, fetchInternship, fetchProject, fetchCompetition } from '@/api/profile'
|
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'
|
||||||
|
|
||||||
// ==================== 类型定义 ====================
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
@@ -328,12 +375,20 @@ interface JobInfo {
|
|||||||
missingSkills: string[]
|
missingSkills: string[]
|
||||||
keywords: string[]
|
keywords: string[]
|
||||||
sourceUrl: string
|
sourceUrl: string
|
||||||
|
/** 默认简历信息(来自 skill-gap 接口) */
|
||||||
|
defaultResume: { resumeId: string; resumeName: string; targetPosition: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AI聊天消息 */
|
/** AI聊天消息(用于界面展示) */
|
||||||
interface AiChatMsg {
|
interface AiChatMsg {
|
||||||
role: 'ai' | 'user'
|
/** 角色:user-用户 assistant-AI助手 */
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
/** 消息内容 */
|
||||||
content: string
|
content: string
|
||||||
|
/** 是否可以撤销(仅 type=updated 的 assistant 消息有此标记) */
|
||||||
|
canRollback?: boolean
|
||||||
|
/** 撤销状态:idle-未操作 done-已撤销 */
|
||||||
|
rollbackStatus?: 'idle' | 'done'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Props & Emits ====================
|
// ==================== Props & Emits ====================
|
||||||
@@ -343,6 +398,8 @@ const props = defineProps<{
|
|||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
/** 岗位信息 */
|
/** 岗位信息 */
|
||||||
jobInfo: JobInfo
|
jobInfo: JobInfo
|
||||||
|
/** 岗位 ID(字符串,避免大整数精度丢失) */
|
||||||
|
jobId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -359,35 +416,163 @@ const currentStep = ref(1)
|
|||||||
/** 缺少的技能列表 */
|
/** 缺少的技能列表 */
|
||||||
const missingSkills = computed(() => props.jobInfo.missingSkills || [])
|
const missingSkills = computed(() => props.jobInfo.missingSkills || [])
|
||||||
|
|
||||||
/** 匹配度等级文案 */
|
/** 匹配度等级文案(6分为高低分界线) */
|
||||||
const matchLevelText = computed(() => {
|
const matchLevelText = computed(() => {
|
||||||
const score = props.jobInfo.matchScore
|
const score = props.jobInfo.matchScore
|
||||||
if (score >= 8) return '高匹配度'
|
if (score >= 6) return '高匹配度'
|
||||||
if (score >= 5) return '中匹配度'
|
|
||||||
return '低匹配度'
|
return '低匹配度'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 是否为低匹配度(低于6分) */
|
||||||
|
const isLowMatch = computed(() => props.jobInfo.matchScore < 6)
|
||||||
|
|
||||||
/** 跳转到指定步骤 */
|
/** 跳转到指定步骤 */
|
||||||
function goToStep(step: number) {
|
function goToStep(step: number) {
|
||||||
if (step === 3) initSkillOptions()
|
if (step === 3) initSkillOptions()
|
||||||
if (step === 4) loadResumeData()
|
if (step === 4) fetchAndLoadCustomResume()
|
||||||
currentStep.value = step
|
else currentStep.value = step
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 抽屉模式下一步 */
|
/** 抽屉模式下一步 */
|
||||||
function handleDrawerNext() {
|
async function handleDrawerNext() {
|
||||||
if (currentStep.value < 4) {
|
if (currentStep.value >= 4) return
|
||||||
if (currentStep.value === 2) initSkillOptions()
|
if (currentStep.value === 2) {
|
||||||
if (currentStep.value === 3) loadResumeData()
|
initSkillOptions()
|
||||||
|
currentStep.value++
|
||||||
|
} else if (currentStep.value === 3) {
|
||||||
|
// 步骤3 → 步骤4:先调用定制简历接口
|
||||||
|
await fetchAndLoadCustomResume()
|
||||||
|
} else {
|
||||||
currentStep.value++
|
currentStep.value++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取定制简历数据并跳转到预览步骤
|
||||||
|
* 流程:先 GET 查询 → 有数据直接用 → 无数据则 POST 生成 → 再 GET 查询
|
||||||
|
*/
|
||||||
|
async function fetchAndLoadCustomResume() {
|
||||||
|
const loadingInstance = ElLoading.service({
|
||||||
|
text: '正在生成定制简历...',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
// 第一步:查询是否已有定制简历
|
||||||
|
let queryRes = await fetchCustomizeResume()
|
||||||
|
// if (queryRes.code === 0 && queryRes.data) {
|
||||||
|
// // 已有定制简历,直接填充数据并跳转预览
|
||||||
|
// fillCustomResumeData(queryRes.data)
|
||||||
|
// currentStep.value = 4
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 第二步:没有定制简历,调用生成接口
|
||||||
|
const genRes = await generateCustomizeResume({
|
||||||
|
jobId: props.jobId,
|
||||||
|
resumeId: selectedResume.value.id,
|
||||||
|
optimizeModules: selectedSectionKeys.value,
|
||||||
|
addSkills: selectedNewSkills.value.length > 0 ? selectedNewSkills.value : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (genRes.code !== 0 || !genRes.data?.success) {
|
||||||
|
ElMessage.error('生成定制简历失败,请稍后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三步:生成成功后再次查询获取简历数据
|
||||||
|
queryRes = await fetchCustomizeResume()
|
||||||
|
if (queryRes.code === 0 && queryRes.data) {
|
||||||
|
fillCustomResumeData(queryRes.data)
|
||||||
|
currentStep.value = 4
|
||||||
|
} else {
|
||||||
|
ElMessage.error('获取定制简历数据失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[JobResumeCustomDialog] 定制简历流程失败', e)
|
||||||
|
ElMessage.error('定制简历失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loadingInstance.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将定制简历接口返回的数据填充到简历模板
|
||||||
|
*/
|
||||||
|
function fillCustomResumeData(data: CustomizeResumeData) {
|
||||||
|
// 保存原始数据供编辑面板使用
|
||||||
|
customResumeRawData.value = JSON.parse(JSON.stringify(data))
|
||||||
|
const r = data.resume || {}
|
||||||
|
resumeTemplateData.value = {
|
||||||
|
name: r.name || '未填写姓名',
|
||||||
|
email: r.email || '',
|
||||||
|
mobileNumber: r.mobileNumber || '',
|
||||||
|
wechatNumber: r.wechatNumber || '',
|
||||||
|
summary: r.summary || '',
|
||||||
|
educations: (data.education || []).map(e => ({
|
||||||
|
school: e.school || '',
|
||||||
|
major: e.major || '',
|
||||||
|
degree: degreeToNumber(e.degree),
|
||||||
|
startDate: e.startDate || '',
|
||||||
|
endDate: e.endDate || '',
|
||||||
|
description: (e.description || []).map(d => ({ id: d.id, text: d.text || '' })),
|
||||||
|
})),
|
||||||
|
workExperiences: (data.work || []).map(w => ({
|
||||||
|
companyName: w.companyName || '',
|
||||||
|
position: w.position || '',
|
||||||
|
startDate: w.startDate || '',
|
||||||
|
endDate: w.endDate || '',
|
||||||
|
description: (w.description || []).map(d => ({ id: d.id, text: d.text || '' })),
|
||||||
|
})),
|
||||||
|
internships: (data.internship || []).map(i => ({
|
||||||
|
companyName: i.companyName || '',
|
||||||
|
position: i.position || '',
|
||||||
|
startDate: i.startDate || '',
|
||||||
|
endDate: i.endDate || '',
|
||||||
|
description: (i.description || []).map(d => ({ id: d.id, text: d.text || '' })),
|
||||||
|
})),
|
||||||
|
projects: (data.project || []).map(p => ({
|
||||||
|
projectName: p.projectName || '',
|
||||||
|
companyName: p.companyName || '',
|
||||||
|
role: p.role || '',
|
||||||
|
startDate: p.startDate || '',
|
||||||
|
endDate: p.endDate || '',
|
||||||
|
description: (p.description || []).map(d => ({ id: d.id, text: d.text || '' })),
|
||||||
|
})),
|
||||||
|
competitions: (data.competition || []).map(c => ({
|
||||||
|
competitionName: c.competitionName || '',
|
||||||
|
award: c.award || '',
|
||||||
|
awardDate: c.awardDate || '',
|
||||||
|
description: (c.description || []).map(d => ({ id: d.id, text: d.text || '' })),
|
||||||
|
})),
|
||||||
|
skills: r.skills || [],
|
||||||
|
certificates: r.certificates || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学历文字转数字(接口返回中文,模板需要数字) */
|
||||||
|
function degreeToNumber(degree?: string): number {
|
||||||
|
const map: Record<string, number> = { '大专': 1, '本科': 2, '硕士': 3, '博士': 4 }
|
||||||
|
return map[degree || ''] || 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑面板数据更新回调 — 同步更新简历模板预览
|
||||||
|
* @param data 编辑面板传回的最新数据
|
||||||
|
*/
|
||||||
|
function onEditPanelUpdate(data: CustomizeResumeData) {
|
||||||
|
fillCustomResumeData(data)
|
||||||
|
}
|
||||||
|
|
||||||
/** 关闭弹窗并重置步骤 */
|
/** 关闭弹窗并重置步骤 */
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
currentStep.value = 1
|
currentStep.value = 1
|
||||||
showResumeDropdown.value = false
|
showResumeDropdown.value = false
|
||||||
showDownloadMenu.value = false
|
showDownloadMenu.value = false
|
||||||
|
// 重置AI对话和差异对比状态
|
||||||
|
aiMessages.value = []
|
||||||
|
aiInputText.value = ''
|
||||||
|
aiLoading.value = false
|
||||||
|
isShowDiff.value = false
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,20 +616,71 @@ function initSkillOptions() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取已勾选的优化部分 key 数组(对应 ["summary", "skills", "experience"]) */
|
||||||
|
const selectedSectionKeys = computed(() =>
|
||||||
|
optimizeSections.value.filter(s => s.checked).map(s => s.key)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 获取已勾选的新增技能名称数组 */
|
||||||
|
const selectedNewSkills = computed(() =>
|
||||||
|
newSkillOptions.value.filter(s => s.checked).map(s => s.name)
|
||||||
|
)
|
||||||
|
|
||||||
// ==================== 简历选择(步骤二) ====================
|
// ==================== 简历选择(步骤二) ====================
|
||||||
|
|
||||||
/** 简历列表(模拟数据,后续对接接口) */
|
/** 简历列表(从接口获取) */
|
||||||
const resumeList = ref<ResumeOption[]>([
|
const resumeList = ref<ResumeOption[]>([])
|
||||||
{ id: '1', name: '李华_产品经理', targetJob: '电商产品经理' },
|
|
||||||
{ id: '2', name: '李华_数据分析', targetJob: '数据分析师' },
|
|
||||||
])
|
|
||||||
|
|
||||||
/** 当前选中的简历 */
|
/** 当前选中的简历 */
|
||||||
const selectedResume = ref<ResumeOption>(resumeList.value[0])
|
const selectedResume = ref<ResumeOption>({ id: '', name: '', targetJob: '' })
|
||||||
|
|
||||||
/** 简历下拉是否展开 */
|
/** 简历下拉是否展开 */
|
||||||
const showResumeDropdown = ref(false)
|
const showResumeDropdown = ref(false)
|
||||||
|
|
||||||
|
/** 加载简历列表(调用 /resume/list 接口) */
|
||||||
|
async function loadResumeList() {
|
||||||
|
try {
|
||||||
|
const res = await fetchResumeList()
|
||||||
|
if (res.code === '0' && res.data) {
|
||||||
|
resumeList.value = res.data.map((item: ResumeListItem) => ({
|
||||||
|
id: item.id || '',
|
||||||
|
name: item.resumeName || '未命名简历',
|
||||||
|
targetJob: item.targetPosition || '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[JobResumeCustomDialog] 加载简历列表失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化简历选择(设置默认选中项) */
|
||||||
|
function initResumeSelection() {
|
||||||
|
const defaultResume = props.jobInfo.defaultResume
|
||||||
|
if (defaultResume) {
|
||||||
|
// 用 skill-gap 接口返回的简历作为默认选中
|
||||||
|
const defaultOption: ResumeOption = {
|
||||||
|
id: defaultResume.resumeId,
|
||||||
|
name: defaultResume.resumeName,
|
||||||
|
targetJob: defaultResume.targetPosition || '',
|
||||||
|
}
|
||||||
|
selectedResume.value = defaultOption
|
||||||
|
// 如果列表中没有这个简历,插入到列表头部
|
||||||
|
if (!resumeList.value.find(r => r.id === defaultOption.id)) {
|
||||||
|
resumeList.value.unshift(defaultOption)
|
||||||
|
}
|
||||||
|
} else if (resumeList.value.length > 0) {
|
||||||
|
selectedResume.value = resumeList.value[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监听弹窗打开,加载简历列表 */
|
||||||
|
watch(() => props.modelValue, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
await loadResumeList()
|
||||||
|
initResumeSelection()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/** 切换简历下拉 */
|
/** 切换简历下拉 */
|
||||||
function toggleResumeDropdown() {
|
function toggleResumeDropdown() {
|
||||||
showResumeDropdown.value = !showResumeDropdown.value
|
showResumeDropdown.value = !showResumeDropdown.value
|
||||||
@@ -477,79 +713,41 @@ const resumeTemplateData = ref<ResumeTemplateData>({
|
|||||||
certificates: [],
|
certificates: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 从接口加载个人资料并组装简历数据 */
|
/** 定制简历原始数据(传给编辑面板组件) */
|
||||||
async function loadResumeData() {
|
const customResumeRawData = ref<CustomizeResumeData>({
|
||||||
try {
|
resume: {},
|
||||||
// 并行请求所有个人资料数据
|
})
|
||||||
const [profileRes, eduRes, workRes, internRes, projRes, compRes] = await Promise.all([
|
|
||||||
fetchProfile(),
|
|
||||||
fetchEducation(),
|
|
||||||
fetchWork(),
|
|
||||||
fetchInternship(),
|
|
||||||
fetchProject(),
|
|
||||||
fetchCompetition(),
|
|
||||||
])
|
|
||||||
|
|
||||||
// 组装简历模板数据
|
|
||||||
const profile = profileRes.code === '0' ? profileRes.data : null
|
|
||||||
resumeTemplateData.value = {
|
|
||||||
name: profile?.name || '未填写姓名',
|
|
||||||
email: profile?.email || '',
|
|
||||||
mobileNumber: profile?.mobileNumber || '',
|
|
||||||
wechatNumber: profile?.wechatNumber || '',
|
|
||||||
summary: '', // 个人概述字段,后续由AI生成或用户编辑
|
|
||||||
educations: eduRes.code === '0' && eduRes.data ? eduRes.data.map(e => ({
|
|
||||||
school: e.school || '',
|
|
||||||
major: e.major || '',
|
|
||||||
degree: e.degree || 2,
|
|
||||||
startDate: e.startDate || '',
|
|
||||||
endDate: e.endDate || '',
|
|
||||||
description: e.description,
|
|
||||||
})) : [],
|
|
||||||
workExperiences: workRes.code === '0' && workRes.data ? workRes.data.map(w => ({
|
|
||||||
companyName: w.companyName || '',
|
|
||||||
position: w.position || '',
|
|
||||||
startDate: w.startDate || '',
|
|
||||||
endDate: w.endDate || '',
|
|
||||||
description: w.description,
|
|
||||||
})) : [],
|
|
||||||
internships: internRes.code === '0' && internRes.data ? internRes.data.map(i => ({
|
|
||||||
companyName: i.companyName || '',
|
|
||||||
position: i.position || '',
|
|
||||||
startDate: i.startDate || '',
|
|
||||||
endDate: i.endDate || '',
|
|
||||||
description: i.description,
|
|
||||||
})) : [],
|
|
||||||
projects: projRes.code === '0' && projRes.data ? projRes.data.map(p => ({
|
|
||||||
projectName: p.projectName || '',
|
|
||||||
companyName: p.companyName || '',
|
|
||||||
role: p.role || '',
|
|
||||||
startDate: p.startDate || '',
|
|
||||||
endDate: p.endDate || '',
|
|
||||||
description: p.description,
|
|
||||||
})) : [],
|
|
||||||
competitions: compRes.code === '0' && compRes.data ? compRes.data.map(c => ({
|
|
||||||
competitionName: c.competitionName || '',
|
|
||||||
award: c.award || '',
|
|
||||||
awardDate: c.awardDate || '',
|
|
||||||
description: c.description,
|
|
||||||
})) : [],
|
|
||||||
skills: profile?.skills || [],
|
|
||||||
certificates: profile?.certificates || [],
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[JobResumeCustomDialog] 加载简历数据失败', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前预览右侧tab:ai-AI帮写 / edit-编辑 */
|
/** 当前预览右侧tab:ai-AI帮写 / edit-编辑 */
|
||||||
const previewTab = ref<'ai' | 'edit'>('ai')
|
const previewTab = ref<'ai' | 'edit'>('ai')
|
||||||
|
|
||||||
/** AI优化结果列表(模拟数据) */
|
/** AI优化结果列表(根据步骤三勾选的优化项动态生成) */
|
||||||
const aiOptimizeResults = ref<string[]>([
|
const aiOptimizeResults = computed<string[]>(() => {
|
||||||
'增加了个人概述',
|
const results: string[] = []
|
||||||
'优化了5段经历描述',
|
|
||||||
])
|
// 根据「1.选择你要优化的部分」勾选情况生成说明
|
||||||
|
const checkedSections = optimizeSections.value.filter(s => s.checked)
|
||||||
|
checkedSections.forEach(section => {
|
||||||
|
switch (section.key) {
|
||||||
|
case 'summary':
|
||||||
|
results.push('优化了个人概述,使其更贴合目标岗位要求')
|
||||||
|
break
|
||||||
|
case 'skills':
|
||||||
|
results.push('优化了技能模块,补充了与岗位匹配的关键技能词')
|
||||||
|
break
|
||||||
|
case 'experience':
|
||||||
|
results.push('润色了工作经历描述,融入岗位相关关键词以提升匹配度')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 兜底:如果什么都没勾选,给一个默认提示
|
||||||
|
if (results.length === 0) {
|
||||||
|
results.push('已根据岗位要求对简历进行整体优化')
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
/** AI快捷操作按钮 */
|
/** AI快捷操作按钮 */
|
||||||
const aiQuickActions = ref<string[]>([
|
const aiQuickActions = ref<string[]>([
|
||||||
@@ -558,7 +756,7 @@ const aiQuickActions = ref<string[]>([
|
|||||||
'删掉和这个岗位不相关的技能',
|
'删掉和这个岗位不相关的技能',
|
||||||
])
|
])
|
||||||
|
|
||||||
/** AI聊天消息列表 */
|
/** AI聊天消息列表(界面展示用) */
|
||||||
const aiMessages = ref<AiChatMsg[]>([])
|
const aiMessages = ref<AiChatMsg[]>([])
|
||||||
|
|
||||||
/** AI输入框内容 */
|
/** AI输入框内容 */
|
||||||
@@ -567,12 +765,43 @@ const aiInputText = ref('')
|
|||||||
/** AI消息区域DOM引用 */
|
/** AI消息区域DOM引用 */
|
||||||
const aiMessagesRef = ref<HTMLElement>()
|
const aiMessagesRef = ref<HTMLElement>()
|
||||||
|
|
||||||
/** 发送AI消息 */
|
/** AI是否正在请求中 */
|
||||||
function sendAiMessage(text: string) {
|
const aiLoading = ref(false)
|
||||||
if (!text.trim()) return
|
|
||||||
aiMessages.value.push({ role: 'user', content: text.trim() })
|
/** 是否显示差异对比模式 */
|
||||||
aiInputText.value = ''
|
const isShowDiff = ref(false)
|
||||||
// TODO: 接入AI聊天接口,获取AI回复
|
|
||||||
|
/** 旧简历模板数据(AI修改前的快照,用于差异对比) */
|
||||||
|
const oldResumeTemplateData = ref<ResumeTemplateData>({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
mobileNumber: '',
|
||||||
|
wechatNumber: '',
|
||||||
|
summary: '',
|
||||||
|
educations: [],
|
||||||
|
workExperiences: [],
|
||||||
|
internships: [],
|
||||||
|
projects: [],
|
||||||
|
competitions: [],
|
||||||
|
skills: [],
|
||||||
|
certificates: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建发送给接口的chatHistory格式
|
||||||
|
* 将界面展示的消息列表转换为接口需要的格式
|
||||||
|
*/
|
||||||
|
function buildChatHistory(): AiEditChatMessage[] {
|
||||||
|
return aiMessages.value.map(msg => ({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动AI消息区域到底部
|
||||||
|
*/
|
||||||
|
function scrollAiMessagesToBottom() {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (aiMessagesRef.value) {
|
if (aiMessagesRef.value) {
|
||||||
aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
|
aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
|
||||||
@@ -580,9 +809,117 @@ function sendAiMessage(text: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送AI消息 — 调用AI对话编辑简历接口
|
||||||
|
* @param text 用户输入的消息文本
|
||||||
|
*/
|
||||||
|
async function sendAiMessage(text: string) {
|
||||||
|
if (!text.trim() || aiLoading.value) return
|
||||||
|
|
||||||
|
const userMessage = text.trim()
|
||||||
|
// 添加用户消息到列表
|
||||||
|
aiMessages.value.push({ role: 'user', content: userMessage })
|
||||||
|
// 清空输入框
|
||||||
|
aiInputText.value = ''
|
||||||
|
// 滚动到底部
|
||||||
|
scrollAiMessagesToBottom()
|
||||||
|
|
||||||
|
// 开始请求
|
||||||
|
aiLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await aiEditResume({
|
||||||
|
jobId: props.jobId,
|
||||||
|
instruction: userMessage,
|
||||||
|
chatHistory: buildChatHistory().slice(0, -1), // 不包含刚发送的这条
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.code === 0 && res.data) {
|
||||||
|
const { type, message } = res.data
|
||||||
|
|
||||||
|
// 添加AI助手回复到消息列表
|
||||||
|
aiMessages.value.push({ role: 'assistant', content: message, canRollback: type === 'updated', rollbackStatus: 'idle' })
|
||||||
|
scrollAiMessagesToBottom()
|
||||||
|
|
||||||
|
if (type === 'updated') {
|
||||||
|
// 简历已更新:深拷贝当前简历数据作为旧数据快照
|
||||||
|
oldResumeTemplateData.value = JSON.parse(JSON.stringify(resumeTemplateData.value))
|
||||||
|
|
||||||
|
// 重新查询定制简历数据来刷新简历预览
|
||||||
|
const queryRes = await fetchCustomizeResume()
|
||||||
|
if (queryRes.code === 0 && queryRes.data) {
|
||||||
|
fillCustomResumeData(queryRes.data)
|
||||||
|
// 开启差异对比模式
|
||||||
|
isShowDiff.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// type === 'message' 时只显示对话,不做额外操作
|
||||||
|
} else {
|
||||||
|
// 接口返回异常
|
||||||
|
aiMessages.value.push({ role: 'assistant', content: '抱歉,请求失败了,请稍后重试。' })
|
||||||
|
scrollAiMessagesToBottom()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[JobResumeCustomDialog] AI对话请求失败', e)
|
||||||
|
aiMessages.value.push({ role: 'assistant', content: '网络异常,请稍后重试。' })
|
||||||
|
scrollAiMessagesToBottom()
|
||||||
|
} finally {
|
||||||
|
aiLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 下载菜单是否展开 */
|
/** 下载菜单是否展开 */
|
||||||
const showDownloadMenu = ref(false)
|
const showDownloadMenu = ref(false)
|
||||||
|
|
||||||
|
/** 撤销确认弹窗是否显示 */
|
||||||
|
const showRollbackConfirm = ref(false)
|
||||||
|
|
||||||
|
/** 当前要撤销的消息索引 */
|
||||||
|
const rollbackMsgIndex = ref(-1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击撤销修改按钮 — 弹出确认弹窗
|
||||||
|
* @param msgIndex 消息在列表中的索引
|
||||||
|
*/
|
||||||
|
function handleRollbackClick(msgIndex: number) {
|
||||||
|
rollbackMsgIndex.value = msgIndex
|
||||||
|
showRollbackConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认撤销修改 — 调用 rollback 接口并刷新简历数据
|
||||||
|
*/
|
||||||
|
async function confirmRollback() {
|
||||||
|
showRollbackConfirm.value = false
|
||||||
|
const idx = rollbackMsgIndex.value
|
||||||
|
if (idx < 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await rollbackCustomizeResume()
|
||||||
|
if (res.code === 0) {
|
||||||
|
// 标记该消息为已撤销
|
||||||
|
aiMessages.value[idx].rollbackStatus = 'done'
|
||||||
|
// 关闭差异对比模式
|
||||||
|
isShowDiff.value = false
|
||||||
|
// 重新查询简历数据刷新预览
|
||||||
|
const queryRes = await fetchCustomizeResume()
|
||||||
|
if (queryRes.code === 0 && queryRes.data) {
|
||||||
|
fillCustomResumeData(queryRes.data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error('撤销失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[JobResumeCustomDialog] 撤销修改失败', e)
|
||||||
|
ElMessage.error('撤销失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消撤销 */
|
||||||
|
function cancelRollback() {
|
||||||
|
showRollbackConfirm.value = false
|
||||||
|
rollbackMsgIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
/** 切换下载菜单 */
|
/** 切换下载菜单 */
|
||||||
function toggleDownloadMenu() {
|
function toggleDownloadMenu() {
|
||||||
showDownloadMenu.value = !showDownloadMenu.value
|
showDownloadMenu.value = !showDownloadMenu.value
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 定制简历编辑面板 — 手风琴折叠式,点击模块名展开编辑,失焦/选择后自动调用PUT接口保存 -->
|
||||||
|
<div class="job-resume-edit">
|
||||||
|
<!-- 遍历所有模块,统一渲染折叠面板 -->
|
||||||
|
<div v-for="sec in sectionList" :key="sec.key" class="job-resume-edit__section">
|
||||||
|
<!-- 模块标题栏 -->
|
||||||
|
<div class="job-resume-edit__section-header" @click="toggle(sec.key)">
|
||||||
|
<span class="job-resume-edit__section-title">{{ sec.label }}</span>
|
||||||
|
<svg viewBox="0 0 12 12" fill="none" class="job-resume-edit__section-arrow" :class="{ 'job-resume-edit__section-arrow--open': openSections[sec.key] }">
|
||||||
|
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开内容区 -->
|
||||||
|
<div v-if="openSections[sec.key]" class="job-resume-edit__section-body">
|
||||||
|
|
||||||
|
<!-- ========== 基本信息 ========== -->
|
||||||
|
<template v-if="sec.key === 'info'">
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">姓名</label><input class="job-resume-edit__input" placeholder="请输入姓名" v-model="editData.resume.name" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">邮箱</label><input class="job-resume-edit__input" type="email" placeholder="请输入邮箱" v-model="editData.resume.email" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">电话</label><input class="job-resume-edit__input" type="tel" placeholder="请输入电话" v-model="editData.resume.mobileNumber" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">城市</label><input class="job-resume-edit__input" placeholder="请输入城市" v-model="editData.resume.city" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">微信</label><input class="job-resume-edit__input" placeholder="请输入微信号" v-model="editData.resume.wechatNumber" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">作品集链接</label><input class="job-resume-edit__input" placeholder="请输入作品集链接" v-model="editData.resume.portfolioUrl" @blur="autoSave" /></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ========== 个人概述 ========== -->
|
||||||
|
<template v-else-if="sec.key === 'summary'">
|
||||||
|
<textarea class="job-resume-edit__textarea" placeholder="请输入个人概述" v-model="editData.resume.summary" rows="4" @blur="autoSave"></textarea>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ========== 教育经历 ========== -->
|
||||||
|
<template v-else-if="sec.key === 'education'">
|
||||||
|
<div v-for="(edu, idx) in editData.education" :key="'edu-'+idx" class="job-resume-edit__card">
|
||||||
|
<div class="job-resume-edit__card-header">
|
||||||
|
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>教育经历{{ idx + 1 }}</span>
|
||||||
|
<button v-if="editData.education.length > 1" class="job-resume-edit__card-delete" @click="removeItem('education', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">学校</label><input class="job-resume-edit__input" placeholder="请输入学校" v-model="edu.school" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">专业</label><input class="job-resume-edit__input" placeholder="请输入专业" v-model="edu.major" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__row">
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">学历类型</label><select class="job-resume-edit__select" v-model="edu.studyType" @change="autoSave"><option value="全日制">全日制</option><option value="非全日制">非全日制</option></select></div>
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">学历</label><select class="job-resume-edit__select" v-model="edu.degree" @change="autoSave"><option value="大专">大专</option><option value="本科">本科</option><option value="硕士">硕士</option><option value="博士">博士</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__row">
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">入学时间</label><el-date-picker v-model="edu.startDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">毕业时间</label><el-date-picker v-model="edu.endDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field">
|
||||||
|
<label class="job-resume-edit__label">经历描述</label>
|
||||||
|
<div v-for="(desc, dIdx) in edu.description" :key="desc.id" class="job-resume-edit__desc-item">
|
||||||
|
<textarea class="job-resume-edit__textarea job-resume-edit__textarea--with-remove" placeholder="请输入描述" v-model="desc.text" rows="3" @blur="autoSave"></textarea>
|
||||||
|
<button v-if="edu.description.length > 1" class="job-resume-edit__desc-remove" @click="removeDesc('education', idx, dIdx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__desc-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<button class="job-resume-edit__add-btn" @click="addDesc('education', idx)"><span class="job-resume-edit__add-icon">+</span> 新增描述</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('education')"><span class="job-resume-edit__add-icon">+</span> 新增教育经历</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ========== 实习经历 ========== -->
|
||||||
|
<template v-else-if="sec.key === 'internship'">
|
||||||
|
<div v-for="(item, idx) in editData.internship" :key="'intern-'+idx" class="job-resume-edit__card">
|
||||||
|
<div class="job-resume-edit__card-header">
|
||||||
|
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>实习经历{{ idx + 1 }}</span>
|
||||||
|
<button v-if="editData.internship.length > 1" class="job-resume-edit__card-delete" @click="removeItem('internship', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">公司名称</label><input class="job-resume-edit__input" placeholder="请输入公司名称" v-model="item.companyName" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">职位</label><input class="job-resume-edit__input" placeholder="请输入职位" v-model="item.position" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__row">
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">开始时间</label><el-date-picker v-model="item.startDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">结束时间</label><el-date-picker v-model="item.endDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field">
|
||||||
|
<label class="job-resume-edit__label">经历描述</label>
|
||||||
|
<div v-for="(desc, dIdx) in item.description" :key="desc.id" class="job-resume-edit__desc-item">
|
||||||
|
<textarea class="job-resume-edit__textarea job-resume-edit__textarea--with-remove" placeholder="请输入描述" v-model="desc.text" rows="3" @blur="autoSave"></textarea>
|
||||||
|
<button v-if="item.description.length > 1" class="job-resume-edit__desc-remove" @click="removeDesc('internship', idx, dIdx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__desc-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<button class="job-resume-edit__add-btn" @click="addDesc('internship', idx)"><span class="job-resume-edit__add-icon">+</span> 新增描述</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('internship')"><span class="job-resume-edit__add-icon">+</span> 新增实习经历</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ========== 工作经历 ========== -->
|
||||||
|
<template v-else-if="sec.key === 'work'">
|
||||||
|
<div v-for="(item, idx) in editData.work" :key="'work-'+idx" class="job-resume-edit__card">
|
||||||
|
<div class="job-resume-edit__card-header">
|
||||||
|
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>工作经历{{ idx + 1 }}</span>
|
||||||
|
<button v-if="editData.work.length > 1" class="job-resume-edit__card-delete" @click="removeItem('work', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">公司名称</label><input class="job-resume-edit__input" placeholder="请输入公司名称" v-model="item.companyName" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">职位</label><input class="job-resume-edit__input" placeholder="请输入职位" v-model="item.position" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__row">
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">开始时间</label><el-date-picker v-model="item.startDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">结束时间</label><el-date-picker v-model="item.endDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field">
|
||||||
|
<label class="job-resume-edit__label">经历描述</label>
|
||||||
|
<div v-for="(desc, dIdx) in item.description" :key="desc.id" class="job-resume-edit__desc-item">
|
||||||
|
<textarea class="job-resume-edit__textarea job-resume-edit__textarea--with-remove" placeholder="请输入描述" v-model="desc.text" rows="3" @blur="autoSave"></textarea>
|
||||||
|
<button v-if="item.description.length > 1" class="job-resume-edit__desc-remove" @click="removeDesc('work', idx, dIdx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__desc-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<button class="job-resume-edit__add-btn" @click="addDesc('work', idx)"><span class="job-resume-edit__add-icon">+</span> 新增描述</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('work')"><span class="job-resume-edit__add-icon">+</span> 新增工作经历</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ========== 项目经历 ========== -->
|
||||||
|
<template v-else-if="sec.key === 'project'">
|
||||||
|
<div v-for="(item, idx) in editData.project" :key="'proj-'+idx" class="job-resume-edit__card">
|
||||||
|
<div class="job-resume-edit__card-header">
|
||||||
|
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>项目经历{{ idx + 1 }}</span>
|
||||||
|
<button v-if="editData.project.length > 1" class="job-resume-edit__card-delete" @click="removeItem('project', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">项目名称</label><input class="job-resume-edit__input" placeholder="请输入项目名称" v-model="item.projectName" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">所属公司</label><input class="job-resume-edit__input" placeholder="请输入所属公司" v-model="item.companyName" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">项目角色</label><input class="job-resume-edit__input" placeholder="请输入项目角色" v-model="item.role" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__row">
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">开始时间</label><el-date-picker v-model="item.startDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field job-resume-edit__field--half"><label class="job-resume-edit__label">结束时间</label><el-date-picker v-model="item.endDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field">
|
||||||
|
<label class="job-resume-edit__label">经历描述</label>
|
||||||
|
<div v-for="(desc, dIdx) in item.description" :key="desc.id" class="job-resume-edit__desc-item">
|
||||||
|
<textarea class="job-resume-edit__textarea job-resume-edit__textarea--with-remove" placeholder="请输入描述" v-model="desc.text" rows="3" @blur="autoSave"></textarea>
|
||||||
|
<button v-if="item.description.length > 1" class="job-resume-edit__desc-remove" @click="removeDesc('project', idx, dIdx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__desc-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<button class="job-resume-edit__add-btn" @click="addDesc('project', idx)"><span class="job-resume-edit__add-icon">+</span> 新增描述</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('project')"><span class="job-resume-edit__add-icon">+</span> 新增项目经历</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ========== 竞赛 ========== -->
|
||||||
|
<template v-else-if="sec.key === 'competition'">
|
||||||
|
<div v-for="(item, idx) in editData.competition" :key="'comp-'+idx" class="job-resume-edit__card">
|
||||||
|
<div class="job-resume-edit__card-header">
|
||||||
|
<span class="job-resume-edit__card-index"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-index-dot"><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>竞赛{{ idx + 1 }}</span>
|
||||||
|
<button v-if="editData.competition.length > 1" class="job-resume-edit__card-delete" @click="removeItem('competition', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__card-delete-icon"><path d="M3 4h10M6 4V3a1 1 0 011-1h2a1 1 0 011 1v1M5 4v8a1 1 0 001 1h4a1 1 0 001-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">竞赛名称</label><input class="job-resume-edit__input" placeholder="请输入竞赛名称" v-model="item.competitionName" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">获奖名次</label><input class="job-resume-edit__input" placeholder="请输入获奖名次" v-model="item.award" @blur="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field"><label class="job-resume-edit__label">获奖时间</label><el-date-picker v-model="item.awardDate" type="month" placeholder="选择" format="YYYY-MM" value-format="YYYY-MM" class="job-resume-edit__date-picker" :teleported="true" @change="autoSave" /></div>
|
||||||
|
<div class="job-resume-edit__field">
|
||||||
|
<label class="job-resume-edit__label">获奖描述</label>
|
||||||
|
<textarea class="job-resume-edit__textarea" placeholder="请输入获奖描述" v-model="item.description[0].text" rows="3" @blur="autoSave"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="job-resume-edit__add-btn job-resume-edit__add-btn--full" @click="addItem('competition')"><span class="job-resume-edit__add-icon">+</span> 新增竞赛</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ========== 证书 ========== -->
|
||||||
|
<template v-else-if="sec.key === 'certificate'">
|
||||||
|
<div class="job-resume-edit__tags">
|
||||||
|
<div v-for="(cert, idx) in editData.resume.certificates" :key="'cert-'+idx" class="job-resume-edit__tag">
|
||||||
|
<span>{{ cert }}</span>
|
||||||
|
<button class="job-resume-edit__tag-remove" @click="removeTag('certificates', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__tag-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field">
|
||||||
|
<input class="job-resume-edit__input" placeholder="输入证书名称后按回车添加" v-model="newCertInput" @keydown.enter="addTag('certificates')" @blur="addTag('certificates')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ========== 技能 ========== -->
|
||||||
|
<template v-else-if="sec.key === 'skills'">
|
||||||
|
<div class="job-resume-edit__tags">
|
||||||
|
<div v-for="(skill, idx) in editData.resume.skills" :key="'skill-'+idx" class="job-resume-edit__tag">
|
||||||
|
<span>{{ skill }}</span>
|
||||||
|
<button class="job-resume-edit__tag-remove" @click="removeTag('skills', idx)"><svg viewBox="0 0 16 16" fill="none" class="job-resume-edit__tag-remove-icon"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-resume-edit__field">
|
||||||
|
<input class="job-resume-edit__input" placeholder="输入技能名称后按回车添加" v-model="newSkillInput" @keydown.enter="addTag('skills')" @blur="addTag('skills')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ========== 作品集 ========== -->
|
||||||
|
<template v-else-if="sec.key === 'portfolio'">
|
||||||
|
<div class="job-resume-edit__field">
|
||||||
|
<input class="job-resume-edit__input" placeholder="请输入作品集链接" v-model="editData.resume.portfolioUrl" @blur="autoSave" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { updateCustomizeResume } from '@/api/jobs'
|
||||||
|
import type { CustomizeResumeData } from '@/api/jobs'
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
/** 描述段落 */
|
||||||
|
interface DescParagraph {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 教育经历编辑项 */
|
||||||
|
interface EduEditItem {
|
||||||
|
id: string; school: string; major: string; degree: string; studyType: string
|
||||||
|
startDate: string; endDate: string; description: DescParagraph[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作/实习经历编辑项 */
|
||||||
|
interface WorkEditItem {
|
||||||
|
id: string; companyName: string; position: string
|
||||||
|
startDate: string; endDate: string; description: DescParagraph[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目经历编辑项 */
|
||||||
|
interface ProjectEditItem {
|
||||||
|
id: string; companyName: string; projectName: string; role: string
|
||||||
|
startDate: string; endDate: string; description: DescParagraph[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 竞赛经历编辑项 */
|
||||||
|
interface CompEditItem {
|
||||||
|
id: string; competitionName: string; award: string; awardDate: string
|
||||||
|
description: DescParagraph[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑数据完整结构 */
|
||||||
|
interface EditFormData {
|
||||||
|
resume: {
|
||||||
|
avatarUrl: string; name: string; email: string; mobileNumber: string
|
||||||
|
city: string; wechatNumber: string; portfolioUrl: string
|
||||||
|
skills: string[]; certificates: string[]; summary: string
|
||||||
|
}
|
||||||
|
education: EduEditItem[]
|
||||||
|
work: WorkEditItem[]
|
||||||
|
internship: WorkEditItem[]
|
||||||
|
project: ProjectEditItem[]
|
||||||
|
competition: CompEditItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Props ====================
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 定制简历数据(从父组件传入) */
|
||||||
|
resumeData: CustomizeResumeData
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** 数据变更时通知父组件同步更新简历模板预览 */
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update', data: CustomizeResumeData): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ==================== 模块列表配置 ====================
|
||||||
|
|
||||||
|
/** 折叠面板模块列表 */
|
||||||
|
const sectionList = [
|
||||||
|
{ key: 'info', label: '基本信息' },
|
||||||
|
{ key: 'summary', label: '个人概述' },
|
||||||
|
{ key: 'education', label: '教育经历' },
|
||||||
|
{ key: 'internship', label: '实习经历' },
|
||||||
|
{ key: 'work', label: '工作经历' },
|
||||||
|
{ key: 'project', label: '项目经历' },
|
||||||
|
{ key: 'competition', label: '竞赛' },
|
||||||
|
{ key: 'certificate', label: '证书' },
|
||||||
|
{ key: 'skills', label: '技能' },
|
||||||
|
{ key: 'portfolio', label: '作品集' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 各模块展开状态 */
|
||||||
|
const openSections = reactive<Record<string, boolean>>({
|
||||||
|
info: false, summary: false, education: false, internship: false,
|
||||||
|
work: false, project: false, competition: false, certificate: false,
|
||||||
|
skills: false, portfolio: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 切换模块展开/收起 */
|
||||||
|
function toggle(key: string) {
|
||||||
|
openSections[key] = !openSections[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 编辑数据 ====================
|
||||||
|
|
||||||
|
/** 生成短随机ID */
|
||||||
|
function genId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑表单数据(响应式) */
|
||||||
|
const editData = reactive<EditFormData>({
|
||||||
|
resume: { avatarUrl: '', name: '', email: '', mobileNumber: '', city: '', wechatNumber: '', portfolioUrl: '', skills: [], certificates: [], summary: '' },
|
||||||
|
education: [],
|
||||||
|
work: [],
|
||||||
|
internship: [],
|
||||||
|
project: [],
|
||||||
|
competition: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 从 props.resumeData 初始化编辑数据 */
|
||||||
|
function initEditData(data: CustomizeResumeData) {
|
||||||
|
const r = data.resume || {}
|
||||||
|
editData.resume.avatarUrl = r.avatarUrl || ''
|
||||||
|
editData.resume.name = r.name || ''
|
||||||
|
editData.resume.email = r.email || ''
|
||||||
|
editData.resume.mobileNumber = r.mobileNumber || ''
|
||||||
|
editData.resume.city = r.city || ''
|
||||||
|
editData.resume.wechatNumber = r.wechatNumber || ''
|
||||||
|
editData.resume.portfolioUrl = r.portfolioUrl || ''
|
||||||
|
editData.resume.skills = r.skills ? [...r.skills] : []
|
||||||
|
editData.resume.certificates = r.certificates ? [...r.certificates] : []
|
||||||
|
editData.resume.summary = r.summary || ''
|
||||||
|
|
||||||
|
editData.education = (data.education || []).map(e => ({
|
||||||
|
id: e.id || genId(), school: e.school || '', major: e.major || '',
|
||||||
|
degree: e.degree || '本科', studyType: e.studyType || '全日制',
|
||||||
|
startDate: e.startDate || '', endDate: e.endDate || '',
|
||||||
|
description: (e.description || []).map(d => ({ id: d.id || genId(), text: d.text || '' })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
editData.work = (data.work || []).map(w => ({
|
||||||
|
id: w.id || genId(), companyName: w.companyName || '', position: w.position || '',
|
||||||
|
startDate: w.startDate || '', endDate: w.endDate || '',
|
||||||
|
description: (w.description || []).map(d => ({ id: d.id || genId(), text: d.text || '' })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
editData.internship = (data.internship || []).map(i => ({
|
||||||
|
id: i.id || genId(), companyName: i.companyName || '', position: i.position || '',
|
||||||
|
startDate: i.startDate || '', endDate: i.endDate || '',
|
||||||
|
description: (i.description || []).map(d => ({ id: d.id || genId(), text: d.text || '' })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
editData.project = (data.project || []).map(p => ({
|
||||||
|
id: p.id || genId(), companyName: p.companyName || '', projectName: p.projectName || '',
|
||||||
|
role: p.role || '', startDate: p.startDate || '', endDate: p.endDate || '',
|
||||||
|
description: (p.description || []).map(d => ({ id: d.id || genId(), text: d.text || '' })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
editData.competition = (data.competition || []).map(c => ({
|
||||||
|
id: c.id || genId(), competitionName: c.competitionName || '', award: c.award || '',
|
||||||
|
awardDate: c.awardDate || '',
|
||||||
|
description: c.description?.length ? c.description.map(d => ({ id: d.id || genId(), text: d.text || '' })) : [{ id: genId(), text: '' }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监听 props 变化,初始化编辑数据 */
|
||||||
|
watch(() => props.resumeData, (val) => {
|
||||||
|
if (val) initEditData(val)
|
||||||
|
}, { immediate: true, deep: true })
|
||||||
|
|
||||||
|
// ==================== 自动保存(失焦/选择后触发PUT接口) ====================
|
||||||
|
|
||||||
|
/** 防抖定时器 */
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
/** 将编辑数据转换为接口所需的 CustomizeResumeData 格式 */
|
||||||
|
function buildSavePayload(): CustomizeResumeData {
|
||||||
|
return {
|
||||||
|
resume: {
|
||||||
|
avatarUrl: editData.resume.avatarUrl,
|
||||||
|
name: editData.resume.name,
|
||||||
|
email: editData.resume.email,
|
||||||
|
mobileNumber: editData.resume.mobileNumber,
|
||||||
|
city: editData.resume.city,
|
||||||
|
wechatNumber: editData.resume.wechatNumber,
|
||||||
|
portfolioUrl: editData.resume.portfolioUrl,
|
||||||
|
skills: [...editData.resume.skills],
|
||||||
|
certificates: [...editData.resume.certificates],
|
||||||
|
summary: editData.resume.summary,
|
||||||
|
},
|
||||||
|
education: editData.education.map(e => ({
|
||||||
|
id: e.id, school: e.school, major: e.major, degree: e.degree,
|
||||||
|
studyType: e.studyType, startDate: e.startDate, endDate: e.endDate,
|
||||||
|
description: e.description.map(d => ({ id: d.id, text: d.text })),
|
||||||
|
})),
|
||||||
|
work: editData.work.map(w => ({
|
||||||
|
id: w.id, companyName: w.companyName, position: w.position,
|
||||||
|
startDate: w.startDate, endDate: w.endDate,
|
||||||
|
description: w.description.map(d => ({ id: d.id, text: d.text })),
|
||||||
|
})),
|
||||||
|
internship: editData.internship.map(i => ({
|
||||||
|
id: i.id, companyName: i.companyName, position: i.position,
|
||||||
|
startDate: i.startDate, endDate: i.endDate,
|
||||||
|
description: i.description.map(d => ({ id: d.id, text: d.text })),
|
||||||
|
})),
|
||||||
|
project: editData.project.map(p => ({
|
||||||
|
id: p.id, companyName: p.companyName, projectName: p.projectName,
|
||||||
|
role: p.role, startDate: p.startDate, endDate: p.endDate,
|
||||||
|
description: p.description.map(d => ({ id: d.id, text: d.text })),
|
||||||
|
})),
|
||||||
|
competition: editData.competition.map(c => ({
|
||||||
|
id: c.id, competitionName: c.competitionName, award: c.award,
|
||||||
|
awardDate: c.awardDate,
|
||||||
|
description: c.description.map(d => ({ id: d.id, text: d.text })),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自动保存 — 防抖300ms后调用PUT接口,同时通知父组件更新预览 */
|
||||||
|
function autoSave() {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(async () => {
|
||||||
|
const payload = buildSavePayload()
|
||||||
|
// 通知父组件同步更新简历模板预览
|
||||||
|
emit('update', payload)
|
||||||
|
try {
|
||||||
|
await updateCustomizeResume(payload)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[JobResumeCustomEditPanel] 自动保存失败', e)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 列表操作(新增/删除条目、描述段落) ====================
|
||||||
|
|
||||||
|
/** 新增一条经历记录 */
|
||||||
|
function addItem(type: 'education' | 'work' | 'internship' | 'project' | 'competition') {
|
||||||
|
const id = genId()
|
||||||
|
const emptyDesc: DescParagraph[] = [{ id: genId(), text: '' }]
|
||||||
|
if (type === 'education') {
|
||||||
|
editData.education.push({ id, school: '', major: '', degree: '本科', studyType: '全日制', startDate: '', endDate: '', description: emptyDesc })
|
||||||
|
} else if (type === 'work') {
|
||||||
|
editData.work.push({ id, companyName: '', position: '', startDate: '', endDate: '', description: emptyDesc })
|
||||||
|
} else if (type === 'internship') {
|
||||||
|
editData.internship.push({ id, companyName: '', position: '', startDate: '', endDate: '', description: emptyDesc })
|
||||||
|
} else if (type === 'project') {
|
||||||
|
editData.project.push({ id, companyName: '', projectName: '', role: '', startDate: '', endDate: '', description: emptyDesc })
|
||||||
|
} else if (type === 'competition') {
|
||||||
|
editData.competition.push({ id, competitionName: '', award: '', awardDate: '', description: emptyDesc })
|
||||||
|
}
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除一条经历记录 */
|
||||||
|
function removeItem(type: 'education' | 'work' | 'internship' | 'project' | 'competition', idx: number) {
|
||||||
|
editData[type].splice(idx, 1)
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增一条描述段落 */
|
||||||
|
function addDesc(type: 'education' | 'work' | 'internship' | 'project', itemIdx: number) {
|
||||||
|
editData[type][itemIdx].description.push({ id: genId(), text: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除一条描述段落 */
|
||||||
|
function removeDesc(type: 'education' | 'work' | 'internship' | 'project', itemIdx: number, descIdx: number) {
|
||||||
|
editData[type][itemIdx].description.splice(descIdx, 1)
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 标签操作(技能/证书) ====================
|
||||||
|
|
||||||
|
/** 新技能输入 */
|
||||||
|
const newSkillInput = ref('')
|
||||||
|
/** 新证书输入 */
|
||||||
|
const newCertInput = ref('')
|
||||||
|
|
||||||
|
/** 添加标签(技能或证书) */
|
||||||
|
function addTag(field: 'skills' | 'certificates') {
|
||||||
|
const inputRef = field === 'skills' ? newSkillInput : newCertInput
|
||||||
|
const val = inputRef.value.trim()
|
||||||
|
if (!val) { inputRef.value = ''; return }
|
||||||
|
const list = editData.resume[field]
|
||||||
|
if (!list.includes(val)) {
|
||||||
|
list.push(val)
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
inputRef.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除标签 */
|
||||||
|
function removeTag(field: 'skills' | 'certificates', idx: number) {
|
||||||
|
editData.resume[field].splice(idx, 1)
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -3,7 +3,15 @@
|
|||||||
<div class="job-resume-template" ref="resumeRef">
|
<div class="job-resume-template" ref="resumeRef">
|
||||||
<div class="resume-html">
|
<div class="resume-html">
|
||||||
<!-- 姓名 -->
|
<!-- 姓名 -->
|
||||||
<h1 class="resume-html__name">{{ resumeData.name }}</h1>
|
<h1 class="resume-html__name">
|
||||||
|
<template v-if="showDiff">
|
||||||
|
<template v-for="(seg, si) in diffText(oldResumeData?.name, resumeData.name)" :key="'name-' + 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>{{ resumeData.name }}</template>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<!-- 联系方式 -->
|
<!-- 联系方式 -->
|
||||||
<div class="resume-html__contact">
|
<div class="resume-html__contact">
|
||||||
@@ -19,7 +27,15 @@
|
|||||||
<template v-if="resumeData.summary">
|
<template v-if="resumeData.summary">
|
||||||
<div class="resume-html__section-title">个人概述</div>
|
<div class="resume-html__section-title">个人概述</div>
|
||||||
<div class="resume-html__divider"></div>
|
<div class="resume-html__divider"></div>
|
||||||
<div class="resume-html__summary">{{ resumeData.summary }}</div>
|
<div class="resume-html__summary">
|
||||||
|
<template v-if="showDiff">
|
||||||
|
<template v-for="(seg, si) in diffText(oldResumeData?.summary, resumeData.summary)" :key="'sum-' + 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>{{ resumeData.summary }}</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 教育背景 -->
|
<!-- 教育背景 -->
|
||||||
@@ -31,7 +47,13 @@
|
|||||||
<div class="resume-html__item-left">
|
<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">{{ edu.school }},{{ edu.major }},{{ degreeText(edu.degree) }}</span>
|
||||||
<span v-if="edu.description && edu.description.length" class="resume-html__item-desc">
|
<span v-if="edu.description && edu.description.length" class="resume-html__item-desc">
|
||||||
主修课程:{{ edu.description.map(d => d.text).join('、') }}等;
|
<!-- 教育经历描述差异对比 -->
|
||||||
|
<template v-if="showDiff">
|
||||||
|
主修课程:<template v-for="(desc, di) in edu.description" :key="'ed-' + di"><template v-if="di > 0">、</template><template v-for="(seg, si) in diffDescText(oldResumeData?.educations, idx, di, desc.text)" :key="'eds-' + si"><span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span><template v-else>{{ seg.text }}</template></template></template>等;
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
主修课程:{{ edu.description.map(d => d.text).join('、') }}等;
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="resume-html__item-right">
|
<div class="resume-html__item-right">
|
||||||
@@ -55,7 +77,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="work.description && work.description.length" class="resume-html__desc-list">
|
<ul v-if="work.description && work.description.length" class="resume-html__desc-list">
|
||||||
<li v-for="(desc, di) in work.description" :key="'wd-' + di">{{ desc.text }}</li>
|
<li v-for="(desc, di) in work.description" :key="'wd-' + di">
|
||||||
|
<template v-if="showDiff">
|
||||||
|
<template v-for="(seg, si) in diffListText(oldResumeData?.workExperiences, idx, di, desc.text)" :key="'wds-' + 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>{{ desc.text }}</template>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -73,7 +103,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="intern.description && intern.description.length" class="resume-html__desc-list">
|
<ul v-if="intern.description && intern.description.length" class="resume-html__desc-list">
|
||||||
<li v-for="(desc, di) in intern.description" :key="'id-' + di">{{ desc.text }}</li>
|
<li v-for="(desc, di) in intern.description" :key="'id-' + di">
|
||||||
|
<template v-if="showDiff">
|
||||||
|
<template v-for="(seg, si) in diffListText(oldResumeData?.internships, idx, di, desc.text)" :key="'ids-' + 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>{{ desc.text }}</template>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -91,7 +129,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="proj.description && proj.description.length" class="resume-html__desc-list">
|
<ul v-if="proj.description && proj.description.length" class="resume-html__desc-list">
|
||||||
<li v-for="(desc, di) in proj.description" :key="'pd-' + di">{{ desc.text }}</li>
|
<li v-for="(desc, di) in proj.description" :key="'pd-' + di">
|
||||||
|
<template v-if="showDiff">
|
||||||
|
<template v-for="(seg, si) in diffListText(oldResumeData?.projects, idx, di, desc.text, 'projects')" :key="'pds-' + 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>{{ desc.text }}</template>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -106,7 +152,15 @@
|
|||||||
<span class="resume-html__item-date" v-if="comp.awardDate">{{ comp.awardDate }}</span>
|
<span class="resume-html__item-date" v-if="comp.awardDate">{{ comp.awardDate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="comp.description && comp.description.length" class="resume-html__desc-list">
|
<ul v-if="comp.description && comp.description.length" class="resume-html__desc-list">
|
||||||
<li v-for="(desc, di) in comp.description" :key="'cd-' + di">{{ desc.text }}</li>
|
<li v-for="(desc, di) in comp.description" :key="'cd-' + di">
|
||||||
|
<template v-if="showDiff">
|
||||||
|
<template v-for="(seg, si) in diffListText(oldResumeData?.competitions, idx, di, desc.text, 'competitions')" :key="'cds-' + 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>{{ desc.text }}</template>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -118,11 +172,27 @@
|
|||||||
<div class="resume-html__skills">
|
<div class="resume-html__skills">
|
||||||
<div v-if="resumeData.skills && resumeData.skills.length" class="resume-html__skill-row">
|
<div v-if="resumeData.skills && resumeData.skills.length" class="resume-html__skill-row">
|
||||||
<span class="resume-html__skill-label">技能:</span>
|
<span class="resume-html__skill-label">技能:</span>
|
||||||
<span>{{ resumeData.skills.join('、') }}</span>
|
<template v-if="showDiff">
|
||||||
|
<span>
|
||||||
|
<template v-for="(seg, si) in diffText(oldResumeData?.skills?.join('、'), resumeData.skills.join('、'))" :key="'sk-' + si">
|
||||||
|
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
|
||||||
|
<template v-else>{{ seg.text }}</template>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ resumeData.skills.join('、') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="resumeData.certificates && resumeData.certificates.length" class="resume-html__skill-row">
|
<div v-if="resumeData.certificates && resumeData.certificates.length" class="resume-html__skill-row">
|
||||||
<span class="resume-html__skill-label">证书:</span>
|
<span class="resume-html__skill-label">证书:</span>
|
||||||
<span>{{ resumeData.certificates.join('、') }}</span>
|
<template v-if="showDiff">
|
||||||
|
<span>
|
||||||
|
<template v-for="(seg, si) in diffText(oldResumeData?.certificates?.join('、'), resumeData.certificates.join('、'))" :key="'ct-' + si">
|
||||||
|
<span v-if="seg.highlight" class="resume-html__diff-highlight">{{ seg.text }}</span>
|
||||||
|
<template v-else>{{ seg.text }}</template>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ resumeData.certificates.join('、') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -132,6 +202,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { computeDiff, type DiffSegment } from '@/utils/textDiff'
|
||||||
|
|
||||||
// ==================== 类型定义 ====================
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
@@ -190,7 +261,7 @@ export interface ResumeTemplateData {
|
|||||||
mobileNumber?: string
|
mobileNumber?: string
|
||||||
/** 微信号 */
|
/** 微信号 */
|
||||||
wechatNumber?: string
|
wechatNumber?: string
|
||||||
/** 个人概述(新增字段) */
|
/** 个人概述 */
|
||||||
summary?: string
|
summary?: string
|
||||||
/** 教育背景 */
|
/** 教育背景 */
|
||||||
educations?: ResumeEducation[]
|
educations?: ResumeEducation[]
|
||||||
@@ -213,6 +284,10 @@ export interface ResumeTemplateData {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 简历数据 */
|
/** 简历数据 */
|
||||||
resumeData: ResumeTemplateData
|
resumeData: ResumeTemplateData
|
||||||
|
/** 是否显示差异对比模式,默认false显示普通模板 */
|
||||||
|
showDiff?: boolean
|
||||||
|
/** 旧简历数据(差异对比时使用,与resumeData对比) */
|
||||||
|
oldResumeData?: ResumeTemplateData
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ==================== DOM引用(供父组件获取HTML内容) ====================
|
// ==================== DOM引用(供父组件获取HTML内容) ====================
|
||||||
@@ -235,4 +310,52 @@ const hasSkillsSection = computed(() => {
|
|||||||
return (props.resumeData.skills && props.resumeData.skills.length > 0) ||
|
return (props.resumeData.skills && props.resumeData.skills.length > 0) ||
|
||||||
(props.resumeData.certificates && props.resumeData.certificates.length > 0)
|
(props.resumeData.certificates && props.resumeData.certificates.length > 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ==================== 差异对比工具方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比两段纯文本,返回差异片段数组
|
||||||
|
* @param oldText 旧文本
|
||||||
|
* @param newText 新文本
|
||||||
|
*/
|
||||||
|
function diffText(oldText?: string, newText?: string): DiffSegment[] {
|
||||||
|
if (!newText) return []
|
||||||
|
if (!oldText) return [{ text: newText, highlight: true }]
|
||||||
|
if (oldText === newText) return [{ text: newText, highlight: false }]
|
||||||
|
return computeDiff(oldText, newText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比带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]) {
|
||||||
|
return [{ text: newText, highlight: true }]
|
||||||
|
}
|
||||||
|
const oldText = oldList[itemIdx].description[descIdx].text || ''
|
||||||
|
if (oldText === newText) return [{ text: newText, highlight: false }]
|
||||||
|
return computeDiff(oldText, newText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比教育经历描述文本的差异
|
||||||
|
* @param oldEducations 旧教育经历列表
|
||||||
|
* @param eduIdx 教育经历索引
|
||||||
|
* @param descIdx 描述段落索引
|
||||||
|
* @param newText 新文本
|
||||||
|
*/
|
||||||
|
function diffDescText(oldEducations: ResumeEducation[] | undefined, eduIdx: number, descIdx: number, newText: string): DiffSegment[] {
|
||||||
|
if (!oldEducations || !oldEducations[eduIdx] || !oldEducations[eduIdx].description || !oldEducations[eduIdx].description![descIdx]) {
|
||||||
|
return [{ text: newText, highlight: true }]
|
||||||
|
}
|
||||||
|
const oldText = oldEducations[eduIdx].description![descIdx].text || ''
|
||||||
|
if (oldText === newText) return [{ text: newText, highlight: false }]
|
||||||
|
return computeDiff(oldText, newText)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 简历分析报告抽屉 — 从右侧滑入,占满屏幕高度 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<!-- 遮罩层 -->
|
||||||
|
<Transition name="report-drawer-overlay">
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="report-drawer-overlay"
|
||||||
|
@click="handleClose"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- 抽屉主体 -->
|
||||||
|
<Transition name="report-drawer-slide">
|
||||||
|
<div v-if="modelValue" class="report-drawer" @click.stop>
|
||||||
|
<!-- 顶部栏:折叠按钮 + 标题 + 更新时间 -->
|
||||||
|
<div class="report-drawer__header">
|
||||||
|
<div class="report-drawer__header-left">
|
||||||
|
<button class="report-drawer__close-btn" @click="handleClose" aria-label="收起">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" class="report-drawer__close-icon">
|
||||||
|
<path d="M10 3l-5 5 5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h3 class="report-drawer__title">简历分析报告</h3>
|
||||||
|
</div>
|
||||||
|
<span class="report-drawer__update-time">{{ updateTimeText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可滚动内容区 -->
|
||||||
|
<div class="report-drawer__body">
|
||||||
|
<!-- 评分概览区域 -->
|
||||||
|
<div class="report-drawer__score-section">
|
||||||
|
<div class="report-drawer__score-left">
|
||||||
|
<!-- 评级字母 -->
|
||||||
|
<span class="report-drawer__grade-avatar">{{ report.grade }}</span>
|
||||||
|
<!-- 评级标签(带 hover 提示) -->
|
||||||
|
<el-tooltip :content="gradeTooltip" placement="bottom" effect="dark">
|
||||||
|
<span class="report-drawer__grade-badge">{{ gradeLabel }} <span class="report-drawer__grade-dot">●</span></span>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<!-- 右侧三项计数 -->
|
||||||
|
<div class="report-drawer__score-right">
|
||||||
|
<div class="report-drawer__score-item">
|
||||||
|
<span class="report-drawer__score-num">{{ report.urgentTotal || 0 }}</span>
|
||||||
|
<span class="report-drawer__score-label">紧急修复项</span>
|
||||||
|
</div>
|
||||||
|
<div class="report-drawer__score-item">
|
||||||
|
<span class="report-drawer__score-num">{{ report.importantTotal || 0 }}</span>
|
||||||
|
<span class="report-drawer__score-label">严重问题</span>
|
||||||
|
</div>
|
||||||
|
<div class="report-drawer__score-item">
|
||||||
|
<span class="report-drawer__score-num">{{ report.expressionTotal || 0 }}</span>
|
||||||
|
<span class="report-drawer__score-label">可选修复项</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 评级提示语 -->
|
||||||
|
<!-- <p class="report-drawer__grade-hint">{{ gradeHintText }}</p>-->
|
||||||
|
|
||||||
|
<!-- 结论 -->
|
||||||
|
<div class="report-drawer__conclusion">
|
||||||
|
<h4 class="report-drawer__section-title">结论</h4>
|
||||||
|
<p class="report-drawer__conclusion-text">{{ report.summary || '暂无结论' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 核心问题 -->
|
||||||
|
<div class="report-drawer__issues">
|
||||||
|
<h4 class="report-drawer__section-title">核心问题</h4>
|
||||||
|
<div
|
||||||
|
v-for="(issue, index) in issues"
|
||||||
|
:key="issue.id || index"
|
||||||
|
class="report-drawer__issue-card"
|
||||||
|
>
|
||||||
|
<!-- 问题标题:序号 -->
|
||||||
|
<p class="report-drawer__issue-title">问题{{ index + 1 }}</p>
|
||||||
|
<!-- 问题内容:finding -->
|
||||||
|
<p class="report-drawer__issue-detail">{{ issue.finding }}</p>
|
||||||
|
<!-- 为什么需要优化 -->
|
||||||
|
<p class="report-drawer__issue-why-label">为什么需要优化?</p>
|
||||||
|
<p class="report-drawer__issue-importance">{{ issue.importance }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部按钮 -->
|
||||||
|
<div class="report-drawer__footer">
|
||||||
|
<button class="report-drawer__optimize-btn" @click="handleOptimize">去优化</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { DiagnosisReport, DiagnosisIssue } from '@/api/resume'
|
||||||
|
|
||||||
|
// ==================== Props ====================
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 控制抽屉显示/隐藏 */
|
||||||
|
modelValue: boolean
|
||||||
|
/** 诊断报告数据 */
|
||||||
|
report: DiagnosisReport
|
||||||
|
/** 诊断问题列表 */
|
||||||
|
issues: DiagnosisIssue[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', val: boolean): void
|
||||||
|
(e: 'optimize'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ==================== 计算属性 ====================
|
||||||
|
|
||||||
|
/** 评级文字映射 */
|
||||||
|
const gradeLabel = computed(() => {
|
||||||
|
const map: Record<string, string> = { A: '优秀', B: '良好', C: '一般', D: '待提升' }
|
||||||
|
return map[props.report.grade || ''] || '未评级'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 评级 hover 提示文字 */
|
||||||
|
const gradeTooltip = computed(() => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
D: '你的简历目前还有较大提升空间,建议尽快补充关键经历、完善内容表达,并优化整体结构,让简历更完整、更有竞争力',
|
||||||
|
C: '你的简历还有打磨空间,多推敲细节、补充些具体内容,整体会更出彩。',
|
||||||
|
B: '简历已经很棒了,但还有提升的潜力。再调整一下细节,会有更具有竞争力!',
|
||||||
|
A: '你的简历相当出彩,在求职市场中格外抢眼,能清晰展现您的优势与经历,已经超越绝大多数候选人了。',
|
||||||
|
}
|
||||||
|
return map[props.report.grade || ''] || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 评级提示语(显示在评分区域下方) */
|
||||||
|
const gradeHintText = computed(() => {
|
||||||
|
return gradeTooltip.value
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 更新时间文字 */
|
||||||
|
const updateTimeText = computed(() => {
|
||||||
|
if (!props.report.createTime) return ''
|
||||||
|
try {
|
||||||
|
const createDate = new Date(props.report.createTime)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - createDate.getTime()
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
if (diffDays === 0) return '今天更新'
|
||||||
|
if (diffDays < 30) return `更新于${diffDays}天前`
|
||||||
|
const diffMonths = Math.floor(diffDays / 30)
|
||||||
|
return `更新于${diffMonths}个月前`
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 事件处理 ====================
|
||||||
|
|
||||||
|
/** 关闭抽屉 */
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击去优化 */
|
||||||
|
function handleOptimize() {
|
||||||
|
emit('optimize')
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 简历评估问题修复抽屉 — 从右侧滑入,占满屏幕高度,宽度 8rem -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<!-- 遮罩层 -->
|
||||||
|
<Transition name="fix-drawer-overlay">
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="fix-drawer-overlay"
|
||||||
|
@click="handleClose"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- 抽屉主体 -->
|
||||||
|
<Transition name="fix-drawer-slide">
|
||||||
|
<div v-if="modelValue" class="fix-drawer" @click.stop>
|
||||||
|
<!-- 顶部栏:折叠按钮 + 模块标题 -->
|
||||||
|
<div class="fix-drawer__header">
|
||||||
|
<button class="fix-drawer__close-btn" @click="handleClose" aria-label="收起">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" class="fix-drawer__close-icon">
|
||||||
|
<path d="M10 3l-5 5 5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h3 class="fix-drawer__title">{{ moduleTitle }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可滚动内容区 -->
|
||||||
|
<div class="fix-drawer__body">
|
||||||
|
<!-- 原文部分 -->
|
||||||
|
<div class="fix-drawer__section">
|
||||||
|
<h4 class="fix-drawer__section-title">原文</h4>
|
||||||
|
<div class="fix-drawer__card">
|
||||||
|
<p class="fix-drawer__original-text">{{ originalText }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 问题检查部分 -->
|
||||||
|
<div class="fix-drawer__section">
|
||||||
|
<h4 class="fix-drawer__section-title">问题检查</h4>
|
||||||
|
<div class="fix-drawer__card">
|
||||||
|
<!-- 发现问题 -->
|
||||||
|
<p class="fix-drawer__label">—发现问题</p>
|
||||||
|
<p class="fix-drawer__text">{{ matchedIssue?.finding || '暂无' }}</p>
|
||||||
|
<!-- 重要性说明 -->
|
||||||
|
<p class="fix-drawer__label">—重要性说明</p>
|
||||||
|
<p class="fix-drawer__text">{{ matchedIssue?.importance || '暂无' }}</p>
|
||||||
|
<!-- 改进建议 -->
|
||||||
|
<p class="fix-drawer__label">—改进建议</p>
|
||||||
|
<p class="fix-drawer__text">{{ matchedIssue?.suggestion || '暂无' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 改进后的版本 -->
|
||||||
|
<div class="fix-drawer__section">
|
||||||
|
<h4 class="fix-drawer__section-title">AI改写后的版本</h4>
|
||||||
|
<div class="fix-drawer__card">
|
||||||
|
<p class="fix-drawer__diff-text">
|
||||||
|
<span
|
||||||
|
v-for="(seg, idx) in diffSegments"
|
||||||
|
:key="idx"
|
||||||
|
:class="{ 'fix-drawer__highlight': seg.highlight }"
|
||||||
|
>{{ seg.text }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自己改写区域 — 每个 optimizedContent 子元素对应一个输入框 -->
|
||||||
|
<div class="fix-drawer__section">
|
||||||
|
<h4 class="fix-drawer__section-title">自己改写</h4>
|
||||||
|
<div class="fix-drawer__card">
|
||||||
|
<textarea
|
||||||
|
v-for="(_, idx) in userRewriteList"
|
||||||
|
:key="'rewrite-' + idx"
|
||||||
|
v-model="userRewriteList[idx]"
|
||||||
|
class="fix-drawer__textarea"
|
||||||
|
:placeholder="'第 ' + (idx + 1) + ' 段改写内容…'"
|
||||||
|
:ref="(el: any) => setTextareaRef(el, idx)"
|
||||||
|
@input="autoResize(idx)"
|
||||||
|
/>
|
||||||
|
<!-- AI 优化按钮 -->
|
||||||
|
<div class="fix-drawer__textarea-footer">
|
||||||
|
<button class="fix-drawer__ai-btn" @click="handleAiPolish">AI优化</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 润色结果区域 — 调用接口返回后显示 -->
|
||||||
|
<div v-if="polishResult.length" class="fix-drawer__section">
|
||||||
|
<h4 class="fix-drawer__section-title">AI润色结果</h4>
|
||||||
|
<div class="fix-drawer__card">
|
||||||
|
<p
|
||||||
|
v-for="(text, idx) in polishResult"
|
||||||
|
:key="'polish-' + idx"
|
||||||
|
class="fix-drawer__polish-text"
|
||||||
|
>{{ text }}</p>
|
||||||
|
<!-- 替换按钮 -->
|
||||||
|
<div class="fix-drawer__textarea-footer">
|
||||||
|
<button class="fix-drawer__replace-btn" @click="handleReplace">替换</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户评价区域 — 始终显示 -->
|
||||||
|
<div class="fix-drawer__section">
|
||||||
|
<div class="fix-drawer__card fix-drawer__feedback-card">
|
||||||
|
<span class="fix-drawer__feedback-label">你认为这个修改建议有用吗?</span>
|
||||||
|
<div class="fix-drawer__feedback-actions">
|
||||||
|
<!-- 有用按钮 -->
|
||||||
|
<button
|
||||||
|
class="fix-drawer__feedback-btn"
|
||||||
|
:class="{ 'fix-drawer__feedback-btn--active': feedbackValue === 1 }"
|
||||||
|
@click="handleFeedback(1)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" class="fix-drawer__feedback-icon">
|
||||||
|
<path d="M4.5 7v6.5h-2a1 1 0 01-1-1V8a1 1 0 011-1h2zm1 6.5h5.3a1.5 1.5 0 001.47-1.2l1.08-5.4A1 1 0 0012.37 5.5H9V3a1.5 1.5 0 00-1.5-1.5L5.5 7z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
有用
|
||||||
|
</button>
|
||||||
|
<!-- 无用按钮 -->
|
||||||
|
<button
|
||||||
|
class="fix-drawer__feedback-btn"
|
||||||
|
:class="{ 'fix-drawer__feedback-btn--active': feedbackValue === 2 }"
|
||||||
|
@click="handleFeedback(2)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" class="fix-drawer__feedback-icon fix-drawer__feedback-icon--down">
|
||||||
|
<path d="M4.5 7v6.5h-2a1 1 0 01-1-1V8a1 1 0 011-1h2zm1 6.5h5.3a1.5 1.5 0 001.47-1.2l1.08-5.4A1 1 0 0012.37 5.5H9V3a1.5 1.5 0 00-1.5-1.5L5.5 7z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
无用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部提交按钮 -->
|
||||||
|
<div class="fix-drawer__footer">
|
||||||
|
<button class="fix-drawer__submit-btn" @click="handleSubmit">提交新版本</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, nextTick } from 'vue'
|
||||||
|
import type { DiagnosisIssue, DescriptionParagraph } from '@/api/resume'
|
||||||
|
import { polishDiagnosisIssue, feedbackDiagnosisIssue, resolveDiagnosisIssue } from '@/api/resume'
|
||||||
|
import { computeDiff, type DiffSegment } from '@/utils/textDiff'
|
||||||
|
|
||||||
|
// ==================== Props ====================
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 控制抽屉显示/隐藏 */
|
||||||
|
modelValue: boolean
|
||||||
|
/** 模块类型:summary / education / work / internship / project / competition */
|
||||||
|
moduleType: string
|
||||||
|
/** 当前记录 ID(个人概述时为 resumeId,经历时为该段经历的 id) */
|
||||||
|
recordId: string
|
||||||
|
/** 原文内容 — 个人概述时为 summary 字符串,经历时为 description 数组 */
|
||||||
|
originalContent: string | DescriptionParagraph[]
|
||||||
|
/** 该记录对应的诊断问题 */
|
||||||
|
issue: DiagnosisIssue | undefined
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', val: boolean): void
|
||||||
|
/** 提交新版本:携带自己改写的最终内容数组 */
|
||||||
|
(e: 'submit', content: string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ==================== 模块标题映射 ====================
|
||||||
|
|
||||||
|
/** 模块类型到中文标题的映射 */
|
||||||
|
const moduleTitleMap: Record<string, string> = {
|
||||||
|
summary: '个人概述',
|
||||||
|
education: '教育背景',
|
||||||
|
work: '工作经历',
|
||||||
|
internship: '实习经历',
|
||||||
|
project: '项目经历',
|
||||||
|
competition: '竞赛经历',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抽屉标题 */
|
||||||
|
const moduleTitle = computed(() => moduleTitleMap[props.moduleType] || '问题修复')
|
||||||
|
|
||||||
|
// ==================== 原文处理 ====================
|
||||||
|
|
||||||
|
/** 将原文内容统一转为字符串(description 数组的 text 逐行拼接) */
|
||||||
|
const originalText = computed(() => {
|
||||||
|
if (typeof props.originalContent === 'string') {
|
||||||
|
return props.originalContent
|
||||||
|
}
|
||||||
|
if (Array.isArray(props.originalContent)) {
|
||||||
|
return props.originalContent.map(d => d.text || '').join('\n')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 匹配的诊断问题 ====================
|
||||||
|
|
||||||
|
/** 当前记录匹配的 issue */
|
||||||
|
const matchedIssue = computed(() => props.issue)
|
||||||
|
|
||||||
|
// ==================== AI 改进内容(optimizedContent 数组) ====================
|
||||||
|
|
||||||
|
/** optimizedContent 子元素数组 — 兼容字符串(个人概述)和数组对象(经历模块) */
|
||||||
|
const optimizedItems = computed<any[]>(() => {
|
||||||
|
const oc = matchedIssue.value?.optimizedContent
|
||||||
|
if (!oc) return []
|
||||||
|
// 个人概述:optimizedContent 是字符串
|
||||||
|
if (typeof oc === 'string') return [oc]
|
||||||
|
if (Array.isArray(oc)) return oc
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
/** AI 改进后的文本(统一拼接为字符串,用于差异对比) */
|
||||||
|
const optimizedText = computed(() => {
|
||||||
|
if (!optimizedItems.value.length) return ''
|
||||||
|
const oc = matchedIssue.value?.optimizedContent
|
||||||
|
// 个人概述:直接就是字符串
|
||||||
|
if (typeof oc === 'string') return oc
|
||||||
|
// 经历模块:数组对象,取每个子元素的 optimizedContent 字段拼接
|
||||||
|
return optimizedItems.value
|
||||||
|
.map((item: any) => item.optimizedContent || item.text || '')
|
||||||
|
.join('\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 差异对比片段 */
|
||||||
|
const diffSegments = computed<DiffSegment[]>(() => {
|
||||||
|
if (!optimizedText.value) return []
|
||||||
|
return computeDiff(originalText.value, optimizedText.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 自己改写(多输入框) ====================
|
||||||
|
|
||||||
|
/** 用户自己改写的内容列表 — 每个 optimizedContent 子元素对应一个输入框 */
|
||||||
|
const userRewriteList = ref<string[]>([])
|
||||||
|
|
||||||
|
/** 抽屉打开时用 AI 改写内容初始化每个输入框 */
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val) {
|
||||||
|
// 用 optimizedContent 初始化每个输入框(兼容字符串和数组对象)
|
||||||
|
const oc = matchedIssue.value?.optimizedContent
|
||||||
|
if (typeof oc === 'string') {
|
||||||
|
// 个人概述:单个字符串 → 单个输入框
|
||||||
|
userRewriteList.value = [oc]
|
||||||
|
} else {
|
||||||
|
userRewriteList.value = optimizedItems.value.map(
|
||||||
|
(item: any) => item.optimizedContent || item.text || ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 如果没有 optimizedContent,至少给一个空输入框
|
||||||
|
if (!userRewriteList.value.length) {
|
||||||
|
userRewriteList.value = ['']
|
||||||
|
}
|
||||||
|
// 清空上次的润色结果
|
||||||
|
polishResult.value = []
|
||||||
|
// 重置评价状态
|
||||||
|
feedbackValue.value = 0
|
||||||
|
// 初始化 textarea 高度
|
||||||
|
initAllTextareaHeight()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== AI 润色(调用接口) ====================
|
||||||
|
|
||||||
|
/** AI 润色返回的结果数组 */
|
||||||
|
const polishResult = ref<string[]>([])
|
||||||
|
|
||||||
|
/** 调用 AI 润色接口 */
|
||||||
|
async function handleAiPolish() {
|
||||||
|
const issueId = matchedIssue.value?.id
|
||||||
|
if (!issueId) {
|
||||||
|
ElMessage.warning('当前没有可润色的问题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集每个输入框的内容
|
||||||
|
const content = userRewriteList.value.map(s => s.trim()).filter(Boolean)
|
||||||
|
if (!content.length) {
|
||||||
|
ElMessage.warning('请先输入改写内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全屏加载提示
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: 'AI 润色中,请耐心等待…',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
customClass: 'resume-upload-loading',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await polishDiagnosisIssue(issueId, content)
|
||||||
|
if (res.code === 0 && res.data?.content) {
|
||||||
|
polishResult.value = res.data.content
|
||||||
|
ElMessage.success('润色完成')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '润色失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('润色请求失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击替换按钮 — 以 AI 润色结果段数为准,替换自己改写的输入框 */
|
||||||
|
function handleReplace() {
|
||||||
|
userRewriteList.value = [...polishResult.value]
|
||||||
|
// 替换完成后清空润色结果区域
|
||||||
|
polishResult.value = []
|
||||||
|
ElMessage.success('已替换')
|
||||||
|
// 重新计算 textarea 高度
|
||||||
|
initAllTextareaHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 用户评价 ====================
|
||||||
|
|
||||||
|
/** 当前评价状态:0=未评价 1=有用 2=无用 */
|
||||||
|
const feedbackValue = ref(0)
|
||||||
|
|
||||||
|
/** 提交用户评价 */
|
||||||
|
async function handleFeedback(value: number) {
|
||||||
|
const issueId = matchedIssue.value?.id
|
||||||
|
if (!issueId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await feedbackDiagnosisIssue(issueId, value)
|
||||||
|
if (res.code === 0) {
|
||||||
|
feedbackValue.value = value
|
||||||
|
ElMessage.success('已收到,感谢您的评价')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '评价提交失败')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('评价请求失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== textarea 自适应高度 ====================
|
||||||
|
|
||||||
|
/** textarea DOM 引用数组 */
|
||||||
|
const textareaRefs: HTMLTextAreaElement[] = []
|
||||||
|
|
||||||
|
/** 收集 textarea ref */
|
||||||
|
function setTextareaRef(el: HTMLTextAreaElement | null, idx: number) {
|
||||||
|
if (el) textareaRefs[idx] = el
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据内容自动调整 textarea 高度 */
|
||||||
|
function autoResize(idx: number) {
|
||||||
|
const el = textareaRefs[idx]
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = el.scrollHeight + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化所有 textarea 高度(抽屉打开后 DOM 渲染完成时调用) */
|
||||||
|
function initAllTextareaHeight() {
|
||||||
|
nextTick(() => {
|
||||||
|
userRewriteList.value.forEach((_, idx) => autoResize(idx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 事件处理 ====================
|
||||||
|
|
||||||
|
/** 提交新版本 — 调用 resolve 接口标记已处理,然后通知父组件更新数据 */
|
||||||
|
async function handleSubmit() {
|
||||||
|
const issueId = matchedIssue.value?.id
|
||||||
|
if (!issueId) {
|
||||||
|
ElMessage.warning('当前没有可提交的问题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全屏加载提示
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: '正在提交新版本…',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
customClass: 'resume-upload-loading',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 调用 resolve 接口标记问题已处理
|
||||||
|
const res = await resolveDiagnosisIssue(issueId)
|
||||||
|
if (res.code !== 0) {
|
||||||
|
ElMessage.error(res.msg || '标记已处理失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 关闭抽屉并通知父组件携带最终改写内容
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
emit('submit', [...userRewriteList.value])
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('提交失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭抽屉 */
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -32,7 +32,18 @@ export interface AiResult<T = any> {
|
|||||||
|
|
||||||
/** 上传简历返回数据 */
|
/** 上传简历返回数据 */
|
||||||
export interface UploadResumeData {
|
export interface UploadResumeData {
|
||||||
resumeId: number
|
/** 简历 ID(字符串,避免大整数精度丢失) */
|
||||||
|
resumeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 JSON 解析:将超出安全整数范围的数字转为字符串,防止精度丢失
|
||||||
|
* 匹配 JSON 中独立的纯数字(不在引号内),超过 15 位的整数加上引号
|
||||||
|
*/
|
||||||
|
function safeParseLargeNumbers(data: string) {
|
||||||
|
// 将超过 15 位的整数值加上引号,变成字符串
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +59,14 @@ export function uploadResume(file: File) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
|
// 自定义响应解析,防止大整数 ID 精度丢失
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
return safeParseLargeNumbers(data)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
}).then(res => res.data)
|
}).then(res => res.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import axios from 'axios'
|
|||||||
import type { AxiosResponse } from 'axios'
|
import type { AxiosResponse } from 'axios'
|
||||||
import store from '@/stores'
|
import store from '@/stores'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 JSON 解析:将超出安全整数范围的数字转为字符串,防止精度丢失
|
||||||
|
* 匹配 JSON 中独立的纯数字(超过 15 位的整数),加上引号变成字符串
|
||||||
|
*/
|
||||||
|
function safeParseLargeNumbers(data: string) {
|
||||||
|
const processed = data.replace(/:\s*(\d{16,})/g, ':"$1"')
|
||||||
|
return JSON.parse(processed)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 axios 实例
|
* 创建 axios 实例
|
||||||
* withCredentials: true — 浏览器自动携带 Cookie(包括 HttpOnly 的 Token)
|
* withCredentials: true — 浏览器自动携带 Cookie(包括 HttpOnly 的 Token)
|
||||||
@@ -10,6 +19,14 @@ const service = axios.create({
|
|||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
// 自定义响应解析,防止大整数 ID 精度丢失
|
||||||
|
transformResponse: [(data: string) => {
|
||||||
|
try {
|
||||||
|
return safeParseLargeNumbers(data)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}],
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* 文本差异对比工具 — 用于简历原文与 AI 改进版本的逐字高亮对比
|
||||||
|
*
|
||||||
|
* 采用 Myers diff 算法的简化实现,能精确找出两段文本之间的
|
||||||
|
* 插入、删除和保留片段,比逐字顺序扫描更准确。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 差异片段类型 */
|
||||||
|
export interface DiffSegment {
|
||||||
|
/** 文本内容 */
|
||||||
|
text: string
|
||||||
|
/** 是否为差异(需要高亮) */
|
||||||
|
highlight: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两段文本的差异,返回新文本中每个片段的高亮标记
|
||||||
|
* 使用最长公共子序列(LCS)来识别相同部分,非 LCS 部分标记为差异
|
||||||
|
*
|
||||||
|
* @param original - 原文字符串
|
||||||
|
* @param modified - AI 改进后的字符串
|
||||||
|
* @returns 差异片段数组,每个片段包含文本和是否高亮
|
||||||
|
*/
|
||||||
|
export function computeDiff(original: string, modified: string): DiffSegment[] {
|
||||||
|
if (!original || !modified) {
|
||||||
|
return modified ? [{ text: modified, highlight: true }] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 LCS 表
|
||||||
|
const m = original.length
|
||||||
|
const n = modified.length
|
||||||
|
|
||||||
|
// 优化:如果文本完全相同,直接返回
|
||||||
|
if (original === modified) {
|
||||||
|
return [{ text: modified, highlight: false }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用滚动数组优化空间,只保留两行
|
||||||
|
let prev = new Uint16Array(n + 1)
|
||||||
|
let curr = new Uint16Array(n + 1)
|
||||||
|
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
if (original[i - 1] === modified[j - 1]) {
|
||||||
|
curr[j] = prev[j - 1] + 1
|
||||||
|
} else {
|
||||||
|
curr[j] = Math.max(prev[j], curr[j - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 交换行
|
||||||
|
;[prev, curr] = [curr, prev]
|
||||||
|
curr.fill(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回溯找出 LCS 中每个字符在 modified 中的位置
|
||||||
|
// 需要完整的 DP 表来回溯,重新计算(用二维数组但限制大小)
|
||||||
|
const lcsSet = new Set<number>() // modified 中属于 LCS 的索引
|
||||||
|
|
||||||
|
// 对于较长文本,使用空间优化的回溯
|
||||||
|
if (m * n > 1000000) {
|
||||||
|
// 文本过长时使用简化的贪心匹配
|
||||||
|
let oi = 0
|
||||||
|
for (let mi = 0; mi < n && oi < m; mi++) {
|
||||||
|
if (modified[mi] === original[oi]) {
|
||||||
|
lcsSet.add(mi)
|
||||||
|
oi++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 标准 LCS 回溯
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
if (original[i - 1] === modified[j - 1]) {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||||
|
} else {
|
||||||
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回溯
|
||||||
|
let i = m, j = n
|
||||||
|
while (i > 0 && j > 0) {
|
||||||
|
if (original[i - 1] === modified[j - 1]) {
|
||||||
|
lcsSet.add(j - 1)
|
||||||
|
i--
|
||||||
|
j--
|
||||||
|
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
||||||
|
i--
|
||||||
|
} else {
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 LCS 标记生成差异片段
|
||||||
|
const segments: DiffSegment[] = []
|
||||||
|
let currentText = ''
|
||||||
|
let currentHighlight = false
|
||||||
|
|
||||||
|
for (let mi = 0; mi < n; mi++) {
|
||||||
|
const isMatch = lcsSet.has(mi)
|
||||||
|
const highlight = !isMatch
|
||||||
|
|
||||||
|
if (mi === 0) {
|
||||||
|
currentHighlight = highlight
|
||||||
|
currentText = modified[mi]
|
||||||
|
} else if (highlight === currentHighlight) {
|
||||||
|
currentText += modified[mi]
|
||||||
|
} else {
|
||||||
|
segments.push({ text: currentText, highlight: currentHighlight })
|
||||||
|
currentText = modified[mi]
|
||||||
|
currentHighlight = highlight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推入最后一段
|
||||||
|
if (currentText) {
|
||||||
|
segments.push({ text: currentText, highlight: currentHighlight })
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
@@ -275,7 +275,7 @@
|
|||||||
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="jobId" />
|
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="jobId" />
|
||||||
|
|
||||||
<!-- 岗位专属简历定制弹窗 -->
|
<!-- 岗位专属简历定制弹窗 -->
|
||||||
<JobResumeCustomDialog v-model="showResumeCustomDialog" :job-info="resumeCustomJobInfo" @skip="handleSkipToApply" />
|
<JobResumeCustomDialog v-model="showResumeCustomDialog" :job-info="resumeCustomJobInfo" :job-id="jobId" @skip="handleSkipToApply" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -289,8 +289,8 @@ import JobPageHeader from '@/components/JobPageHeader.vue'
|
|||||||
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
|
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
|
||||||
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
|
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
|
||||||
import JobResumeCustomDialog from '@/components/JobResumeCustomDialog.vue'
|
import JobResumeCustomDialog from '@/components/JobResumeCustomDialog.vue'
|
||||||
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite } from '@/api/jobs'
|
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite, fetchSkillGap } from '@/api/jobs'
|
||||||
import type { JobDetailData } from '@/api/jobs'
|
import type { JobDetailData, SkillGapData } from '@/api/jobs'
|
||||||
|
|
||||||
// ==================== 路由相关 ====================
|
// ==================== 路由相关 ====================
|
||||||
|
|
||||||
@@ -554,20 +554,41 @@ function handleReport() {
|
|||||||
/** 简历定制弹窗显隐 */
|
/** 简历定制弹窗显隐 */
|
||||||
const showResumeCustomDialog = ref(false)
|
const showResumeCustomDialog = ref(false)
|
||||||
|
|
||||||
/** 传递给简历定制弹窗的岗位信息 */
|
/** 技能差距分析数据 */
|
||||||
|
const skillGapData = ref<SkillGapData | null>(null)
|
||||||
|
|
||||||
|
/** 传递给简历定制弹窗的岗位信息(从 skill-gap 接口获取) */
|
||||||
const resumeCustomJobInfo = computed(() => ({
|
const resumeCustomJobInfo = computed(() => ({
|
||||||
title: job.title,
|
title: skillGapData.value?.job?.title || job.title,
|
||||||
company: job.company,
|
company: job.company,
|
||||||
companyLogoUrl: job.companyLogoUrl,
|
companyLogoUrl: job.companyLogoUrl,
|
||||||
location: job.location,
|
location: job.location,
|
||||||
matchScore: +(job.matchScore / 10).toFixed(1),
|
matchScore: skillGapData.value?.score ?? +(job.matchScore / 10).toFixed(1),
|
||||||
missingSkills: job.requiredSkills.filter(s => !s.matched).map(s => s.name),
|
missingSkills: skillGapData.value?.missingSkills || [],
|
||||||
keywords: job.requiredSkills.map(s => s.name),
|
keywords: skillGapData.value?.job?.skillTags || job.requiredSkills.map(s => s.name),
|
||||||
sourceUrl: job.sourceUrl,
|
sourceUrl: job.sourceUrl,
|
||||||
|
/** 默认简历信息(来自 skill-gap 接口) */
|
||||||
|
defaultResume: skillGapData.value?.resume || null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/** 生成岗位专属简历 — 打开定制弹窗 */
|
/** 生成岗位专属简历 — 调用 skill-gap 接口后打开定制弹窗 */
|
||||||
function handleGenerateResume() {
|
async function handleGenerateResume() {
|
||||||
|
const loadingInstance = ElLoading.service({
|
||||||
|
text: '正在分析岗位匹配度...',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await fetchSkillGap(jobId)
|
||||||
|
if (res.code === 0 && res.data) {
|
||||||
|
skillGapData.value = res.data
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('技能差距分析失败', e)
|
||||||
|
ElMessage.error('分析失败,请稍后重试')
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
loadingInstance.close()
|
||||||
|
}
|
||||||
showResumeCustomDialog.value = true
|
showResumeCustomDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import SideNav from '@/components/SideNav.vue'
|
import SideNav from '@/components/SideNav.vue'
|
||||||
import { uploadResume } from '@/utils/aiRequest'
|
import { uploadResume } from '@/utils/aiRequest'
|
||||||
import { fetchResumeList, type ResumeListItem } from '@/api/resume'
|
import { fetchResumeList, deleteResume, type ResumeListItem } from '@/api/resume'
|
||||||
|
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||||||
|
// ElLoading.service() 是命令式调用,按需引入插件不会自动加载其样式,需手动引入
|
||||||
|
import 'element-plus/es/components/loading/style/css'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -183,9 +186,36 @@ function toggleMenu(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 弹出菜单操作点击 */
|
/** 弹出菜单操作点击 */
|
||||||
function handleAction(action: string, id: string) {
|
async function handleAction(action: string, id: string) {
|
||||||
activeMenuId.value = null
|
activeMenuId.value = null
|
||||||
console.log(action, id)
|
|
||||||
|
if (action === '删除') {
|
||||||
|
// 二次确认删除
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除这份简历吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 调用删除接口
|
||||||
|
const res = await deleteResume(id)
|
||||||
|
if (res.code === '0') {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
// 刷新列表
|
||||||
|
loadResumeList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 用户取消删除或接口报错
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(action, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传简历 — 弹出文件选择,选择后调用 AI 接口上传 */
|
/** 上传简历 — 弹出文件选择,选择后调用 AI 接口上传 */
|
||||||
@@ -210,16 +240,20 @@ function handleUpload() {
|
|||||||
try {
|
try {
|
||||||
const res = await uploadResume(file)
|
const res = await uploadResume(file)
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
// 上传成功,刷新列表并跳转详情页
|
// 上传成功,刷新列表
|
||||||
loadResumeList()
|
loadResumeList()
|
||||||
goDetail(String(res.data.resumeId))
|
// 等待让后端异步处理数据,再关闭加载动画并跳转详情页
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
|
loading.close()
|
||||||
|
// resumeId 已经是字符串(transformResponse 处理过),直接使用
|
||||||
|
goDetail(res.data.resumeId)
|
||||||
} else {
|
} else {
|
||||||
|
loading.close()
|
||||||
ElMessage.error(res.msg || '上传失败')
|
ElMessage.error(res.msg || '上传失败')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('上传失败,请稍后重试')
|
|
||||||
} finally {
|
|
||||||
loading.close()
|
loading.close()
|
||||||
|
ElMessage.error('上传失败,请稍后重试')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,24 @@
|
|||||||
@save="handleSaveEdit"
|
@save="handleSaveEdit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 简历分析报告抽屉 -->
|
||||||
|
<ResumeAnalysisReportDrawer
|
||||||
|
v-model="showReportDrawer"
|
||||||
|
:report="diagnosisReport"
|
||||||
|
:issues="diagnosisIssues"
|
||||||
|
@optimize="handleClose_report"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 简历评估问题修复抽屉 -->
|
||||||
|
<ResumeIssueFixDrawer
|
||||||
|
v-model="showFixDrawer"
|
||||||
|
:module-type="fixModuleType"
|
||||||
|
:record-id="fixRecordId"
|
||||||
|
:original-content="fixOriginalContent"
|
||||||
|
:issue="fixIssue"
|
||||||
|
@submit="handleFixSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 顶部标题 -->
|
<!-- 顶部标题 -->
|
||||||
<h2 class="resume-detail__page-title">我的简历</h2>
|
<h2 class="resume-detail__page-title">我的简历</h2>
|
||||||
|
|
||||||
@@ -50,26 +68,37 @@
|
|||||||
<!-- 简历评分区域 -->
|
<!-- 简历评分区域 -->
|
||||||
<div class="resume-detail__score-bar">
|
<div class="resume-detail__score-bar">
|
||||||
<div class="resume-detail__score-left">
|
<div class="resume-detail__score-left">
|
||||||
|
<!-- 评级 -->
|
||||||
<span class="resume-detail__score-avatar">
|
<span class="resume-detail__score-avatar">
|
||||||
{{ getAvatarLetter() }}
|
{{ diagnosisReport.grade }}
|
||||||
</span>
|
</span>
|
||||||
<span class="resume-detail__score-badge">良好</span>
|
<!-- 有诊断报告时显示评级和查看链接 -->
|
||||||
<button class="resume-detail__score-link" @click="handleViewReport">查看评估报告 ></button>
|
<template v-if="hasDiagnosis">
|
||||||
|
<span class="resume-detail__score-badge">{{ gradeLabel }}</span>
|
||||||
|
<button class="resume-detail__score-link" @click="handleViewReport">查看评估报告 ></button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="resume-detail__score-right">
|
<div class="resume-detail__score-right">
|
||||||
<div class="resume-detail__score-item">
|
<!-- 有诊断报告时显示三项计数和重新诊断按钮 -->
|
||||||
<span class="resume-detail__score-num">0</span>
|
<template v-if="hasDiagnosis">
|
||||||
<span class="resume-detail__score-label">紧急修复项</span>
|
<div class="resume-detail__score-item">
|
||||||
</div>
|
<span class="resume-detail__score-num">{{ diagnosisReport.urgentTotal || 0 }}</span>
|
||||||
<div class="resume-detail__score-item">
|
<span class="resume-detail__score-label">紧急修复</span>
|
||||||
<span class="resume-detail__score-num">0</span>
|
</div>
|
||||||
<span class="resume-detail__score-label">严重问题</span>
|
<div class="resume-detail__score-item">
|
||||||
</div>
|
<span class="resume-detail__score-num">{{ diagnosisReport.importantTotal || 0 }}</span>
|
||||||
<div class="resume-detail__score-item">
|
<span class="resume-detail__score-label">重点优化</span>
|
||||||
<span class="resume-detail__score-num">0</span>
|
</div>
|
||||||
<span class="resume-detail__score-label">可选修复项</span>
|
<div class="resume-detail__score-item">
|
||||||
</div>
|
<span class="resume-detail__score-num">{{ diagnosisReport.expressionTotal || 0 }}</span>
|
||||||
<button class="resume-detail__diagnose-btn" @click="handleDiagnose">重新诊断</button>
|
<span class="resume-detail__score-label">表达提升</span>
|
||||||
|
</div>
|
||||||
|
<button class="resume-detail__diagnose-btn" @click="handleDiagnose">重新诊断</button>
|
||||||
|
</template>
|
||||||
|
<!-- 没有诊断报告时显示开始诊断按钮 -->
|
||||||
|
<template v-else>
|
||||||
|
<button class="resume-detail__diagnose-btn resume-detail__diagnose-btn--start" @click="handleDiagnose">开始诊断</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,18 +149,6 @@
|
|||||||
{{ resumeMain.wechatNumber }}
|
{{ resumeMain.wechatNumber }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 问题操作区 -->
|
|
||||||
<div v-if="cardIssueConfig.personalInfo.show" class="resume-detail__card-issue">
|
|
||||||
<div class="resume-detail__issue-type-group">
|
|
||||||
<button
|
|
||||||
v-for="t in getCardIssueTypes('personalInfo')" :key="t.value"
|
|
||||||
class="resume-detail__issue-type-btn"
|
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.personalInfo.activeType === t.value }"
|
|
||||||
@click="cardIssueConfig.personalInfo.activeType = t.value"
|
|
||||||
>{{ t.label }}</button>
|
|
||||||
</div>
|
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('personalInfo')">修复</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 个人概述 -->
|
<!-- 个人概述 -->
|
||||||
@@ -144,15 +161,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="resume-detail__summary-text">{{ resumeMain.summary }}</p>
|
<p class="resume-detail__summary-text">{{ resumeMain.summary }}</p>
|
||||||
<!-- 问题操作区 -->
|
<!-- 个人概述的问题操作区(仅诊断数据中有 summary 类型且 status===0 时显示) -->
|
||||||
<div v-if="cardIssueConfig.summary.show" class="resume-detail__card-issue">
|
<div v-for="issue in getIssuesByModule('summary')" :key="issue.id" v-show="issue.status === 0" class="resume-detail__card-issue">
|
||||||
<div class="resume-detail__issue-type-group">
|
<div class="resume-detail__issue-type-group">
|
||||||
<button
|
<button
|
||||||
v-for="t in getCardIssueTypes('summary')" :key="t.value"
|
v-for="t in getIssueTypeCounts(issue)" :key="t.value"
|
||||||
class="resume-detail__issue-type-btn"
|
class="resume-detail__issue-type-btn resume-detail__issue-type-btn--active"
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.summary.activeType === t.value }"
|
:class="{ 'resume-detail__issue-type-btn--active': getActiveType('summary', resumeId) === t.value }"
|
||||||
@click="cardIssueConfig.summary.activeType = t.value"
|
@click="setActiveType('summary', resumeId, t.value)"
|
||||||
>{{ t.label }}</button>
|
>{{ t.count }} {{ t.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('summary')">修复</button>
|
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('summary')">修复</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,18 +187,6 @@
|
|||||||
<a :href="resumeMain.portfolioUrl" target="_blank" rel="noopener noreferrer" class="resume-detail__portfolio-link">
|
<a :href="resumeMain.portfolioUrl" target="_blank" rel="noopener noreferrer" class="resume-detail__portfolio-link">
|
||||||
{{ resumeMain.portfolioUrl }}
|
{{ resumeMain.portfolioUrl }}
|
||||||
</a>
|
</a>
|
||||||
<!-- 问题操作区 -->
|
|
||||||
<div v-if="cardIssueConfig.portfolio.show" class="resume-detail__card-issue">
|
|
||||||
<div class="resume-detail__issue-type-group">
|
|
||||||
<button
|
|
||||||
v-for="t in getCardIssueTypes('portfolio')" :key="t.value"
|
|
||||||
class="resume-detail__issue-type-btn"
|
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.portfolio.activeType === t.value }"
|
|
||||||
@click="cardIssueConfig.portfolio.activeType = t.value"
|
|
||||||
>{{ t.label }}</button>
|
|
||||||
</div>
|
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('portfolio')">修复</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 教育背景 -->
|
<!-- 教育背景 -->
|
||||||
@@ -199,18 +204,20 @@
|
|||||||
<div v-if="edu.description?.length" class="resume-detail__desc-list">
|
<div v-if="edu.description?.length" class="resume-detail__desc-list">
|
||||||
<p v-for="p in edu.description" :key="p.id" class="resume-detail__desc-item">{{ p.text }}</p>
|
<p v-for="p in edu.description" :key="p.id" class="resume-detail__desc-item">{{ p.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- 每段教育经历的问题操作区(status===0 待处理才显示) -->
|
||||||
<!-- 问题操作区 -->
|
<template v-if="hasDiagnosis && getIssueByRecord('education', edu.id!) && getIssueByRecord('education', edu.id!)!.status === 0">
|
||||||
<div v-if="cardIssueConfig.education.show" class="resume-detail__card-issue">
|
<div class="resume-detail__card-issue">
|
||||||
<div class="resume-detail__issue-type-group">
|
<div class="resume-detail__issue-type-group">
|
||||||
<button
|
<button
|
||||||
v-for="t in getCardIssueTypes('education')" :key="t.value"
|
v-for="t in getIssueTypeCounts(getIssueByRecord('education', edu.id!)!)" :key="t.value"
|
||||||
class="resume-detail__issue-type-btn"
|
class="resume-detail__issue-type-btn resume-detail__issue-type-btn--active"
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.education.activeType === t.value }"
|
:class="{ 'resume-detail__issue-type-btn--active': getActiveType('education', edu.id!) === t.value }"
|
||||||
@click="cardIssueConfig.education.activeType = t.value"
|
@click="setActiveType('education', edu.id!, t.value)"
|
||||||
>{{ t.label }}</button>
|
>{{ t.count }} {{ t.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('education')">修复</button>
|
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('education', edu.id)">修复</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -232,18 +239,20 @@
|
|||||||
<ul v-if="exp.description?.length" class="resume-detail__desc-list pl16">
|
<ul v-if="exp.description?.length" class="resume-detail__desc-list pl16">
|
||||||
<li v-for="p in exp.description" :key="p.id" class="resume-detail__desc-item fs12 color-5">{{ p.text }}</li>
|
<li v-for="p in exp.description" :key="p.id" class="resume-detail__desc-item fs12 color-5">{{ p.text }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
<!-- 每段工作经历的问题操作区(status===0 待处理才显示) -->
|
||||||
<!-- 问题操作区 -->
|
<template v-if="hasDiagnosis && getIssueByRecord('work', exp.id!) && getIssueByRecord('work', exp.id!)!.status === 0">
|
||||||
<div v-if="cardIssueConfig.work.show" class="resume-detail__card-issue">
|
<div class="resume-detail__card-issue">
|
||||||
<div class="resume-detail__issue-type-group">
|
<div class="resume-detail__issue-type-group">
|
||||||
<button
|
<button
|
||||||
v-for="t in getCardIssueTypes('work')" :key="t.value"
|
v-for="t in getIssueTypeCounts(getIssueByRecord('work', exp.id!)!)" :key="t.value"
|
||||||
class="resume-detail__issue-type-btn"
|
class="resume-detail__issue-type-btn resume-detail__issue-type-btn--active"
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.work.activeType === t.value }"
|
:class="{ 'resume-detail__issue-type-btn--active': getActiveType('work', exp.id!) === t.value }"
|
||||||
@click="cardIssueConfig.work.activeType = t.value"
|
@click="setActiveType('work', exp.id!, t.value)"
|
||||||
>{{ t.label }}</button>
|
>{{ t.count }} {{ t.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('work')">修复</button>
|
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('work', exp.id)">修复</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -265,18 +274,20 @@
|
|||||||
<div v-if="intern.description?.length" class="resume-detail__desc-list">
|
<div v-if="intern.description?.length" class="resume-detail__desc-list">
|
||||||
<p v-for="p in intern.description" :key="p.id" class="resume-detail__desc-item">{{ p.text }}</p>
|
<p v-for="p in intern.description" :key="p.id" class="resume-detail__desc-item">{{ p.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- 每段实习经历的问题操作区(status===0 待处理才显示) -->
|
||||||
<!-- 问题操作区 -->
|
<template v-if="hasDiagnosis && getIssueByRecord('internship', intern.id!) && getIssueByRecord('internship', intern.id!)!.status === 0">
|
||||||
<div v-if="cardIssueConfig.internship.show" class="resume-detail__card-issue">
|
<div class="resume-detail__card-issue">
|
||||||
<div class="resume-detail__issue-type-group">
|
<div class="resume-detail__issue-type-group">
|
||||||
<button
|
<button
|
||||||
v-for="t in getCardIssueTypes('internship')" :key="t.value"
|
v-for="t in getIssueTypeCounts(getIssueByRecord('internship', intern.id!)!)" :key="t.value"
|
||||||
class="resume-detail__issue-type-btn"
|
class="resume-detail__issue-type-btn resume-detail__issue-type-btn--active"
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.internship.activeType === t.value }"
|
:class="{ 'resume-detail__issue-type-btn--active': getActiveType('internship', intern.id!) === t.value }"
|
||||||
@click="cardIssueConfig.internship.activeType = t.value"
|
@click="setActiveType('internship', intern.id!, t.value)"
|
||||||
>{{ t.label }}</button>
|
>{{ t.count }} {{ t.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('internship')">修复</button>
|
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('internship', intern.id)">修复</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -298,18 +309,20 @@
|
|||||||
<div v-if="proj.description?.length" class="resume-detail__desc-list">
|
<div v-if="proj.description?.length" class="resume-detail__desc-list">
|
||||||
<p v-for="p in proj.description" :key="p.id" class="resume-detail__desc-item">{{ p.text }}</p>
|
<p v-for="p in proj.description" :key="p.id" class="resume-detail__desc-item">{{ p.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- 每段项目经历的问题操作区(status===0 待处理才显示) -->
|
||||||
<!-- 问题操作区 -->
|
<template v-if="hasDiagnosis && getIssueByRecord('project', proj.id!) && getIssueByRecord('project', proj.id!)!.status === 0">
|
||||||
<div v-if="cardIssueConfig.project.show" class="resume-detail__card-issue">
|
<div class="resume-detail__card-issue">
|
||||||
<div class="resume-detail__issue-type-group">
|
<div class="resume-detail__issue-type-group">
|
||||||
<button
|
<button
|
||||||
v-for="t in getCardIssueTypes('project')" :key="t.value"
|
v-for="t in getIssueTypeCounts(getIssueByRecord('project', proj.id!)!)" :key="t.value"
|
||||||
class="resume-detail__issue-type-btn"
|
class="resume-detail__issue-type-btn resume-detail__issue-type-btn--active"
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.project.activeType === t.value }"
|
:class="{ 'resume-detail__issue-type-btn--active': getActiveType('project', proj.id!) === t.value }"
|
||||||
@click="cardIssueConfig.project.activeType = t.value"
|
@click="setActiveType('project', proj.id!, t.value)"
|
||||||
>{{ t.label }}</button>
|
>{{ t.count }} {{ t.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('project')">修复</button>
|
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('project', proj.id)">修复</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -331,18 +344,20 @@
|
|||||||
<div v-if="comp.description?.length" class="resume-detail__desc-list">
|
<div v-if="comp.description?.length" class="resume-detail__desc-list">
|
||||||
<p v-for="p in comp.description" :key="p.id" class="resume-detail__desc-item">{{ p.text }}</p>
|
<p v-for="p in comp.description" :key="p.id" class="resume-detail__desc-item">{{ p.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- 每段竞赛经历的问题操作区(status===0 待处理才显示) -->
|
||||||
<!-- 问题操作区 -->
|
<template v-if="hasDiagnosis && getIssueByRecord('competition', comp.id!) && getIssueByRecord('competition', comp.id!)!.status === 0">
|
||||||
<div v-if="cardIssueConfig.competition.show" class="resume-detail__card-issue">
|
<div class="resume-detail__card-issue">
|
||||||
<div class="resume-detail__issue-type-group">
|
<div class="resume-detail__issue-type-group">
|
||||||
<button
|
<button
|
||||||
v-for="t in getCardIssueTypes('competition')" :key="t.value"
|
v-for="t in getIssueTypeCounts(getIssueByRecord('competition', comp.id!)!)" :key="t.value"
|
||||||
class="resume-detail__issue-type-btn"
|
class="resume-detail__issue-type-btn resume-detail__issue-type-btn--active"
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.competition.activeType === t.value }"
|
:class="{ 'resume-detail__issue-type-btn--active': getActiveType('competition', comp.id!) === t.value }"
|
||||||
@click="cardIssueConfig.competition.activeType = t.value"
|
@click="setActiveType('competition', comp.id!, t.value)"
|
||||||
>{{ t.label }}</button>
|
>{{ t.count }} {{ t.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('competition')">修复</button>
|
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('competition', comp.id)">修复</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -360,18 +375,6 @@
|
|||||||
{{ skill }}
|
{{ skill }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 问题操作区 -->
|
|
||||||
<div v-if="cardIssueConfig.skills.show" class="resume-detail__card-issue">
|
|
||||||
<div class="resume-detail__issue-type-group">
|
|
||||||
<button
|
|
||||||
v-for="t in getCardIssueTypes('skills')" :key="t.value"
|
|
||||||
class="resume-detail__issue-type-btn"
|
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.skills.activeType === t.value }"
|
|
||||||
@click="cardIssueConfig.skills.activeType = t.value"
|
|
||||||
>{{ t.label }}</button>
|
|
||||||
</div>
|
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('skills')">修复</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 证书 -->
|
<!-- 证书 -->
|
||||||
@@ -388,18 +391,6 @@
|
|||||||
{{ cert }}
|
{{ cert }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 问题操作区 -->
|
|
||||||
<div v-if="cardIssueConfig.certificates.show" class="resume-detail__card-issue">
|
|
||||||
<div class="resume-detail__issue-type-group">
|
|
||||||
<button
|
|
||||||
v-for="t in getCardIssueTypes('certificates')" :key="t.value"
|
|
||||||
class="resume-detail__issue-type-btn"
|
|
||||||
:class="{ 'resume-detail__issue-type-btn--active': cardIssueConfig.certificates.activeType === t.value }"
|
|
||||||
@click="cardIssueConfig.certificates.activeType = t.value"
|
|
||||||
>{{ t.label }}</button>
|
|
||||||
</div>
|
|
||||||
<button class="resume-detail__card-btn resume-detail__card-btn--dark" @click="handleFixIssue('certificates')">修复</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,10 +398,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import SideNav from '@/components/SideNav.vue'
|
import SideNav from '@/components/SideNav.vue'
|
||||||
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
|
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
|
||||||
|
import ResumeAnalysisReportDrawer from '@/components/ResumeAnalysisReportDrawer.vue'
|
||||||
|
import ResumeIssueFixDrawer from '@/components/ResumeIssueFixDrawer.vue'
|
||||||
import {
|
import {
|
||||||
fetchResumeMain,
|
fetchResumeMain,
|
||||||
fetchResumeEducation,
|
fetchResumeEducation,
|
||||||
@@ -418,6 +411,8 @@ import {
|
|||||||
fetchResumeInternship,
|
fetchResumeInternship,
|
||||||
fetchResumeProject,
|
fetchResumeProject,
|
||||||
fetchResumeCompetition,
|
fetchResumeCompetition,
|
||||||
|
fetchResumeDiagnosis,
|
||||||
|
triggerResumeDiagnosis,
|
||||||
saveResumeMain,
|
saveResumeMain,
|
||||||
saveResumeEducation,
|
saveResumeEducation,
|
||||||
saveResumeWork,
|
saveResumeWork,
|
||||||
@@ -430,50 +425,101 @@ import {
|
|||||||
type ResumeInternship,
|
type ResumeInternship,
|
||||||
type ResumeProject,
|
type ResumeProject,
|
||||||
type ResumeCompetition,
|
type ResumeCompetition,
|
||||||
|
type DiagnosisReport,
|
||||||
|
type DiagnosisIssue,
|
||||||
|
type DescriptionParagraph,
|
||||||
} from '@/api/resume'
|
} from '@/api/resume'
|
||||||
|
|
||||||
// ==================== 问题类型相关 ====================
|
// ==================== 诊断数据相关 ====================
|
||||||
|
|
||||||
/** 问题类型枚举值 */
|
/** 问题类型枚举值 */
|
||||||
type IssueType = 'urgent' | 'optimize' | 'expression'
|
type IssueType = 'urgent' | 'optimize' | 'expression'
|
||||||
|
|
||||||
/** 问题类型选项列表 */
|
/** 是否已有诊断报告 */
|
||||||
const issueTypes: { label: string; value: IssueType }[] = [
|
const hasDiagnosis = ref(false)
|
||||||
{ label: '紧急修复项', value: 'urgent' },
|
|
||||||
{ label: '重点优化项', value: 'optimize' },
|
|
||||||
{ label: '表达提升', value: 'expression' },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 单个卡片的问题配置 */
|
/** 诊断报告数据 */
|
||||||
interface CardIssueItem {
|
const diagnosisReport = ref<DiagnosisReport>({})
|
||||||
/** 是否显示问题操作区 */
|
|
||||||
show: boolean
|
|
||||||
/** 当前选中的问题类型 */
|
|
||||||
activeType: IssueType
|
|
||||||
/** 该模块拥有的问题类型列表(只显示这些) */
|
|
||||||
types: IssueType[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 所有卡片模块的 key */
|
/** 诊断问题列表 */
|
||||||
type CardKey = 'personalInfo' | 'summary' | 'portfolio' | 'education' | 'work' | 'internship' | 'project' | 'competition' | 'skills' | 'certificates'
|
const diagnosisIssues = ref<DiagnosisIssue[]>([])
|
||||||
|
|
||||||
/** 各模块问题操作区的显示与选中状态配置,每个模块只包含自己实际存在的问题类型 */
|
/** 评级文字映射 */
|
||||||
const cardIssueConfig = reactive<Record<CardKey, CardIssueItem>>({
|
const gradeLabel = computed(() => {
|
||||||
personalInfo: { show: true, activeType: 'urgent', types: ['urgent'] },
|
const map: Record<string, string> = { A: '优秀', B: '良好', C: '一般', D: '待提升' }
|
||||||
summary: { show: true, activeType: 'expression', types: ['expression'] },
|
return map[diagnosisReport.value.grade || ''] || '未评级'
|
||||||
portfolio: { show: true, activeType: 'optimize', types: ['optimize'] },
|
|
||||||
education: { show: true, activeType: 'urgent', types: ['urgent'] },
|
|
||||||
work: { show: true, activeType: 'optimize', types: ['optimize'] },
|
|
||||||
internship: { show: true, activeType: 'optimize', types: ['optimize'] },
|
|
||||||
project: { show: true, activeType: 'expression', types: ['expression'] },
|
|
||||||
competition: { show: true, activeType: 'urgent', types: ['urgent'] },
|
|
||||||
skills: { show: true, activeType: 'optimize', types: ['optimize'] },
|
|
||||||
certificates: { show: true, activeType: 'urgent', types: ['urgent'] },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 根据模块 key 获取该模块可用的问题类型选项 */
|
/** 每个子经历的问题操作区当前选中类型,key 为 "moduleType_recordId" */
|
||||||
function getCardIssueTypes(cardKey: CardKey) {
|
const issueActiveTypes = reactive<Record<string, IssueType>>({})
|
||||||
return issueTypes.filter(t => cardIssueConfig[cardKey].types.includes(t.value))
|
|
||||||
|
/** 根据模块类型获取该模块的所有 issue */
|
||||||
|
function getIssuesByModule(moduleType: string): DiagnosisIssue[] {
|
||||||
|
return diagnosisIssues.value.filter(i => i.moduleType === moduleType)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据模块类型和记录 ID 获取对应的 issue */
|
||||||
|
function getIssueByRecord(moduleType: string, recordId: string): DiagnosisIssue | undefined {
|
||||||
|
return diagnosisIssues.value.find(i => i.moduleType === moduleType && i.moduleRecordId === recordId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算单个 issue 的子类型计数,将 urgentIssues/importantIssues/expressionIssues 的 value 求和 */
|
||||||
|
function sumSubCounts(obj?: Record<string, number>): number {
|
||||||
|
if (!obj) return 0
|
||||||
|
return Object.values(obj).reduce((s, v) => s + v, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取单个 issue 的三类问题计数,用于渲染问题类型按钮 */
|
||||||
|
function getIssueTypeCounts(issue: DiagnosisIssue) {
|
||||||
|
const urgent = sumSubCounts(issue.urgentIssues)
|
||||||
|
const important = sumSubCounts(issue.importantIssues)
|
||||||
|
const expression = sumSubCounts(issue.expressionIssues)
|
||||||
|
const result: { label: string; value: IssueType; count: number }[] = []
|
||||||
|
if (urgent > 0) result.push({ label: '紧急修复', value: 'urgent', count: urgent })
|
||||||
|
if (important > 0) result.push({ label: '重点优化', value: 'optimize', count: important })
|
||||||
|
if (expression > 0) result.push({ label: '表达提升', value: 'expression', count: expression })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取某个子经历问题操作区的当前选中类型 */
|
||||||
|
function getActiveType(moduleType: string, recordId: string): IssueType {
|
||||||
|
const key = `${moduleType}_${recordId}`
|
||||||
|
if (!issueActiveTypes[key]) {
|
||||||
|
// 默认选中第一个有计数的类型
|
||||||
|
const issue = getIssueByRecord(moduleType, recordId)
|
||||||
|
if (issue) {
|
||||||
|
const types = getIssueTypeCounts(issue)
|
||||||
|
issueActiveTypes[key] = types.length > 0 ? types[0].value : 'urgent'
|
||||||
|
} else {
|
||||||
|
issueActiveTypes[key] = 'urgent'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issueActiveTypes[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置某个子经历问题操作区的选中类型 */
|
||||||
|
function setActiveType(moduleType: string, recordId: string, type: IssueType) {
|
||||||
|
issueActiveTypes[`${moduleType}_${recordId}`] = type
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载诊断报告数据 */
|
||||||
|
async function loadDiagnosis() {
|
||||||
|
try {
|
||||||
|
const res = await fetchResumeDiagnosis(resumeId)
|
||||||
|
if (res.code === 0 && res.data) {
|
||||||
|
hasDiagnosis.value = true
|
||||||
|
diagnosisReport.value = res.data.report
|
||||||
|
diagnosisIssues.value = res.data.issues || []
|
||||||
|
} else {
|
||||||
|
// data 为 null,没有诊断报告
|
||||||
|
hasDiagnosis.value = false
|
||||||
|
diagnosisReport.value = {}
|
||||||
|
diagnosisIssues.value = []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error('[ResumeDetail] 加载诊断报告失败')
|
||||||
|
hasDiagnosis.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 路由相关 ====================
|
// ==================== 路由相关 ====================
|
||||||
@@ -541,6 +587,8 @@ async function loadResumeDetail() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadResumeDetail()
|
loadResumeDetail()
|
||||||
|
// 加载诊断报告
|
||||||
|
loadDiagnosis()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== 计算属性 ====================
|
// ==================== 计算属性 ====================
|
||||||
@@ -570,15 +618,190 @@ function handleExport() { console.log('导出') }
|
|||||||
function handleDelete() { console.log('删除') }
|
function handleDelete() { console.log('删除') }
|
||||||
|
|
||||||
/** 查看评估报告 */
|
/** 查看评估报告 */
|
||||||
function handleViewReport() { console.log('查看评估报告') }
|
function handleViewReport() {
|
||||||
|
showReportDrawer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
/** 重新诊断简历 */
|
/** 简历分析报告抽屉显示状态 */
|
||||||
function handleDiagnose() { console.log('重新诊断') }
|
const showReportDrawer = ref(false)
|
||||||
|
|
||||||
/** 修复指定模块的问题,传入模块 key */
|
/** 分析报告点击去优化 — 关闭抽屉 */
|
||||||
function handleFixIssue(cardKey: CardKey) {
|
function handleClose_report() {
|
||||||
const config = cardIssueConfig[cardKey]
|
showReportDrawer.value = false
|
||||||
console.log(`修复模块: ${cardKey}, 问题类型: ${config.activeType}`)
|
}
|
||||||
|
|
||||||
|
/** 重新诊断 / 开始诊断 — 调用 AI 诊断接口,完成后刷新诊断数据 */
|
||||||
|
async function handleDiagnose() {
|
||||||
|
// 全屏加载提示,AI 接口响应较慢
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: '简历诊断中,请耐心等待…',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
customClass: 'resume-upload-loading',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await triggerResumeDiagnosis(resumeId)
|
||||||
|
if (res.code === 0) {
|
||||||
|
// 诊断完成,重新拉取诊断报告数据
|
||||||
|
await loadDiagnosis()
|
||||||
|
ElMessage.success('诊断完成')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '诊断失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('诊断请求失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修复指定模块的问题 */
|
||||||
|
function handleFixIssue(moduleType: string, recordId?: string) {
|
||||||
|
fixModuleType.value = moduleType
|
||||||
|
// 确定记录 ID:个人概述用 resumeId,经历用传入的 recordId
|
||||||
|
const rid = moduleType === 'summary' ? resumeId : (recordId || '')
|
||||||
|
fixRecordId.value = rid
|
||||||
|
// 获取原文内容
|
||||||
|
fixOriginalContent.value = getOriginalContent(moduleType, rid)
|
||||||
|
// 获取匹配的诊断问题
|
||||||
|
fixIssue.value = getIssueByRecord(moduleType, rid)
|
||||||
|
showFixDrawer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 问题修复抽屉相关 ====================
|
||||||
|
|
||||||
|
/** 修复抽屉显示状态 */
|
||||||
|
const showFixDrawer = ref(false)
|
||||||
|
|
||||||
|
/** 修复抽屉当前模块类型 */
|
||||||
|
const fixModuleType = ref('')
|
||||||
|
|
||||||
|
/** 修复抽屉当前记录 ID */
|
||||||
|
const fixRecordId = ref('')
|
||||||
|
|
||||||
|
/** 修复抽屉原文内容 */
|
||||||
|
const fixOriginalContent = ref<string | DescriptionParagraph[]>('')
|
||||||
|
|
||||||
|
/** 修复抽屉匹配的诊断问题 */
|
||||||
|
const fixIssue = ref<DiagnosisIssue | undefined>(undefined)
|
||||||
|
|
||||||
|
/** 根据模块类型和记录 ID 获取原文内容 */
|
||||||
|
function getOriginalContent(moduleType: string, recordId: string): string | DescriptionParagraph[] {
|
||||||
|
if (moduleType === 'summary') {
|
||||||
|
return resumeMain.value.summary || ''
|
||||||
|
}
|
||||||
|
// 5 大经历模块 — 找到对应记录的 description 数组
|
||||||
|
const listMap: Record<string, any[]> = {
|
||||||
|
education: educationList.value,
|
||||||
|
work: workList.value,
|
||||||
|
internship: internshipList.value,
|
||||||
|
project: projectList.value,
|
||||||
|
competition: competitionList.value,
|
||||||
|
}
|
||||||
|
const list = listMap[moduleType]
|
||||||
|
if (!list) return ''
|
||||||
|
const record = list.find((item: any) => item.id === recordId)
|
||||||
|
return record?.description || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修复抽屉提交新版本 — 更新本地数据、调用保存接口、刷新页面 */
|
||||||
|
async function handleFixSubmit(content: string[]) {
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: '正在保存修改…',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
customClass: 'resume-upload-loading',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleType = fixModuleType.value
|
||||||
|
const recordId = fixRecordId.value
|
||||||
|
|
||||||
|
if (moduleType === 'summary') {
|
||||||
|
// 个人概述:content 只有一个元素,直接更新 summary 字段
|
||||||
|
const newSummary = content.join('\n')
|
||||||
|
resumeMain.value.summary = newSummary
|
||||||
|
await saveResumeMain({ resumeId, summary: newSummary })
|
||||||
|
} else {
|
||||||
|
// 5 大经历模块:找到对应经历记录,按 id 对应更新 description 数组里的 text
|
||||||
|
const listMap: Record<string, any[]> = {
|
||||||
|
education: educationList.value,
|
||||||
|
work: workList.value,
|
||||||
|
internship: internshipList.value,
|
||||||
|
project: projectList.value,
|
||||||
|
competition: competitionList.value,
|
||||||
|
}
|
||||||
|
const list = listMap[moduleType]
|
||||||
|
if (list) {
|
||||||
|
const record = list.find((item: any) => item.id === recordId)
|
||||||
|
if (record?.description) {
|
||||||
|
// 按顺序更新 description 数组里每个对象的 text,保留原有 id
|
||||||
|
content.forEach((text, idx) => {
|
||||||
|
if (idx < record.description.length) {
|
||||||
|
record.description[idx].text = text
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 调用对应模块的保存接口(全量覆盖)
|
||||||
|
await saveModuleData(moduleType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新加载所有简历数据和诊断报告
|
||||||
|
await Promise.all([loadResumeDetail(), loadDiagnosis()])
|
||||||
|
ElMessage.success('修改已保存')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('保存失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据模块类型调用对应的保存接口(全量覆盖) */
|
||||||
|
async function saveModuleData(moduleType: string) {
|
||||||
|
if (moduleType === 'education') {
|
||||||
|
await saveResumeEducation(resumeId, educationList.value.map(edu => ({
|
||||||
|
school: edu.school,
|
||||||
|
major: edu.major,
|
||||||
|
degree: edu.degree,
|
||||||
|
studyType: edu.studyType,
|
||||||
|
startDate: edu.startDate,
|
||||||
|
endDate: edu.endDate,
|
||||||
|
description: edu.description,
|
||||||
|
})))
|
||||||
|
} else if (moduleType === 'work') {
|
||||||
|
await saveResumeWork(resumeId, workList.value.map(w => ({
|
||||||
|
companyName: w.companyName,
|
||||||
|
position: w.position,
|
||||||
|
startDate: w.startDate,
|
||||||
|
endDate: w.endDate,
|
||||||
|
description: w.description,
|
||||||
|
})))
|
||||||
|
} else if (moduleType === 'internship') {
|
||||||
|
await saveResumeInternship(resumeId, internshipList.value.map(i => ({
|
||||||
|
companyName: i.companyName,
|
||||||
|
position: i.position,
|
||||||
|
startDate: i.startDate,
|
||||||
|
endDate: i.endDate,
|
||||||
|
description: i.description,
|
||||||
|
})))
|
||||||
|
} else if (moduleType === 'project') {
|
||||||
|
await saveResumeProject(resumeId, projectList.value.map(p => ({
|
||||||
|
projectName: p.projectName,
|
||||||
|
companyName: p.companyName,
|
||||||
|
role: p.role,
|
||||||
|
startDate: p.startDate,
|
||||||
|
endDate: p.endDate,
|
||||||
|
description: p.description,
|
||||||
|
})))
|
||||||
|
} else if (moduleType === 'competition') {
|
||||||
|
await saveResumeCompetition(resumeId, competitionList.value.map(c => ({
|
||||||
|
competitionName: c.competitionName,
|
||||||
|
award: c.award,
|
||||||
|
awardDate: c.awardDate,
|
||||||
|
description: c.description,
|
||||||
|
})))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 编辑抽屉相关 ====================
|
// ==================== 编辑抽屉相关 ====================
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/ai-api': {
|
'/ai-api': {
|
||||||
target: 'http://192.168.31.133:8000',
|
target: 'http://192.168.31.135:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/ai-api/, ''),
|
rewrite: (path) => path.replace(/^\/ai-api/, ''),
|
||||||
},
|
},
|
||||||
|
|||||||