AI 简历制作平台:结构化输出解析、多模板渲染与 PDF 导出的 Vue3 + Spring AI 全栈实战

AI 简历制作平台:结构化输出解析、多模板渲染与 PDF 导出的 Vue3 + Spring AI 全栈实战

本文将构建一个智能简历制作平台,支持 AI 智能优化简历内容、一键生成专业简历、多模板切换、PDF 导出等功能。前端使用 Vue3 + TypeScript,后端使用 Spring Boot + Spring AI。


博客:

https://blog.csdn.net/badao_liumang_qizhi

一、项目架构设计

1.1 整体架构

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   前端 (Vue3 + Vite)                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │ 简历编辑  │  │ AI 优化  │  │ 模板选择  │  │ PDF 导出  │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
└─────────────────────────────────────────────────────────────┘
                              │ HTTP / SSE
┌─────────────────────────────────────────────────────────────┐
│                   后端 (Spring Boot 3.x)                     │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │ 简历服务  │  │ AI 服务  │  │ 模板引擎  │  │ 导出服务  │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
└─────────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────────────────────────────────────┐
│                    数据层                                    │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                  │
│  │  MySQL   │  │  Redis   │  │  MinIO   │                  │
│  └──────────┘  └──────────┘  └──────────┘                  │
└─────────────────────────────────────────────────────────────┘

1.2 核心功能

  • 简历在线编辑与实时预览
  • AI 智能优化(经历描述、技能展示)
  • 多模板切换
  • PDF 导出
  • 简历版本管理
  • 隐私信息脱敏

1.3 模块与数据流

功能模块 核心能力 关键技术
简历 CRUD 增删改查、版本管理 Spring Data JPA
AI 优化 简历改写、自我评价优化、技能推荐 Spring AI ChatClient + BeanOutputConverter
简历分析 综合评分、优缺点、优化建议 结构化输出 + JSON Schema
PDF 导出 多模板渲染 Thymeleaf + OpenHTMLToPDF
文件存储 头像、附件、PDF 归档 MinIO 对象存储

核心数据流:用户编辑简历 → 保存到 MySQL → 点击 AI 优化 → 后端构建 Prompt → LLM 返回结构化 JSON → 反序列化为 POJO → 前端展示结果。

二、后端实现

2.1 项目依赖配置

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>ai-resume-backend</artifactId>
    <version>1.0.0</version>
    <name>AI Resume Backend</name>
    
    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0-M4</spring-ai.version>
    </properties>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.0.33</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- PDF 生成 -->
        <dependency>
            <groupId>com.openhtmltopdf</groupId>
            <artifactId>openhtmltopdf-core</artifactId>
            <version>1.0.10</version>
        </dependency>
        <dependency>
            <groupId>com.openhtmltopdf</groupId>
            <artifactId>openhtmltopdf-pdfbox</artifactId>
            <version>1.0.10</version>
        </dependency>
        <!-- 中文字体支持 -->
        <dependency>
            <groupId>com.openhtmltopdf</groupId>
            <artifactId>openhtmltopdf-slf4j</artifactId>
            <version>1.0.10</version>
        </dependency>
        <!-- Thymeleaf 模板 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <!-- MinIO 文件存储 -->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.6.0</version>
        </dependency>
    </dependencies>
</project>

2.2 配置文件

yaml 复制代码
# application.yml
server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/ai_resume?useUnicode=true&characterEncoding=utf8
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
  
  data:
    redis:
      host: localhost
      port: 6379

  ai:
    openai:
      api-key: ${DASHSCOPE_API_KEY}
      base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
      chat:
        options:
          model: qwen-max
          temperature: 0.7
          max-tokens: 4096

minio:
  endpoint: http://localhost:9000
  access-key: ${MINIO_ACCESS_KEY}
  secret-key: ${MINIO_SECRET_KEY}
  bucket-name: ai-resume

# PDF 导出配置
pdf:
  font-path: /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc
  base-url: classpath:/templates/

2.3 实体类定义

java 复制代码
package com.example.airesume.entity;

import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;
import java.util.List;

@Data
@Entity
@Table(name = "resume")
public class Resume {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private Long userId;
    
    @Column(nullable = false, length = 100)
    private String title;
    
    @Embedded
    private BasicInfo basicInfo;
    
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "resume_id")
    private List<Education> educations;
    
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "resume_id")
    private List<WorkExperience> workExperiences;
    
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "resume_id")
    private List<ProjectExperience> projectExperiences;
    
    @ElementCollection
    @CollectionTable(name = "resume_skills", 
                     joinColumns = @JoinColumn(name = "resume_id"))
    private List<Skill> skills;
    
    @Column(columnDefinition = "TEXT")
    private String selfEvaluation;
    
    @Column(length = 50)
    private String templateId = "default";
    
    @Column(nullable = false)
    private Integer version = 1;
    
    @CreationTimestamp
    private LocalDateTime createdAt;
    
    @UpdateTimestamp
    private LocalDateTime updatedAt;
}

@Data
@Embeddable
public class BasicInfo {
    @Column(length = 50)
    private String name;
    @Column(length = 20)
    private String phone;
    @Column(length = 100)
    private String email;
    @Column(length = 200)
    private String address;
    @Column(length = 500)
    private String avatar;
    @Column(length = 50)
    private String jobTitle;
    @Column(length = 100)
    private String blog;
    @Column(length = 100)
    private String github;
}

2.4 AI 简历优化服务

关键改进说明 :原文档中 parseAnalysisResult 方法返回的是硬编码的假数据,这在实际生产中不可用。本节使用 Spring AI 的 BeanOutputConverter 实现真正的结构化输出解析,让 LLM 返回的 JSON 自动反序列化为 POJO。

java 复制代码
package com.example.airesume.service;

import com.example.airesume.entity.Resume;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class AiResumeService {
    
    private final ChatClient.Builder chatClientBuilder;
    
    private static final String RESUME_OPTIMIZE_SYSTEM = """
            你是一位资深的 HR 和简历优化专家,擅长帮助求职者优化简历内容。
            请遵循以下原则:
            1. 使用 STAR 法则(情境、任务、行动、结果)描述经历
            2. 量化成果,使用具体数字
            3. 突出与目标职位相关的技能
            4. 保持简洁,避免冗余
            5. 使用动词开头的短句
            """;
    
    /**
     * 优化工作经历描述
     */
    public String optimizeWorkDescription(String company, String position,
                                           String originalDescription, String targetJob) {
        String prompt = String.format("""
                请帮我优化以下工作经历描述,目标职位是"%s"。
                
                公司:%s
                职位:%s
                原始描述:
                %s
                
                要求:
                1. 使用 STAR 法则重写
                2. 量化成果(如有可能)
                3. 突出与目标职位相关的技能和经验
                4. 每条成就单独一行,用"-"开头
                5. 只返回优化后的内容,不需要解释
                """, targetJob, company, position, originalDescription);
        
        return chatClientBuilder.build()
                .prompt(prompt)
                .call()
                .content();
    }
    
    /**
     * 优化自我评价
     */
    public String optimizeSelfEvaluation(String originalEvaluation,
                                          String targetJob, List<String> keySkills) {
        String prompt = String.format("""
                请帮我优化以下自我评价,目标职位是"%s"。
                
                原始评价:
                %s
                
                核心技能:%s
                
                要求:
                1. 突出核心竞争优势
                2. 与目标职位要求匹配
                3. 控制在 150 字以内
                4. 使用专业但自然的语言
                5. 只返回优化后的内容
                """, targetJob, originalEvaluation, String.join("、", keySkills));
        
        return chatClientBuilder.build()
                .prompt(prompt)
                .call()
                .content();
    }
    
    /**
     * 生成技能推荐 --- 结构化输出示例
     */
    public List<String> generateSkillSuggestions(String targetJob, String industry) {
        // 定义输出结构
        record SkillSuggestion(List<String> skills) {}
        
        var converter = new BeanOutputConverter<>(
                new ParameterizedTypeReference<SkillSuggestion>() {});
        
        String prompt = String.format("""
                针对"%s"职位(%s行业),推荐 8-10 个最重要的技能。
                
                要求:
                1. 包含硬技能和软技能
                2. 按重要性排序
                3. 严格按以下 JSON 格式返回:
                %s
                """, targetJob, industry, converter.getFormat());
        
        String response = chatClientBuilder.build()
                .prompt(prompt)
                .call()
                .content();
        
        SkillSuggestion result = converter.convert(response);
        return result.skills();
    }
    
    /**
     * 整体简历分析与建议 --- 使用 BeanOutputConverter 实现真正的结构化解析
     * 
     * 修正说明:原文档中 parseAnalysisResult 方法返回硬编码假数据,
     * 实际应用中会导致所有简历分析结果完全相同。此处改用 BeanOutputConverter,
     * 让 LLM 返回的 JSON 自动映射为 Java Record。
     */
    public ResumeAnalysisResult analyzeResume(Resume resume, String targetJob) {
        String resumeContent = buildResumeContent(resume);
        
        var converter = new BeanOutputConverter<>(ResumeAnalysisResult.class);
        
        String prompt = String.format("""
                请分析以下简历,目标职位是"%s"。
                
                简历内容:
                %s
                
                请提供以下分析:
                1. 整体评分(1-10分)
                2. 3个主要优点
                3. 3个需要改进的地方
                4. 具体的优化建议
                
                严格按照以下 JSON 格式返回(不要包含其他内容):
                %s
                """, targetJob, resumeContent, converter.getFormat());
        
        String response = chatClientBuilder.build()
                .prompt(prompt)
                .call()
                .content();
        
        try {
            return converter.convert(response);
        } catch (Exception e) {
            log.error("简历分析结果解析失败: {}", response, e);
            // 降级:返回默认结果
            return new ResumeAnalysisResult(5, List.of(), List.of(), 
                List.of("AI 分析暂时不可用,请稍后重试"));
        }
    }
    
    /**
     * 生成项目经历亮点
     */
    public String generateProjectHighlights(String projectName, String description,
                                              List<String> technologies) {
        String prompt = String.format("""
                请根据以下项目信息,生成 3-4 条项目亮点。
                
                项目名称:%s
                项目描述:%s
                技术栈:%s
                
                要求:
                1. 每个亮点用一行,以"-"开头
                2. 体现技术深度和业务价值
                3. 使用具体数据(如有可能)
                4. 突出个人贡献
                """, projectName, description, String.join(", ", technologies));
        
        return chatClientBuilder.build()
                .prompt(prompt)
                .call()
                .content();
    }
    
    private String buildResumeContent(Resume resume) {
        StringBuilder sb = new StringBuilder();
        if (resume.getBasicInfo() != null) {
            sb.append("姓名:").append(resume.getBasicInfo().getName()).append("\n");
            sb.append("求职职位:").append(resume.getBasicInfo().getJobTitle()).append("\n\n");
        }
        if (resume.getWorkExperiences() != null) {
            for (var work : resume.getWorkExperiences()) {
                sb.append("公司:").append(work.getCompany()).append("\n");
                sb.append("职位:").append(work.getPosition()).append("\n");
                sb.append(work.getDescription()).append("\n\n");
            }
        }
        return sb.toString();
    }
}

/**
 * 简历分析结果 --- 使用 Java Record 与 BeanOutputConverter 配合
 * 注意:必须提供无参构造器或使用 @JsonProperty 映射
 */
public record ResumeAnalysisResult(
        Integer score,
        List<String> strengths,
        List<String> weaknesses,
        List<String> suggestions
) {
    // Jackson 反序列化所需的默认构造器
    public ResumeAnalysisResult() {
        this(0, List.of(), List.of(), List.of());
    }
}

代码解析 :BeanOutputConverter 是 Spring AI 提供的结构化输出核心工具。它的工作流程是:调用 getFormat() 生成 JSON Schema 描述 → 将 Schema 注入 Prompt → LLM 按 Schema 返回 JSON → 调用 convert() 自动反序列化。使用 Java Record 配合 ParameterizedTypeReference 是处理泛型和嵌套结构的推荐方式。

生活类比:BeanOutputConverter 就像点菜时的标准菜单。 你给厨师(LLM)一份标准菜单(JSON Schema),说"按这个格式给我做菜"。厨师做好后,服务员(converter)直接把菜摆到对应的盘子里(Java 字段),你不需要自己动手装盘。

2.5 PDF 导出服务

java 复制代码
package com.example.airesume.service;

import com.example.airesume.entity.Resume;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

import java.io.ByteArrayOutputStream;
import java.io.File;

@Slf4j
@Service
@RequiredArgsConstructor
public class PdfExportService {
    
    private final TemplateEngine templateEngine;
    
    @Value("${pdf.font-path:/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc}")
    private String fontPath;
    
    /**
     * 导出简历为 PDF
     */
    public byte[] exportToPdf(Resume resume, String templateId) throws Exception {
        // 1. 使用 Thymeleaf 渲染 HTML
        String html = renderHtml(resume, templateId);
        
        // 2. 转换为 PDF
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            PdfRendererBuilder builder = new PdfRendererBuilder();
            
            // 关键修正:传入 baseUrl,否则相对路径的资源无法加载
            builder.withHtmlContent(html, "classpath:/templates/");
            
            // 关键补充:注册中文字体,否则 PDF 中文显示为方框
            File fontFile = new File(fontPath);
            if (fontFile.exists()) {
                builder.useFont(fontFile, "WenQuanYi Zen Hei");
            } else {
                log.warn("中文字体不存在: {},PDF 中文可能显示异常", fontPath);
            }
            
            builder.toStream(outputStream);
            builder.useFastMode();
            builder.run();
            
            return outputStream.toByteArray();
        }
    }
    
    /**
     * 渲染 HTML 模板
     */
    private String renderHtml(Resume resume, String templateId) {
        Context context = new Context();
        context.setVariable("resume", resume);
        
        // 根据模板ID选择不同模板
        String template = switch (templateId) {
            case "modern" -> "resume/modern";
            case "classic" -> "resume/classic";
            case "minimal" -> "resume/minimal";
            default -> "resume/default";
        };
        
        return templateEngine.process(template, context);
    }
}

关键补充说明:

  1. baseUrl 参数 :withHtmlContent(html, "classpath:/templates/") 让 PDF 渲染器能够解析 HTML 中的相对路径资源(如图片、CSS)
  2. 中文字体注册:OpenHTMLToPDF 默认不含中文字体,必须显式注册,否则中文会渲染为空白或方框
  3. 中文字体安装 :在服务器上需要预先安装中文字体,如 apt-get install fonts-wqy-zenhei,路径通常为 /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc

2.6 REST 控制器

java 复制代码
package com.example.airesume.controller;

import com.example.airesume.entity.Resume;
import com.example.airesume.service.AiResumeService;
import com.example.airesume.service.PdfExportService;
import com.example.airesume.service.ResumeService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/resume")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class ResumeController {
    
    private final ResumeService resumeService;
    private final AiResumeService aiResumeService;
    private final PdfExportService pdfExportService;
    
    @PostMapping
    public ResponseEntity<Resume> createResume(@RequestBody Resume resume) {
        return ResponseEntity.ok(resumeService.createResume(resume));
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<Resume> updateResume(@PathVariable Long id,
                                                @RequestBody Resume resume) {
        return ResponseEntity.ok(resumeService.updateResume(id, resume));
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<Resume> getResume(@PathVariable Long id) {
        return ResponseEntity.ok(resumeService.getResume(id));
    }
    
    @GetMapping("/list")
    public ResponseEntity<List<Resume>> getUserResumes(@RequestParam Long userId) {
        return ResponseEntity.ok(resumeService.getUserResumes(userId));
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteResume(@PathVariable Long id) {
        resumeService.deleteResume(id);
        return ResponseEntity.ok().build();
    }
    
    @PostMapping("/ai/optimize-work")
    public ResponseEntity<Map<String, String>> optimizeWork(
            @RequestBody OptimizeWorkRequest request) {
        String optimized = aiResumeService.optimizeWorkDescription(
                request.getCompany(),
                request.getPosition(),
                request.getDescription(),
                request.getTargetJob()
        );
        return ResponseEntity.ok(Map.of("optimized", optimized));
    }
    
    @PostMapping("/ai/optimize-evaluation")
    public ResponseEntity<Map<String, String>> optimizeEvaluation(
            @RequestBody OptimizeEvaluationRequest request) {
        String optimized = aiResumeService.optimizeSelfEvaluation(
                request.getEvaluation(),
                request.getTargetJob(),
                request.getKeySkills()
        );
        return ResponseEntity.ok(Map.of("optimized", optimized));
    }
    
    @GetMapping("/ai/suggest-skills")
    public ResponseEntity<Map<String, List<String>>> suggestSkills(
            @RequestParam String targetJob,
            @RequestParam String industry) {
        List<String> skills = aiResumeService.generateSkillSuggestions(targetJob, industry);
        return ResponseEntity.ok(Map.of("skills", skills));
    }
    
    @PostMapping("/ai/analyze")
    public ResponseEntity<?> analyzeResume(@RequestBody AnalyzeRequest request) {
        Resume resume = resumeService.getResume(request.getResumeId());
        return ResponseEntity.ok(aiResumeService.analyzeResume(resume, request.getTargetJob()));
    }
    
    @PostMapping("/ai/project-highlights")
    public ResponseEntity<Map<String, String>> generateHighlights(
            @RequestBody ProjectHighlightRequest request) {
        String highlights = aiResumeService.generateProjectHighlights(
                request.getProjectName(),
                request.getDescription(),
                request.getTechnologies()
        );
        return ResponseEntity.ok(Map.of("highlights", highlights));
    }
    
    @GetMapping("/{id}/export")
    public ResponseEntity<byte[]> exportPdf(@PathVariable Long id,
                                             @RequestParam(defaultValue = "default") String template)
            throws Exception {
        Resume resume = resumeService.getResume(id);
        byte[] pdfBytes = pdfExportService.exportToPdf(resume, template);
        
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        "attachment; filename=resume_" + id + ".pdf")
                .contentType(MediaType.APPLICATION_PDF)
                .body(pdfBytes);
    }
}

// DTO 类
@Data
class OptimizeWorkRequest {
    private String company;
    private String position;
    private String description;
    private String targetJob;
}

@Data
class OptimizeEvaluationRequest {
    private String evaluation;
    private String targetJob;
    private List<String> keySkills;
}

@Data
class AnalyzeRequest {
    private Long resumeId;
    private String targetJob;
}

@Data
class ProjectHighlightRequest {
    private String projectName;
    private String description;
    private List<String> technologies;
}

三、前端实现

3.1 项目初始化

bash 复制代码
npm create vite@latest ai-resume-frontend -- --template vue-ts
cd ai-resume-frontend
npm install
npm install element-plus @element-plus/icons-vue
npm install pinia vue-router axios
npm install @vueuse/core

3.2 简历编辑组件(核心部分)

vue 复制代码
<!-- src/views/ResumeEdit.vue -->
<template>
  <div class="resume-edit-container">
    <div class="edit-layout">
      <div class="editor-panel">
        <el-tabs v-model="activeTab">
          <!-- 工作经历 -->
          <el-tab-pane label="工作经历" name="work">
            <div v-for="(work, index) in resume.workExperiences" :key="index"
                 class="experience-item">
              <div class="item-header">
                <h4>工作经历 {{ index + 1 }}</h4>
                <el-button type="danger" size="small"
                           @click="removeWorkExperience(index)">删除</el-button>
              </div>
              <el-row :gutter="20">
                <el-col :span="12">
                  <el-form-item label="公司">
                    <el-input v-model="work.company" placeholder="公司名称" />
                  </el-form-item>
                </el-col>
                <el-col :span="12">
                  <el-form-item label="职位">
                    <el-input v-model="work.position" placeholder="职位名称" />
                  </el-form-item>
                </el-col>
              </el-row>
              <el-form-item label="工作描述">
                <el-input v-model="work.description" type="textarea" :rows="4"
                          placeholder="描述你的工作内容和成就" />
              </el-form-item>
              <el-button type="primary" size="small" @click="optimizeWork(index)">
                <el-icon><Magic /></el-icon>
                AI 优化描述
              </el-button>
            </div>
            <el-button @click="addWorkExperience" style="width: 100%">
              <el-icon><Plus /></el-icon>添加工作经历
            </el-button>
          </el-tab-pane>

          <!-- 自我评价 -->
          <el-tab-pane label="自我评价" name="evaluation">
            <el-input v-model="resume.selfEvaluation" type="textarea" :rows="6"
                      placeholder="介绍自己的优势和特点" maxlength="500" show-word-limit />
            <el-button type="primary" style="margin-top: 16px"
                       @click="optimizeEvaluation">
              <el-icon><Magic /></el-icon>AI 优化自我评价
            </el-button>
          </el-tab-pane>
        </el-tabs>

        <div class="action-bar">
          <el-button @click="saveResume">保存</el-button>
          <el-button type="primary" @click="exportPdf">导出 PDF</el-button>
          <el-button type="success" @click="analyzeResume">
            <el-icon><Magic /></el-icon>AI 分析
          </el-button>
        </div>
      </div>

      <!-- 右侧预览区 -->
      <div class="preview-panel">
        <resume-preview :resume="resume" :template-id="resume.templateId" />
      </div>
    </div>

    <!-- AI 分析结果弹窗 -->
    <el-dialog v-model="analysisVisible" title="AI 简历分析" width="600px">
      <div class="analysis-result">
        <div class="score-section">
          <el-progress type="dashboard" :percentage="analysisResult.score * 10"
                       :color="scoreColors" />
          <p>综合评分</p>
        </div>
        <div class="analysis-section">
          <h4><el-icon><CircleCheckFilled /></el-icon> 主要优点</h4>
          <ul>
            <li v-for="item in analysisResult.strengths" :key="item">{{ item }}</li>
          </ul>
        </div>
        <div class="analysis-section">
          <h4><el-icon><WarningFilled /></el-icon> 需要改进</h4>
          <ul>
            <li v-for="item in analysisResult.weaknesses" :key="item">{{ item }}</li>
          </ul>
        </div>
        <div class="analysis-section">
          <h4><el-icon><Promotion /></el-icon> 优化建议</h4>
          <ul>
            <li v-for="item in analysisResult.suggestions" :key="item">{{ item }}</li>
          </ul>
        </div>
      </div>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
  Magic, Plus, CircleCheckFilled, WarningFilled, Promotion
} from '@element-plus/icons-vue'
import { resumeApi } from '@/api/resume'
import ResumePreview from '@/components/ResumePreview.vue'

const route = useRoute()
const router = useRouter()

const activeTab = ref('basic')
const analysisVisible = ref(false)
const selectedSkills = ref<string[]>([])

const resume = reactive({
  id: null as number | null,
  title: '我的简历',
  basicInfo: {
    name: '', phone: '', email: '', address: '',
    jobTitle: '', blog: '', github: ''
  },
  educations: [],
  workExperiences: [] as any[],
  projectExperiences: [] as any[],
  skills: [] as any[],
  selfEvaluation: '',
  templateId: 'default'
})

const analysisResult = ref({
  score: 8,
  strengths: [] as string[],
  weaknesses: [] as string[],
  suggestions: [] as string[]
})

const scoreColors = [
  { color: '#f56c6c', percentage: 60 },
  { color: '#e6a23c', percentage: 70 },
  { color: '#5cb87a', percentage: 80 },
  { color: '#1989fa', percentage: 90 }
]

onMounted(async () => {
  const id = route.params.id
  if (id) {
    try {
      const { data } = await resumeApi.getResume(Number(id))
      Object.assign(resume, data)
    } catch (error) {
      ElMessage.error('加载简历失败')
    }
  }
})

const addWorkExperience = () => {
  resume.workExperiences.push({
    company: '', position: '', startDate: '', endDate: '',
    description: '', achievements: []
  })
}

const removeWorkExperience = (index: number) => {
  resume.workExperiences.splice(index, 1)
}

const optimizeWork = async (index: number) => {
  const work = resume.workExperiences[index]
  if (!work.description.trim()) {
    ElMessage.warning('请先输入工作描述')
    return
  }
  try {
    const targetJob = resume.basicInfo.jobTitle || ''
    const { data } = await resumeApi.optimizeWork({
      company: work.company,
      position: work.position,
      description: work.description,
      targetJob
    })
    work.description = data.optimized
    ElMessage.success('优化完成')
  } catch (error) {
    ElMessage.error('优化失败')
  }
}

const optimizeEvaluation = async () => {
  if (!resume.selfEvaluation.trim()) {
    ElMessage.warning('请先输入自我评价')
    return
  }
  try {
    const targetJob = resume.basicInfo.jobTitle || ''
    const { data } = await resumeApi.optimizeEvaluation({
      evaluation: resume.selfEvaluation,
      targetJob,
      keySkills: selectedSkills.value
    })
    resume.selfEvaluation = data.optimized
    ElMessage.success('优化完成')
  } catch (error) {
    ElMessage.error('优化失败')
  }
}

const saveResume = async () => {
  try {
    if (resume.id) {
      await resumeApi.updateResume(resume.id, resume)
    } else {
      const { data } = await resumeApi.createResume(resume)
      resume.id = data.id
    }
    ElMessage.success('保存成功')
  } catch (error) {
    ElMessage.error('保存失败')
  }
}

const exportPdf = async () => {
  if (!resume.id) {
    ElMessage.warning('请先保存简历')
    return
  }
  try {
    // 使用 axios 下载二进制文件,避免 window.open 的 CORS 问题
    const response = await fetch(
      `/api/resume/${resume.id}/export?template=${resume.templateId}`
    )
    const blob = await response.blob()
    const url = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = url
    link.download = `resume_${resume.id}.pdf`
    link.click()
    window.URL.revokeObjectURL(url)
    ElMessage.success('导出成功')
  } catch (error) {
    ElMessage.error('导出失败')
  }
}

const analyzeResume = async () => {
  if (!resume.id) {
    ElMessage.warning('请先保存简历')
    return
  }
  try {
    const targetJob = resume.basicInfo.jobTitle || ''
    const { data } = await resumeApi.analyzeResume({
      resumeId: resume.id,
      targetJob
    })
    analysisResult.value = data
    analysisVisible.value = true
  } catch (error) {
    ElMessage.error('分析失败')
  }
}
</script>

3.3 简历预览组件

vue 复制代码
<!-- src/components/ResumePreview.vue -->
<template>
  <div class="resume-preview" :class="`template-${templateId}`">
    <div class="resume-content">
      <!-- 头部 -->
      <header class="resume-header">
        <h1>{{ resume.basicInfo?.name || '姓名' }}</h1>
        <p class="job-title">{{ resume.basicInfo?.jobTitle || '求职职位' }}</p>
        <div class="contact-info">
          <span v-if="resume.basicInfo?.phone">{{ resume.basicInfo.phone }}</span>
          <span v-if="resume.basicInfo?.email">{{ resume.basicInfo.email }}</span>
          <span v-if="resume.basicInfo?.address">{{ resume.basicInfo.address }}</span>
        </div>
      </header>

      <!-- 工作经历 -->
      <section v-if="resume.workExperiences?.length" class="section">
        <h2 class="section-title">工作经历</h2>
        <div v-for="(work, idx) in resume.workExperiences" :key="idx"
             class="section-item">
          <div class="item-header">
            <h3>{{ work.company }} - {{ work.position }}</h3>
            <span class="date">{{ work.startDate }} 至 {{ work.endDate || '至今' }}</span>
          </div>
          <p class="description">{{ work.description }}</p>
        </div>
      </section>

      <!-- 技能 -->
      <section v-if="resume.skills?.length" class="section">
        <h2 class="section-title">专业技能</h2>
        <div class="skills-grid">
          <div v-for="skill in resume.skills" :key="skill.name" class="skill-bar">
            <span class="skill-name">{{ skill.name }}</span>
            <el-rate :model-value="skill.level" :max="5" disabled />
          </div>
        </div>
      </section>

      <!-- 自我评价 -->
      <section v-if="resume.selfEvaluation" class="section">
        <h2 class="section-title">自我评价</h2>
        <p>{{ resume.selfEvaluation }}</p>
      </section>
    </div>
  </div>
</template>

<script setup lang="ts">
defineProps<{
  resume: any
  templateId: string
}>()
</script>

<style scoped>
.resume-preview {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  line-height: 1.6;
  color: #333;
}

.resume-content {
  padding: 30px;
}

.resume-header {
  text-align: center;
  padding-bottom: 20px;
  border-bottom: 2px solid #409eff;
  margin-bottom: 20px;
}

.resume-header h1 {
  font-size: 28px;
  margin-bottom: 8px;
}

.job-title {
  font-size: 18px;
  color: #666;
  margin-bottom: 12px;
}

.contact-info {
  display: flex;
  justify-content: center;
  gap: 20px;
  color: #999;
  font-size: 14px;
}

.section {
  margin-bottom: 24px;
}

.section-title {
  font-size: 18px;
  color: #409eff;
  border-bottom: 1px solid #eee;
  padding-bottom: 8px;
  margin-bottom: 12px;
}

.item-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.item-header h3 {
  font-size: 16px;
}

.date {
  color: #999;
  font-size: 14px;
}

.description {
  color: #666;
  white-space: pre-line;
}

.skills-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
}

.skill-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

/* 不同模板样式 */
.template-modern .resume-header {
  background: linear-gradient(135deg, #409eff, #66b1ff);
  color: white;
  padding: 30px;
  border-radius: 8px;
}

.template-modern .resume-header h1,
.template-modern .job-title,
.template-modern .contact-info {
  color: white;
}

.template-classic .resume-header {
  border-bottom: 3px double #333;
}

.template-classic .section-title {
  color: #333;
  font-weight: bold;
}

.template-minimal {
  border: 1px solid #ddd;
  border-radius: 4px;
}

.template-minimal .section-title {
  font-size: 16px;
  text-transform: uppercase;
  letter-spacing: 2px;
}
</style>

四、总结

本文详细介绍了 AI 简历制作平台的全栈实现:

模块 核心能力 关键技术
简历 CRUD 增删改查、版本管理 Spring Data JPA
AI 优化 经历改写、自我评价优化、技能推荐 Spring AI ChatClient
结构化输出 简历分析结果自动反序列化 BeanOutputConverter + Java Record
PDF 导出 多模板渲染、中文字体支持 Thymeleaf + OpenHTMLToPDF
前端交互 实时预览、AI 分析弹窗 Vue3 + Element Plus

通过这个项目可以掌握全栈 AI 应用开发中表单处理、结构化输出、文档生成、AI 集成的核心技术。


参考资源:

博客: https://blog.csdn.net/badao_liumang_qizhi

相关推荐
ADAI_Bowen1 小时前
当AI读懂图纸:ADAI,用垂直大模型重构建筑方案的迭代逻辑
人工智能·重构
方方洛1 小时前
ai-agent教程-09-MCP模型上下文协议
人工智能·llm·agent
QinHsiu1 小时前
4.4%到61.1%,提升14倍:LongHarness证明证据管理比扩大窗口更有效
人工智能
Cyberpeace1 小时前
AI时代,内生安全基因 | 第九届“强网”拟态防御国际精英挑战赛邀你来战
人工智能
tianbin9111 小时前
告别多供应商管理,一站式聚合API的价值与实践
人工智能
加宝的起点雷达1 小时前
用 ChatGPT 退了 Adobe 订阅并免掉违约金——聊聊这类工具的工程可行性
人工智能
打工仔折腾 AI1 小时前
Spring AI + Qwen 周报生成实战:从原始日志到人工确认卡
人工智能·后端·python·spring·elasticsearch·ai agent 实战
橘和柠1 小时前
LangChain应用开发:用脚手架组装本地 LLM 应用
人工智能
翻滚的芋头1 小时前
注意力机制
人工智能