334 lines
9.3 KiB
TypeScript
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;
|