website-01/components/admin/ImageUpload.tsx

128 lines
3.8 KiB
TypeScript
Raw Normal View History

2026-06-22 14:43:46 +08:00
'use client';
import { useState, useRef, useCallback } from 'react';
import { ImagePlus, Loader2, X } from 'lucide-react';
import { http } from '@/lib/api';
import { resolveUploadUrl, cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
export interface ImageUploadProps {
value?: string;
onChange: (url: string) => void;
className?: string;
/** 提示文字 */
hint?: string;
}
interface UploadResp {
url: string;
filename: string;
}
/**
*
* - jpg/png/jpeg/webp
* - 2M
* - POST /api/admin/upload
*/
export function ImageUpload({
value,
onChange,
className,
hint = '建议尺寸 1600x900支持 jpg/png/webp单图 ≤ 2M',
}: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = useCallback(
async (file: File) => {
const allowed = ['image/jpeg', 'image/png', 'image/jpg', 'image/webp'];
if (!allowed.includes(file.type)) {
setError('仅支持 jpg/png/jpeg/webp 格式');
return;
}
if (file.size > 2 * 1024 * 1024) {
setError('单图大小不能超过 2M');
return;
}
setError(null);
setUploading(true);
try {
const form = new FormData();
form.append('file', file);
const res = (await http.post<unknown, UploadResp>(
'/admin/upload',
form,
{ headers: { 'Content-Type': 'multipart/form-data' } },
)) as UploadResp;
onChange(res.url);
} catch (e) {
setError((e as Error).message);
} finally {
setUploading(false);
}
},
[onChange],
);
const onPick = () => inputRef.current?.click();
const preview = value ? resolveUploadUrl(value) : '';
return (
<div className={cn('flex items-start gap-4', className)}>
<div
className={cn(
'relative flex h-28 w-44 items-center justify-center overflow-hidden rounded-md border border-dashed border-gray-300 bg-gray-50',
!preview && 'cursor-pointer hover:border-brand-400',
)}
onClick={!preview ? onPick : undefined}
>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="预览" className="h-full w-full object-cover" />
) : uploading ? (
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
) : (
<div className="flex flex-col items-center text-gray-400">
<ImagePlus className="h-6 w-6" />
<span className="mt-1 text-xs"></span>
</div>
)}
{preview && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onChange('');
}}
className="absolute right-1 top-1 rounded-full bg-black/60 p-1 text-white hover:bg-black/80"
aria-label="移除"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<div className="flex-1">
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
e.target.value = '';
}}
/>
<Button type="button" variant="outline" size="sm" onClick={onPick} disabled={uploading}>
{uploading ? '上传中…' : '选择图片'}
</Button>
<p className="mt-2 text-xs text-gray-500">{hint}</p>
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</div>
</div>
);
}