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>
|
|||
|
|
);
|
|||
|
|
}
|