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 MB500 KB)。

总结

这是一个交互体验良好且逻辑严谨的上传组件:

  1. 容错性:限制了文件类型和大小,防止用户上传无效文件。
  2. 即时反馈:选中文件后立即显示文件名和大小,并提供移除选项。
  3. 响应式:使用了 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"

总结

这三个函数分别解决了前端开发中的三个常见问题:

  1. 样式管理 (cn):让动态 CSS 类名合并更安全。
  2. 数据展示 (formatSize):让复杂的文件大小数据变得易读。
  3. 身份标识 (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 个步骤

  1. 文件上传:调用 fs.upload 将用户的原始 PDF 简历上传到 Puter 云端存储。
  2. PDF 转图片:调用 convertPdfToImage
    • 为什么要这一步? 通常是因为 AI(如多模态大模型)分析图片格式的简历排版效果更好。
  3. 上传预览图:将转换后的图片也上传到云端,以便后续在卡片和详情页展示。
  4. 初始化数据库记录
    • 生成一个唯一的 uuid
    • 将职位信息、文件路径等初步存入 Puter 的 kv(键值对数据库)中,作为一条“解析中”的记录。
  5. AI 分析
    • 调用 ai.feedback,并将 PDF 路径和根据职位需求生成的提示词(prepareInstructions)发送给 AI。
  6. 解析与存储反馈
    • 接收 AI 返回的 JSON 格式的评分和建议。
    • 更新 kv 数据库中的该条记录,填入完整的反馈内容。
  7. 跳转:分析完成后,利用 navigate 自动跳转到该简历的详情页面 (/resume/${uuid})。

3. 表单提交处理:handleSubmit

  • 使用 FormData API 快速获取表单中的“公司名称”、“职位名称”和“职位描述”。
  • 在确保文件已选择的情况下,触发上述的 handleAnalyze 逻辑。

4. UI 界面布局与交互

  • 背景与导航:使用了全屏背景图和自定义的 Navbar
  • 条件渲染
    • 处理中状态:显示一个扫描简历的 GIF 动画,并动态更新 statusText(如“正在分析…”),给用户实时的心理反馈。
    • 正常状态:显示一个包含多个输入框的表单:
      • Company Name (文本输入)
      • Job Title (文本输入)
      • Job Description (多行文本域)
      • FileUploader (自定义的拖拽上传组件)
  • 按钮:点击“Analyze Resume”按钮开始整个分析流程。

总结

这是一个典型的 AI 赋能的 SaaS 业务逻辑

  1. 收集输入(用户填写职位信息和上传简历)。
  2. 数据预处理(PDF 转图片,上传云端)。
  3. 调用 AI 能力(结合上下文进行简历评估)。
  4. 数据持久化(存入 KV 数据库)。
  5. 结果展示(跳转到详情页)。

代码亮点

  • 用户体验:通过 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. 读取与初始化
  1. 加载库:确保 pdfjsLib 已就绪。
  2. 获取数据:将输入的 File 对象转换为 ArrayBuffer(二进制数组),供 PDF.js 读取。
  3. 解析文档:通过 getDocument 加载 PDF,并获取第 1 页 (getPage(1))。
B. 渲染设置
  1. 设置缩放scale: 4。这是一个高分辨率设置。默认缩放通常是 1,设置为 4 可以确保转换后的图片文字非常清晰(这对 AI 分析文字非常重要)。
  2. 创建画布 (Canvas):在内存中创建一个隐藏的 <canvas> 元素,并根据 PDF 页面的尺寸设置画布的宽高。
  3. 优化画质:开启 imageSmoothingEnabled 并设置为 high,确保图片边缘平滑,不产生锯齿。
C. 渲染与输出
  1. 执行渲染:调用 page.render 将 PDF 页面内容“画”到画布上。
  2. 导出 Blob:使用 canvas.toBlob 将画布内容导出为 PNG 格式的二进制数据。
  3. 封装为文件
    • 将导出的 Blob 重新包装成一个标准的 File 对象。
    • 自动更名:把原文件的 .pdf 后缀替换为 .png
  4. 生成预览地址:调用 URL.createObjectURL 生成一个临时链接,以便在页面上立即看到图片。

4. 异常处理

  • 使用了 try...catch 块。如果在加载库、解析 PDF 或渲染过程中出现任何问题(例如文件损坏),函数会捕获错误并返回包含错误信息的对象,而不会导致整个应用崩溃。

总结

这段代码的作用可以形象地理解为:“给 PDF 的第一页拍一张高清照片”

它的技术亮点:

  1. 按需加载:只有在需要转换时才下载大型 PDF.js 库。
  2. 高质量:通过 4 倍缩放,解决了 PDF 转图片常见的模糊问题。
  3. 自动化:自动处理了从二进制流到 Canvas,再到 Blob,最后到 File 对象的复杂转换过程。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐