构建并部署一套全人工智能驱动的求职者追踪系统(第三部分)
npm install react-dropzone
app/components/FileUploader.tsx
import {useState, useCallback} from 'react'
import {useDropzone} from 'react-dropzone'
import { formatSize } from '../lib/utils'
interface FileUploaderProps {
onFileSelect?: (file: File | null) => void;
}
const FileUploader = ({ onFileSelect }: FileUploaderProps) => {
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0] || null;
onFileSelect?.(file);
}, [onFileSelect]);
const maxFileSize = 20 * 1024 * 1024; // 20MB in bytes
const {getRootProps, getInputProps, isDragActive, acceptedFiles} = useDropzone({
onDrop,
multiple: false,
accept: { 'application/pdf': ['.pdf']},
maxSize: maxFileSize,
})
const file = acceptedFiles[0] || null;
return (
<div className="w-full gradient-border">
<div {...getRootProps()}>
<input {...getInputProps()} />
<div className="space-y-4 cursor-pointer">
{file ? (
<div className="uploader-selected-file" onClick={(e) => e.stopPropagation()}>
<img src="/images/pdf.png" alt="pdf" className="size-10" />
<div className="flex items-center space-x-3">
<div>
<p className="text-sm font-medium text-gray-700 truncate max-w-xs">
{file.name}
</p>
<p className="text-sm text-gray-500">
{formatSize(file.size)}
</p>
</div>
</div>
<button className="p-2 cursor-pointer" onClick={(e) => {
onFileSelect?.(null)
}}>
<img src="/icons/cross.svg" alt="remove" className="w-4 h-4" />
</button>
</div>
): (
<div>
<div className="mx-auto w-16 h-16 flex items-center justify-center mb-2">
<img src="/icons/info.svg" alt="upload" className="size-20" />
</div>
<p className="text-lg text-gray-500">
<span className="font-semibold">
Click to upload
</span> or drag and drop
</p>
<p className="text-lg text-gray-500">PDF (max {formatSize(maxFileSize)})</p>
</div>
)}
</div>
</div>
</div>
)
}
export default FileUploader
这段代码定义了一个名为 FileUploader 的 React 组件,专门用于实现简历(PDF 格式)的上传功能。它使用了流行的 react-dropzone 库来处理拖拽和文件选择。
以下是该组件的详细解析:
1. 核心逻辑与配置
- 库引用:使用了
react-dropzone。这是 React 中处理文件上传的标准库。 - 限制条件:
multiple: false: 每次只允许上传一个文件。accept: { 'application/pdf': ['.pdf'] }: 仅允许 PDF 格式。maxSize: 20 * 1024 * 1024: 限制文件最大为 20MB。
- 回调函数 (
onDrop):当用户放下文件或选择文件后触发。它会提取第一个文件并通过onFileSelect属性(Props)通知父组件。
2. UI 界面展示
组件根据是否已经选择了文件,展示两种不同的状态:
A. 未选择文件状态(上传提示区)
- 显示一个图标和提示文字:“Click to upload or drag and drop”(点击上传或拖拽)。
- 告知用户文件格式限制(PDF)和最大尺寸(20MB)。
- 整个区域是可点击的,点击后会打开系统的文件选择器。
B. 已选择文件状态(文件信息预览)
当用户选择了一个文件后,界面会切换:
- 图标:显示一个 PDF 图标。
- 信息:显示文件名(带有截断处理
truncate,防止名字太长溢出)和文件大小。 - 删除按钮:右侧有一个叉号(X)图标。
- 点击删除按钮时,调用
onFileSelect?.(null)清空选择。 - 注意使用了
e.stopPropagation(),这是为了防止点击删除按钮时,再次触发外层容器的文件打开弹窗。
- 点击删除按钮时,调用
3. 样式与视觉效果
gradient-border: 沿用了之前 CSS 定义的渐变边框效果。cursor-pointer: 鼠标悬停时显示手型,提示用户该区域可以交互。truncate&max-w-xs: 确保长文件名不会破坏布局。
4. 辅助函数
formatSize: 这是一个自定义的工具函数,用于将字节(Bytes)转换成更易读的格式(如1.2 MB或500 KB)。
总结
这是一个交互体验良好且逻辑严谨的上传组件:
- 容错性:限制了文件类型和大小,防止用户上传无效文件。
- 即时反馈:选中文件后立即显示文件名和大小,并提供移除选项。
- 响应式:使用了 Tailwind CSS 确保在不同屏幕下表现良好。
app/lib/utils.ts
import {type ClassValue, clsx} from "clsx";
import {twMerge} from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
// Determine the appropriate unit by calculating the log
const i = Math.floor(Math.log(bytes) / Math.log(k));
// Format with 2 decimal places and round
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export const generateUUID = () => crypto.randomUUID();
这段代码包含了三个非常实用的工具函数,通常用于现代前端开发(尤其是使用 Tailwind CSS 的项目)。
以下是详细解释:
1. cn 函数 (类名合并工具)
这是目前 Tailwind CSS 社区最标准的写法,通常用于动态管理组件的类名。
- 它的原理:
clsx: 允许你通过对象、数组或条件表达式来拼接类名。例如:clsx('base', isTrue && 'active')。twMerge: 解决 Tailwind 类名冲突。比如你传入了p-4又传入了p-2,它会自动保留最后面的样式,防止 CSS 优先级导致样式混乱。
- 作用:让你在写 React 组件时,可以非常方便地合并自定义类名和默认类名,且不会产生样式冲突。
2. formatSize 函数 (文件大小格式化)
将原本以“字节”(Bytes)为单位的数字,转换成人类可读的格式(如 KB, MB, GB)。
- 逻辑拆解:
- 零值处理:如果字节是 0,直接返回 ‘0 Bytes’。
- 单位进位:设定
k = 1024(计算机标准的 1024 进位制)。 - 对数计算:使用
Math.log(bytes) / Math.log(k)来计算当前数字属于哪个量级(索引 iii)。例如,如果是 1024 字节,计算结果为 1,对应sizes[1]也就是 ‘KB’。 - 保留小数:计算结果保留 2 位小数,并移除末尾多余的零(通过
parseFloat)。
- 示例:
formatSize(1024)->"1 KB"formatSize(1572864)->"1.5 MB"
3. generateUUID 函数 (唯一 ID 生成)
生成一个全球唯一标识符(UUID)。
- 逻辑:它调用了现代浏览器原生支持的
crypto.randomUUID()方法。 - 作用:相比于
Math.random(),这种方法生成的 ID 碰撞概率极低,非常适合用于数据库主键、文件 ID 或 React 列表的key。 - 格式示例:
"550e8400-e29b-41d4-a716-446655440000"
总结
这三个函数分别解决了前端开发中的三个常见问题:
- 样式管理 (
cn):让动态 CSS 类名合并更安全。 - 数据展示 (
formatSize):让复杂的文件大小数据变得易读。 - 身份标识 (
generateUUID):生成唯一的字符串 ID。
app/routes/upload.tsx
import {type FormEvent, useState} from 'react'
import Navbar from "~/components/Navbar";
import FileUploader from "~/components/FileUploader";
import {usePuterStore} from "~/lib/puter";
import {useNavigate} from "react-router";
import {convertPdfToImage} from "~/lib/pdf2img";
import {generateUUID} from "~/lib/utils";
import {prepareInstructions} from "../../constants";
const Upload = () => {
const { auth, isLoading, fs, ai, kv } = usePuterStore();
const navigate = useNavigate();
const [isProcessing, setIsProcessing] = useState(false);
const [statusText, setStatusText] = useState('');
const [file, setFile] = useState<File | null>(null);
const handleFileSelect = (file: File | null) => {
setFile(file)
}
const handleAnalyze = async ({ companyName, jobTitle, jobDescription, file }: { companyName: string, jobTitle: string, jobDescription: string, file: File }) => {
setIsProcessing(true);
setStatusText('Uploading the file...');
const uploadedFile = await fs.upload([file]);
if(!uploadedFile) return setStatusText('Error: Failed to upload file');
setStatusText('Converting to image...');
const imageFile = await convertPdfToImage(file);
if(!imageFile.file) return setStatusText('Error: Failed to convert PDF to image');
setStatusText('Uploading the image...');
const uploadedImage = await fs.upload([imageFile.file]);
if(!uploadedImage) return setStatusText('Error: Failed to upload image');
setStatusText('Preparing data...');
const uuid = generateUUID();
const data = {
id: uuid,
resumePath: uploadedFile.path,
imagePath: uploadedImage.path,
companyName, jobTitle, jobDescription,
feedback: '',
}
await kv.set(`resume:${uuid}`, JSON.stringify(data));
setStatusText('Analyzing...');
const feedback = await ai.feedback(
uploadedFile.path,
prepareInstructions({ jobTitle, jobDescription })
)
if (!feedback) return setStatusText('Error: Failed to analyze resume');
const feedbackText = typeof feedback.message.content === 'string'
? feedback.message.content
: feedback.message.content[0].text;
data.feedback = JSON.parse(feedbackText);
await kv.set(`resume:${uuid}`, JSON.stringify(data));
setStatusText('Analysis complete, redirecting...');
console.log(data);
navigate(`/resume/${uuid}`);
}
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget.closest('form');
if(!form) return;
const formData = new FormData(form);
const companyName = formData.get('company-name') as string;
const jobTitle = formData.get('job-title') as string;
const jobDescription = formData.get('job-description') as string;
if(!file) return;
handleAnalyze({ companyName, jobTitle, jobDescription, file });
}
return (
<main className="bg-[url('/images/bg-main.svg')] bg-cover">
<Navbar />
<section className="main-section">
<div className="page-heading py-16">
<h1>Smart feedback for your dream job</h1>
{isProcessing ? (
<>
<h2>{statusText}</h2>
<img src="/images/resume-scan.gif" className="w-full" />
</>
) : (
<h2>Drop your resume for an ATS score and improvement tips</h2>
)}
{!isProcessing && (
<form id="upload-form" onSubmit={handleSubmit} className="flex flex-col gap-4 mt-8">
<div className="form-div">
<label htmlFor="company-name">Company Name</label>
<input type="text" name="company-name" placeholder="Company Name" id="company-name" />
</div>
<div className="form-div">
<label htmlFor="job-title">Job Title</label>
<input type="text" name="job-title" placeholder="Job Title" id="job-title" />
</div>
<div className="form-div">
<label htmlFor="job-description">Job Description</label>
<textarea rows={5} name="job-description" placeholder="Job Description" id="job-description" />
</div>
<div className="form-div">
<label htmlFor="uploader">Upload Resume</label>
<FileUploader onFileSelect={handleFileSelect} />
</div>
<button className="primary-button" type="submit">
Analyze Resume
</button>
</form>
)}
</div>
</section>
</main>
)
}
export default Upload
这段代码定义了一个名为 Upload 的 React 页面组件,它是该应用的核心功能页:简历上传与 AI 分析页。
它完整地展示了用户从填写职位信息到获取 AI 简历评估报告的整个业务流。以下是代码的详细逻辑分解:
1. 核心状态管理
usePuterStore: 获取 Puter.js 的各种能力,包括身份验证 (auth)、文件系统 (fs)、人工智能 (ai) 和键值对存储 (kv)。isProcessing: 这是一个布尔开关,用于控制 UI 状态。如果为true,页面会隐藏表单并显示“扫描中”的动画和进度文字。statusText: 用于向用户展示当前的后台进度(例如:“正在上传…”、“正在分析…”)。file: 存储用户通过FileUploader组件选择的 PDF 文件对象。
2. 核心业务逻辑:handleAnalyze(重点)
这是一个复杂的异步函数,按顺序执行了以下 7 个步骤:
- 文件上传:调用
fs.upload将用户的原始 PDF 简历上传到 Puter 云端存储。 - PDF 转图片:调用
convertPdfToImage。- 为什么要这一步? 通常是因为 AI(如多模态大模型)分析图片格式的简历排版效果更好。
- 上传预览图:将转换后的图片也上传到云端,以便后续在卡片和详情页展示。
- 初始化数据库记录:
- 生成一个唯一的
uuid。 - 将职位信息、文件路径等初步存入 Puter 的
kv(键值对数据库)中,作为一条“解析中”的记录。
- 生成一个唯一的
- AI 分析:
- 调用
ai.feedback,并将 PDF 路径和根据职位需求生成的提示词(prepareInstructions)发送给 AI。
- 调用
- 解析与存储反馈:
- 接收 AI 返回的 JSON 格式的评分和建议。
- 更新
kv数据库中的该条记录,填入完整的反馈内容。
- 跳转:分析完成后,利用
navigate自动跳转到该简历的详情页面 (/resume/${uuid})。
3. 表单提交处理:handleSubmit
- 使用
FormDataAPI 快速获取表单中的“公司名称”、“职位名称”和“职位描述”。 - 在确保文件已选择的情况下,触发上述的
handleAnalyze逻辑。
4. UI 界面布局与交互
- 背景与导航:使用了全屏背景图和自定义的
Navbar。 - 条件渲染:
- 处理中状态:显示一个扫描简历的 GIF 动画,并动态更新
statusText(如“正在分析…”),给用户实时的心理反馈。 - 正常状态:显示一个包含多个输入框的表单:
Company Name(文本输入)Job Title(文本输入)Job Description(多行文本域)FileUploader(自定义的拖拽上传组件)
- 处理中状态:显示一个扫描简历的 GIF 动画,并动态更新
- 按钮:点击“Analyze Resume”按钮开始整个分析流程。
总结
这是一个典型的 AI 赋能的 SaaS 业务逻辑:
- 收集输入(用户填写职位信息和上传简历)。
- 数据预处理(PDF 转图片,上传云端)。
- 调用 AI 能力(结合上下文进行简历评估)。
- 数据持久化(存入 KV 数据库)。
- 结果展示(跳转到详情页)。
代码亮点:
- 用户体验:通过
statusText让用户知道程序没死机,而是在一步步处理。 - 云端集成:完全基于 Puter.js,无需自己搭建后端服务器即可实现文件存储、数据库和 AI 调用。
- 安全性:使用 UUID 确保每个简历链接的唯一性。
app/lib/pdf2img.ts
export interface PdfConversionResult {
imageUrl: string;
file: File | null;
error?: string;
}
let pdfjsLib: any = null;
let isLoading = false;
let loadPromise: Promise<any> | null = null;
async function loadPdfJs(): Promise<any> {
if (pdfjsLib) return pdfjsLib;
if (loadPromise) return loadPromise;
isLoading = true;
// @ts-expect-error - pdfjs-dist/build/pdf.mjs is not a module
loadPromise = import("pdfjs-dist/build/pdf.mjs").then((lib) => {
// Set the worker source to use local file
lib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
pdfjsLib = lib;
isLoading = false;
return lib;
});
return loadPromise;
}
export async function convertPdfToImage(
file: File
): Promise<PdfConversionResult> {
try {
const lib = await loadPdfJs();
const arrayBuffer = await file.arrayBuffer();
const pdf = await lib.getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 4 });
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = viewport.width;
canvas.height = viewport.height;
if (context) {
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
}
await page.render({ canvasContext: context!, viewport }).promise;
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
if (blob) {
// Create a File from the blob with the same name as the pdf
const originalName = file.name.replace(/\.pdf$/i, "");
const imageFile = new File([blob], `${originalName}.png`, {
type: "image/png",
});
resolve({
imageUrl: URL.createObjectURL(blob),
file: imageFile,
});
} else {
resolve({
imageUrl: "",
file: null,
error: "Failed to create image blob",
});
}
},
"image/png",
1.0
); // Set quality to maximum (1.0)
});
} catch (err) {
return {
imageUrl: "",
file: null,
error: `Failed to convert PDF: ${err}`,
};
}
}
这段代码是一个工具模块,其核心功能是将 PDF 文件的第一页转换为高质量的 PNG 图片。这在简历分析应用中非常常见,用于生成简历的预览图,方便用户查看或供 AI 进行视觉分析。
以下是代码的详细解释:
1. 结果接口定义 (PdfConversionResult)
定义了转换函数返回的数据结构:
imageUrl: 用于在浏览器中直接显示的预览地址(Blob URL)。file: 转换后的图片文件对象(File类型),可以直接上传到服务器。error: 可选的错误信息。
2. 动态加载 PDF.js (loadPdfJs)
PDF.js 是一个体积较大的库,为了优化性能,代码采用了异步动态导入:
- 单例模式:使用
loadPromise确保全局只加载一次库文件,避免重复加载。 - Worker 配置:设置了
workerSrc。PDF.js 使用 Web Worker 在后台处理复杂的渲染任务,以防止浏览器界面卡死。
3. 核心转换函数 (convertPdfToImage)
这是最主要的部分,逻辑步骤如下:
A. 读取与初始化
- 加载库:确保
pdfjsLib已就绪。 - 获取数据:将输入的
File对象转换为ArrayBuffer(二进制数组),供 PDF.js 读取。 - 解析文档:通过
getDocument加载 PDF,并获取第 1 页 (getPage(1))。
B. 渲染设置
- 设置缩放:
scale: 4。这是一个高分辨率设置。默认缩放通常是 1,设置为 4 可以确保转换后的图片文字非常清晰(这对 AI 分析文字非常重要)。 - 创建画布 (Canvas):在内存中创建一个隐藏的
<canvas>元素,并根据 PDF 页面的尺寸设置画布的宽高。 - 优化画质:开启
imageSmoothingEnabled并设置为high,确保图片边缘平滑,不产生锯齿。
C. 渲染与输出
- 执行渲染:调用
page.render将 PDF 页面内容“画”到画布上。 - 导出 Blob:使用
canvas.toBlob将画布内容导出为 PNG 格式的二进制数据。 - 封装为文件:
- 将导出的 Blob 重新包装成一个标准的
File对象。 - 自动更名:把原文件的
.pdf后缀替换为.png。
- 将导出的 Blob 重新包装成一个标准的
- 生成预览地址:调用
URL.createObjectURL生成一个临时链接,以便在页面上立即看到图片。
4. 异常处理
- 使用了
try...catch块。如果在加载库、解析 PDF 或渲染过程中出现任何问题(例如文件损坏),函数会捕获错误并返回包含错误信息的对象,而不会导致整个应用崩溃。
总结
这段代码的作用可以形象地理解为:“给 PDF 的第一页拍一张高清照片”。
它的技术亮点:
- 按需加载:只有在需要转换时才下载大型 PDF.js 库。
- 高质量:通过 4 倍缩放,解决了 PDF 转图片常见的模糊问题。
- 自动化:自动处理了从二进制流到 Canvas,再到 Blob,最后到 File 对象的复杂转换过程。
更多推荐



所有评论(0)