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);
}
}
关键补充说明:
- baseUrl 参数 :
withHtmlContent(html, "classpath:/templates/")让 PDF 渲染器能够解析 HTML 中的相对路径资源(如图片、CSS) - 中文字体注册:OpenHTMLToPDF 默认不含中文字体,必须显式注册,否则中文会渲染为空白或方框
- 中文字体安装 :在服务器上需要预先安装中文字体,如
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 集成的核心技术。
参考资源: