128 lines
3.8 KiB
TypeScript
128 lines
3.8 KiB
TypeScript
'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>
|
||
);
|
||
}
|