Next.js 集成
Dropup 完全兼容 Next.js,包括 App Router 和 Server Components。
安装
npm install @samithahansaka/dropup
基本设置
Dropup 是一个客户端库,所以需要使用 'use client' 指令:
// components/FileUploader.tsx
'use client';
import { useDropup } from '@samithahansaka/dropup';
export function FileUploader() {
const { files, getDropProps, getInputProps } = useDropup();
return (
<div {...getDropProps()} className="dropzone">
<input {...getInputProps()} />
<p>拖放文件到此处</p>
<ul>
{files.map(file => (
<li key={file.id}>{file.name}</li>
))}
</ul>
</div>
);
}
// app/upload/page.tsx
import { FileUploader } from '@/components/FileUploader';
export default function UploadPage() {
return (
<main>
<h1>上传文件</h1>
<FileUploader />
</main>
);
}
上传 API 路由
App Router(Route Handlers)
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: '未提供文件' },
{ status: 400 }
);
}
// 如果上传目录不存在则创建
const uploadsDir = path.join(process.cwd(), 'public', 'uploads');
await mkdir(uploadsDir, { recursive: true });
// 保存文件
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const filepath = path.join(uploadsDir, filename);
await writeFile(filepath, buffer);
return NextResponse.json({
url: `/uploads/${filename}`,
name: file.name,
size: file.size,
});
}
// 配置请求体大小限制
export const config = {
api: {
bodyParser: false,
},
};
Pages Router(API Routes)
// pages/api/upload.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import formidable from 'formidable';
import path from 'path';
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: '方法不允许' });
}
const form = formidable({
uploadDir: path.join(process.cwd(), 'public', 'uploads'),
keepExtensions: true,
filename: (name, ext) => `${Date.now()}-${name}${ext}`,
});
form.parse(req, (err, fields, files) => {
if (err) {
return res.status(500).json({ error: '上传失败' });
}
const file = Array.isArray(files.file) ? files.file[0] : files.file;
const filename = path.basename(file.filepath);
res.json({
url: `/uploads/${filename}`,
});
});
}
带上传功能的客户端组件
// components/FullUploader.tsx
'use client';
import { useDropup } from '@samithahansaka/dropup';
export function FullUploader() {
const {
files,
state,
actions,
getDropProps,
getInputProps,
} = useDropup({
accept: 'image/*',
maxSize: 10 * 1024 * 1024,
upload: {
url: '/api/upload',
method: 'POST',
},
onUploadComplete: (file) => {
console.log('已上传:', file.uploadedUrl);
},
});
return (
<div className="space-y-4">
<div
{...getDropProps()}
className={`
border-2 border-dashed rounded-lg p-8 text-center
transition-colors cursor-pointer
${state.isDragAccept ? 'border-green-500 bg-green-50' : ''}
${state.isDragReject ? 'border-red-500 bg-red-50' : ''}
${!state.isDragging ? 'border-gray-300 hover:border-gray-400' : ''}
`}
>
<input {...getInputProps()} />
<p className="text-gray-600">
拖放图片到此处或点击选择
</p>
</div>
{/* 文件列表 */}
<div className="space-y-2">
{files.map(file => (
<div
key={file.id}
className="flex items-center gap-4 p-3 bg-gray-50 rounded-lg"
>
{file.preview && (
<img
src={file.preview}
alt=""
className="w-12 h-12 object-cover rounded"
/>
)}
<div className="flex-1">
<p className="font-medium">{file.name}</p>
{file.status === 'uploading' && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${file.progress}%` }}
/>
</div>
)}
</div>
<button
onClick={() => actions.remove(file.id)}
className="text-red-500 hover:text-red-700"
>
移除
</button>
</div>
))}
</div>
{/* 上传按钮 */}
<button
onClick={() => actions.upload()}
disabled={state.isUploading || files.length === 0}
className={`
w-full py-3 rounded-lg font-medium
${state.isUploading || files.length === 0
? 'bg-gray-300 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'}
`}
>
{state.isUploading
? `上传中... ${state.progress}%`
: '上传全部'}
</button>
</div>
);
}
使用 Next.js 上传到 S3
API 路由
// app/api/s3/presign/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function POST(request: NextRequest) {
const { filename, contentType } = await request.json();
const key = `uploads/${Date.now()}-${filename}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3, command, { expiresIn: 3600 });
return NextResponse.json({
url,
key,
publicUrl: `https://${process.env.S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`,
});
}
客户端组件
// components/S3Uploader.tsx
'use client';
import { useDropup } from '@samithahansaka/dropup';
import { createS3Uploader } from '@samithahansaka/dropup/cloud/s3';
export function S3Uploader() {
const { files, getDropProps, getInputProps } = useDropup({
upload: createS3Uploader({
getPresignedUrl: async (file) => {
const response = await fetch('/api/s3/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
});
return response.json();
},
}),
});
return (
<div {...getDropProps()}>
<input {...getInputProps()} />
<p>上传到 S3</p>
</div>
);
}
Server Actions(Next.js 14+)
// app/actions.ts
'use server';
import { writeFile } from 'fs/promises';
import path from 'path';
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File;
if (!file) {
throw new Error('未提供文件');
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const filepath = path.join(process.cwd(), 'public', 'uploads', filename);
await writeFile(filepath, buffer);
return {
url: `/uploads/${filename}`,
};
}
// components/ServerActionUploader.tsx
'use client';
import { useDropup } from '@samithahansaka/dropup';
import { uploadFile } from '@/app/actions';
export function ServerActionUploader() {
const { files, actions, getDropProps, getInputProps } = useDropup({
upload: async (file, options) => {
const formData = new FormData();
formData.append('file', file.file);
const result = await uploadFile(formData);
return { url: result.url };
},
});
return (
<div {...getDropProps()}>
<input {...getInputProps()} />
<p>使用 Server Actions 上传</p>
</div>
);
}
环境变量
# .env.local
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
S3_BUCKET=your-bucket-name
SSR 注意事项
Dropup 是 SSR 安全的。它只在浏览器 API 可用时才使用它们:
// 这在 SSR 中完全正常工作
'use client';
import { useDropup } from '@samithahansaka/dropup';
export function Uploader() {
// Hook 只在客户端运行
const { files } = useDropup();
// ...
}
TypeScript 配置
要获得 Next.js 的完整类型支持:
// tsconfig.json
{
"compilerOptions": {
"moduleResolution": "bundler",
"strict": true
}
}
上传路由中间件
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 为上传路由添加认证检查
if (request.nextUrl.pathname.startsWith('/api/upload')) {
const token = request.headers.get('authorization');
if (!token) {
return NextResponse.json(
{ error: '未授权' },
{ status: 401 }
);
}
}
return NextResponse.next();
}
export const config = {
matcher: '/api/upload/:path*',
};