前端 + AI 进阶 Day5:多模态输入初探 —— 图片上传与实时预览
前端 + AI 进阶学习路线|Week 1-2:流式体验优化
Day 5:多模态输入初探 —— 图片上传与实时预览
学习时间:2025年12月29日(星期一)
关键词:图片上传、拖拽上传、截图粘贴、Clipboard API、File API、Canvas 预览
📁 项目文件结构
day05-image-upload/
├── src/
│ ├── components/
│ │ └── ImageUpload.jsx # 多模态图片上传组件(支持拖拽/粘贴)
│ └── App.jsx # 主应用入口
└── public/
✅ 本日不依赖第三方 UI 库,仅使用 React + 原生 Web API
🎯 今日学习目标
- 实现 三种图片上传方式:文件选择器、拖拽、截图粘贴(Ctrl+V / Cmd+V)
- 支持 本地预览(无需后端,使用
URL.createObjectURL) - 构建可复用
ImageUpload组件,输出File对象供后续 AI 分析 - 为 Day 6 的视觉 AI(如 LLaVA)准备输入素材
💡 为什么多模态输入对 AI 应用至关重要?
现代 AI 不再只是“文字聊天”:
- 用户可上传 截图 让 AI 解释错误信息
- 上传 商品图 让 AI 生成描述
- 粘贴 设计稿 让 AI 提建议
✅ 前端需成为 多模态入口:接收图片、音频、PDF 等,并预处理后传给 AI
今天,我们从 图片上传 开始!
📚 核心 Web API
| 功能 | API |
|---|---|
| 选择文件 | <input type="file" accept="image/*"> |
| 拖拽上传 | dragover / drop 事件 |
| 截图粘贴 | paste 事件 + clipboardData.items |
| 本地预览 | URL.createObjectURL(file) |
| 释放内存 | URL.revokeObjectURL(url)(组件卸载时调用) |
⚠️ 注意:
createObjectURL会占用内存,务必在组件销毁时清理
🔧 动手实践:构建多功能图片上传组件
步骤 1:创建项目(无需额外依赖)
npx create-react-app day05-image-upload
cd day05-image-upload
# 无需安装 npm 包,纯原生实现
步骤 2:编写图片上传组件
// src/components/ImageUpload.jsx
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* 多模态图片上传组件
* 支持:文件选择 / 拖拽 / 截图粘贴(Ctrl+V)
* 输出:File 对象(供 AI 分析)
*/
const ImageUpload = ({ onFileSelect, accept = "image/*" }) => {
const [previewUrl, setPreviewUrl] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef(null);
// 清理 object URL 避免内存泄漏
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
// 处理文件逻辑(校验 + 预览 + 回调)
const handleFile = useCallback((file) => {
if (!file) return;
// 类型校验
if (accept === "image/*" && !file.type.startsWith('image/')) {
alert('⚠️ 请上传图片文件(支持 PNG、JPG、GIF 等)');
return;
}
// 生成本地预览 URL
const url = URL.createObjectURL(file);
setPreviewUrl(url);
// 通知父组件
onFileSelect?.(file);
}, [onFileSelect, accept]);
// 点击触发文件选择器
const handleSelectClick = () => {
fileInputRef.current?.click();
};
// 文件选择器变化
const handleFileChange = (e) => {
const file = e.target.files?.[0];
handleFile(file);
// 重置 input value,允许重复上传同名文件
e.target.value = '';
};
// 拖拽事件
const handleDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
handleFile(file);
};
// 截图粘贴(全局)
const handlePaste = (e) => {
const items = e.clipboardData?.items;
if (!items) return;
// 遍历剪贴板项,查找图片
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') === 0) {
const blob = items[i].getAsFile();
if (blob) {
// 转为 File 对象(带文件名)
const file = new File([blob], `pasted-${Date.now()}.png`, {
type: blob.type,
});
handleFile(file);
break; // 只处理第一张图
}
}
}
};
return (
<div
style={{
padding: '24px',
border: '2px dashed #d9d9d9',
borderRadius: '12px',
textAlign: 'center',
backgroundColor: isDragging ? '#e6f7ff' : '#fafafa',
transition: 'background 0.2s ease',
cursor: 'pointer',
position: 'relative',
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleSelectClick}
onPaste={handlePaste}
tabIndex={0} // 使 div 可聚焦以接收 paste 事件
role="button"
aria-label="上传图片区域"
>
{/* 隐藏的原生文件 input */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept={accept}
style={{ display: 'none' }}
aria-hidden="true"
/>
{previewUrl ? (
<div>
<img
src={previewUrl}
alt="上传预览"
style={{
maxWidth: '100%',
maxHeight: '300px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
objectFit: 'contain',
}}
/>
<p style={{ marginTop: '12px', color: '#666', fontSize: '14px' }}>
✅ 已上传,点击重新选择
</p>
</div>
) : (
<div>
<div style={{ fontSize: '20px', marginBottom: '8px' }}>🖼️</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333' }}>
拖拽图片到此处,或点击上传
</div>
<div style={{ fontSize: '14px', color: '#888', marginTop: '6px' }}>
也支持截图后直接 <strong>Ctrl+V (Windows) / Cmd+V (Mac)</strong> 粘贴!
</div>
</div>
)}
</div>
);
};
export default ImageUpload;
步骤 3:在 App 中集成并展示文件信息
// src/App.jsx
import { useState } from 'react';
import ImageUpload from './components/ImageUpload';
function App() {
const [selectedFile, setSelectedFile] = useState(null);
const handleFileSelect = (file) => {
console.log('✅ 选中的文件:', {
name: file.name,
size: `${(file.size / 1024).toFixed(2)} KB`,
type: file.type,
});
setSelectedFile(file);
};
// 模拟发送给 AI(实际应调用视觉模型)
const handleSendToAI = () => {
if (!selectedFile) {
alert('请先上传一张图片!');
return;
}
alert(`📤 图片 "${selectedFile.name}" 已准备好发送给 AI 分析!`);
// 后续可在此调用:
// streamVisualAnalysis({ imageFile: selectedFile, prompt: "..." })
};
return (
<div style={{ padding: '20px', fontFamily: 'Inter, -apple-system, sans-serif', maxWidth: '700px', margin: '0 auto' }}>
<header style={{ textAlign: 'center', marginBottom: '32px' }}>
<h1 style={{ fontSize: '28px', fontWeight: '700', color: '#1d1d1f' }}>
多模态输入:图片上传
</h1>
<p style={{ color: '#666', fontSize: '16px' }}>
为 AI 视觉分析准备输入素材
</p>
</header>
<main>
<ImageUpload onFileSelect={handleFileSelect} />
{selectedFile && (
<div style={{ marginTop: '24px', padding: '16px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e9ecef' }}>
<h3 style={{ margin: '0 0 12px 0', fontSize: '18px', color: '#333' }}>📄 文件信息</h3>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
<li style={{ marginBottom: '8px' }}>
<strong>文件名:</strong> {selectedFile.name}
</li>
<li style={{ marginBottom: '8px' }}>
<strong>大小:</strong> {(selectedFile.size / 1024).toFixed(2)} KB
</li>
<li>
<strong>类型:</strong> {selectedFile.type}
</li>
</ul>
<button
onClick={handleSendToAI}
style={{
marginTop: '16px',
padding: '10px 24px',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '16px',
cursor: 'pointer',
fontWeight: '500',
transition: 'background 0.2s',
}}
onMouseOver={(e) => e.target.style.backgroundColor = '#40a9ff'}
onMouseOut={(e) => e.target.style.backgroundColor = '#1890ff'}
>
🤖 发送给 AI 分析
</button>
</div>
)}
</main>
<footer style={{ marginTop: '40px', textAlign: 'center', color: '#888', fontSize: '14px' }}>
Day 5 · 前端 + AI 实战 · 支持拖拽、粘贴、选择三种上传方式
</footer>
</div>
);
}
export default App;
✅ 效果验证
| 功能 | 操作 | 预期结果 |
|---|---|---|
| 文件选择 | 点击区域 → 选择图片 | 显示预览 |
| 拖拽上传 | 拖一张图到区域 | 自动上传并预览 |
| 截图粘贴 | 截图 → 切换到页面 → Ctrl+V | 图片自动粘贴上传 |
| 非图片文件 | 上传 PDF | 弹出警告 |
| 重复上传 | 上传后再次上传 | 预览更新,无内存泄漏 |
🤔 思考与延伸
-
大图性能:如何限制上传尺寸(如 >5MB 提示)?
→ 在handleFile中检查file.size -
多图上传:如何支持一次上传多张图?
→ 修改accept为multiple,状态改为数组 -
AI 集成准备:如何将图片转为 Base64 供 LLaVA 使用?
→ 使用FileReader读取(Day 6 将实现)
提示:为适配视觉模型(如 LLaVA),建议后续加入 图片压缩(Canvas 缩放)
📅 明日预告
Day 6:图片标注与 AI 视觉分析
- 在 Canvas 上实现 画框、圈选、文字标注
- 调用 Ollama LLaVA 进行流式视觉问答
- 构建“上传 → 标注 → 提问 → AI 回答”完整闭环
✍️ 小结
今天,我们让 AI 聊天界面“看得见”了!通过拖拽、粘贴、选择三种方式,用户可以轻松上传图片,前端实时预览,为后续的视觉理解打下坚实基础。多模态交互,从此刻开始。
💬 实践提示:如果截图粘贴无效,请确保点击上传区域使其获得焦点后再粘贴。欢迎分享你的多模态交互设计!

浙公网安备 33010602011771号