website-01/components/admin/ImageUpload.tsx
2026-06-22 14:43:46 +08:00

128 lines
3.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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