kra/web/src/components/Upload/Cropper.tsx

334 lines
9.3 KiB
TypeScript

/**
* KRA - Image Cropper Component
* Provides image cropping functionality with preview
*/
import React, { useState, useRef, useCallback } from 'react';
import { Modal, Button, Upload, Select, Space, Tooltip, message, Slider } from 'antd';
import {
RotateLeftOutlined,
RotateRightOutlined,
ZoomInOutlined,
ZoomOutOutlined,
UploadOutlined,
} from '@ant-design/icons';
import Cropper from 'react-cropper';
import 'cropperjs/dist/cropper.css';
import { uploadFile } from '@/services/kratos/fileUpload';
import { useToken } from '@/models';
import type { UploadFile, RcFile } from 'antd/es/upload/interface';
interface CropperImageProps {
/** Class ID for file categorization */
classId?: number;
/** Callback when upload succeeds */
onSuccess?: (url: string) => void;
/** Button text */
buttonText?: string;
/** Max file size in MB */
maxSize?: number;
}
interface RatioOption {
label: string;
value: number[];
}
const ratioOptions: RatioOption[] = [
{ label: '1:1', value: [1, 1] },
{ label: '16:9', value: [16, 9] },
{ label: '9:16', value: [9, 16] },
{ label: '4:3', value: [4, 3] },
{ label: '自由比例', value: [] },
];
const CropperImage: React.FC<CropperImageProps> = ({
classId = 0,
onSuccess,
buttonText = '裁剪上传',
maxSize = 8,
}) => {
const token = useToken();
const cropperRef = useRef<Cropper>(null);
const [visible, setVisible] = useState(false);
const [imgSrc, setImgSrc] = useState<string>('');
const [uploading, setUploading] = useState(false);
const [currentRatio, setCurrentRatio] = useState(4); // Default to free ratio
const [previewUrl, setPreviewUrl] = useState<string>('');
const [zoom, setZoom] = useState(1);
// Get aspect ratio based on selection
const getAspectRatio = useCallback(() => {
const ratio = ratioOptions[currentRatio].value;
if (ratio.length === 0) return NaN; // Free ratio
return ratio[0] / ratio[1];
}, [currentRatio]);
// Handle file selection
const handleBeforeUpload = (file: RcFile) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('请选择图片文件');
return false;
}
if (file.size / 1024 / 1024 > maxSize) {
message.error(`文件大小不能超过${maxSize}MB!`);
return false;
}
const reader = new FileReader();
reader.onload = (e) => {
setImgSrc(e.target?.result as string);
setVisible(true);
};
reader.readAsDataURL(file);
return false; // Prevent auto upload
};
// Rotate image
const handleRotate = (degree: number) => {
const cropper = cropperRef.current?.cropper;
if (cropper) {
cropper.rotate(degree);
}
};
// Zoom image
const handleZoom = (ratio: number) => {
const cropper = cropperRef.current?.cropper;
if (cropper) {
cropper.zoom(ratio);
}
};
// Handle ratio change
const handleRatioChange = (value: number) => {
setCurrentRatio(value);
const cropper = cropperRef.current?.cropper;
if (cropper) {
const ratio = ratioOptions[value].value;
if (ratio.length === 0) {
cropper.setAspectRatio(NaN);
} else {
cropper.setAspectRatio(ratio[0] / ratio[1]);
}
}
};
// Handle crop and upload
const handleUpload = async () => {
const cropper = cropperRef.current?.cropper;
if (!cropper) return;
setUploading(true);
try {
const canvas = cropper.getCroppedCanvas({
maxWidth: 1200,
maxHeight: 1200,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
canvas.toBlob(async (blob) => {
if (!blob) {
message.error('裁剪失败');
setUploading(false);
return;
}
const file = new File([blob], `${Date.now()}.jpg`, { type: 'image/jpeg' });
const formData = new FormData();
formData.append('file', file);
if (classId) {
formData.append('classId', String(classId));
}
try {
const res = await uploadFile(formData);
if (res.code === 0 && res.data?.file?.url) {
message.success('上传成功');
setVisible(false);
setImgSrc('');
onSuccess?.(res.data.file.url);
} else {
message.error(res.msg || '上传失败');
}
} catch (error: any) {
message.error('上传失败: ' + (error.message || '未知错误'));
} finally {
setUploading(false);
}
}, 'image/jpeg', 0.9);
} catch (error: any) {
setUploading(false);
message.error('裁剪失败: ' + (error.message || '未知错误'));
}
};
// Handle crop event for preview
const handleCrop = () => {
const cropper = cropperRef.current?.cropper;
if (cropper) {
const canvas = cropper.getCroppedCanvas({
maxWidth: 300,
maxHeight: 300,
});
setPreviewUrl(canvas.toDataURL());
}
};
// Handle modal close
const handleClose = () => {
setVisible(false);
setImgSrc('');
setPreviewUrl('');
setZoom(1);
};
return (
<>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={handleBeforeUpload}
>
<Button type="primary" icon={<UploadOutlined />}>
{buttonText}
</Button>
</Upload>
<Modal
title="图片裁剪"
open={visible}
width={1200}
onCancel={handleClose}
footer={[
<Button key="cancel" onClick={handleClose}>
</Button>,
<Button
key="upload"
type="primary"
loading={uploading}
onClick={handleUpload}
>
{uploading ? '上传中...' : '上传'}
</Button>,
]}
destroyOnClose
>
<div style={{ display: 'flex', gap: 30, height: 600 }}>
{/* Left: Cropper Area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div
style={{
flex: 1,
backgroundColor: '#f8f8f8',
borderRadius: 8,
overflow: 'hidden',
}}
>
{imgSrc && (
<Cropper
ref={cropperRef}
src={imgSrc}
style={{ height: '100%', width: '100%' }}
aspectRatio={getAspectRatio()}
guides={true}
viewMode={1}
minCropBoxHeight={10}
minCropBoxWidth={10}
background={false}
responsive={true}
autoCropArea={0.8}
checkOrientation={false}
crop={handleCrop}
/>
)}
</div>
{/* Toolbar */}
<div
style={{
marginTop: 20,
padding: 10,
backgroundColor: '#fff',
borderRadius: 8,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
display: 'flex',
alignItems: 'center',
gap: 16,
}}
>
<Space.Compact>
<Tooltip title="向左旋转">
<Button icon={<RotateLeftOutlined />} onClick={() => handleRotate(-90)} />
</Tooltip>
<Tooltip title="向右旋转">
<Button icon={<RotateRightOutlined />} onClick={() => handleRotate(90)} />
</Tooltip>
<Tooltip title="放大">
<Button icon={<ZoomInOutlined />} onClick={() => handleZoom(0.1)} />
</Tooltip>
<Tooltip title="缩小">
<Button icon={<ZoomOutOutlined />} onClick={() => handleZoom(-0.1)} />
</Tooltip>
</Space.Compact>
<Select
value={currentRatio}
onChange={handleRatioChange}
style={{ width: 120 }}
options={ratioOptions.map((item, index) => ({
label: item.label,
value: index,
}))}
/>
</div>
</div>
{/* Right: Preview Area */}
<div style={{ width: 340 }}>
<div
style={{
backgroundColor: '#fff',
padding: 20,
borderRadius: 8,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
}}
>
<div style={{ marginBottom: 15, color: '#666' }}></div>
<div
style={{
width: 300,
height: 300,
backgroundColor: '#f5f5f5',
borderRadius: 8,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt="preview"
style={{ maxWidth: '100%', maxHeight: '100%' }}
/>
) : (
<span style={{ color: '#999' }}></span>
)}
</div>
</div>
</div>
</div>
</Modal>
</>
);
};
export default CropperImage;