把一份 PDF 或 Word 拖进对话框,让 AI 读完再回答------这个交互体验上一秒钟的事,前端背后却有一堆脏活:拖拽态、类型校验、大文件、上传进度、解析中的占位。我按真实做下来的顺序记一遍,TypeScript。
拖拽区:别只监听 drop
新手最容易漏的是:只绑 drop 不绑 dragover,浏览器默认行为会直接把文件当新页面打开,你的回调根本不触发。四个事件都得管,而且要处理「拖拽进入子元素时 leave 误触发」。
ini
function useDropzone(onFiles: (f: File[]) => void) {
const dragging = ref(false);
let depth = 0; // 计数解决 enter/leave 抖动
const onEnter = (e: DragEvent) => { e.preventDefault(); depth++; dragging.value = true; };
const onLeave = (e: DragEvent) => { e.preventDefault(); if (--depth <= 0) dragging.value = false; };
const onOver = (e: DragEvent) => { e.preventDefault(); };
const onDrop = (e: DragEvent) => {
e.preventDefault();
depth = 0; dragging.value = false;
const files = Array.from(e.dataTransfer?.files ?? []);
onFiles(files);
};
return { dragging, onEnter, onLeave, onOver, onDrop };
}
那个 depth 计数器是重点。不用它的话,鼠标从拖拽区滑过里面任何一个子元素都会触发一次 dragleave,高亮边框疯狂闪。
类型和大小:前端先拦一道
别信文件后缀,.pdf 改个名也能传上来。但前端拿不到完整 magic number 又不想读整个文件,我的折中是后缀 + MIME 双重粗筛,真正的内容校验交给后端:
javascript
const ALLOW = ['.pdf', '.docx', '.md', '.txt', '.pptx'];
const MAX = 20 * 1024 * 1024; // 20MB
function check(f: File): string | null {
const ext = '.' + f.name.split('.').pop()!.toLowerCase();
if (!ALLOW.includes(ext)) return `不支持的格式:${ext}`;
if (f.size > MAX) return `文件太大(${(f.size / 1048576).toFixed(1)}MB)`;
if (f.size === 0) return '这是个空文件';
return null;
}
f.size === 0 那行是血泪。有用户从某网盘下载到一半的占位文件拖进来,0 字节,后端解析器直接抛异常,前端转圈不动。前面拦一下,体验好太多。
上传带进度:用 XHR 不用 fetch
到现在 fetch 的上传进度支持依然不靠谱,要进度条还得回去用 XMLHttpRequest 的 upload.onprogress:
typescript
function upload(file: File, onProgress: (p: number) => void): Promise<{ docId: string }> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append('file', file);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
};
xhr.onload = () => xhr.status < 300
? resolve(JSON.parse(xhr.responseText))
: reject(new Error('上传失败 ' + xhr.status));
xhr.onerror = () => reject(new Error('网络错误'));
xhr.open('POST', '/api/docs');
xhr.send(form);
});
}
解析是异步的,前端要会等
文件传完不等于能用了------后端还要抽取文本、切片、做向量化。这步可能要几秒到几十秒。前端别傻等,先把文档以「解析中」的灰色卡片塞进对话框,轮询状态:
javascript
async function waitReady(docId: string) {
for (let i = 0; i < 60; i++) {
const s = await fetch(`/api/docs/${docId}`).then(r => r.json());
if (s.status === 'ready') return s;
if (s.status === 'failed') throw new Error(s.error ?? '解析失败');
await new Promise(r => setTimeout(r, 1500));
}
throw new Error('解析超时');
}
体验上的关键:让用户在解析没完成时也能先打字。把问题先存住,文档 ready 了再连同 docId 一起发出去。不然用户拖完文件干等着,以为卡死了。
取舍
我没在前端做 PDF 文本抽取(pdf.js 能做,但大文件会把主线程卡死,还得处理扫描件 OCR),统一扔给后端。代价是多一次上传往返,换来前端逻辑干净、手机端也不发烫。
那「后端解析」这块我也没自己写。文本抽取、切片、向量化、检索,我整条交给了讯飞Agent------它是 MaaS,把这套 RAG 流程封成了上传 + 检索接口,我前端只管拖拽、校验、轮询状态,模型和算力都不用我操心。