From cb59b3693d5a3944597161d1db92dc5b80f5b77d Mon Sep 17 00:00:00 2001 From: mamamiyear Date: Tue, 25 Nov 2025 14:47:31 +0800 Subject: [PATCH] feat: support upload cover for people - wrappe the post and delete people image api, and upload image api - add cover preview when register and edit - support upload image when edit cover of people in edit and register --- src/apis/config.ts | 2 + src/apis/people.ts | 23 ++- src/apis/upload.ts | 4 + src/components/PeopleForm.tsx | 296 +++++++++++++++++++++++++++++----- 4 files changed, 279 insertions(+), 46 deletions(-) diff --git a/src/apis/config.ts b/src/apis/config.ts index c1b48fb..4e95bfc 100644 --- a/src/apis/config.ts +++ b/src/apis/config.ts @@ -17,7 +17,9 @@ export const API_ENDPOINTS = { // 新增单个资源路径 /people PEOPLE: '/people', PEOPLE_BY_ID: (id: string) => `/people/${id}`, + PEOPLE_IMAGE_BY_ID: (id: string) => `/people/${id}/image`, PEOPLE_REMARK_BY_ID: (id: string) => `/people/${id}/remark`, + UPLOAD_IMAGE: '/upload/image', // 用户相关 SEND_CODE: '/user/send_code', REGISTER: '/user', diff --git a/src/apis/people.ts b/src/apis/people.ts index 2b5fa0a..746ffbc 100644 --- a/src/apis/people.ts +++ b/src/apis/people.ts @@ -1,13 +1,13 @@ // 人员管理相关 API -import { get, post, del, put } from './request'; +import { get, post, del, put, upload } from './request'; import { API_ENDPOINTS } from './config'; import type { PostPeopleRequest, GetPeoplesParams, People, ApiResponse, - PaginatedResponse + PaginatedResponse } from './types'; /** @@ -140,6 +140,25 @@ export async function deleteRemark(peopleId: string): Promise { return del(API_ENDPOINTS.PEOPLE_REMARK_BY_ID(peopleId)); } +/** + * 上传人员照片 + * @param peopleId 人员ID + * @param file 照片文件 + * @returns Promise + */ +export async function uploadPeopleImage(peopleId: string, file: File): Promise> { + return upload>(API_ENDPOINTS.PEOPLE_IMAGE_BY_ID(peopleId), file, 'image'); +} + +/** + * 删除人员照片 + * @param peopleId 人员ID + * @returns Promise + */ +export async function deletePeopleImage(peopleId: string): Promise { + return del(API_ENDPOINTS.PEOPLE_IMAGE_BY_ID(peopleId)); +} + /** * 批量创建人员信息 * @param peopleList 人员信息数组 diff --git a/src/apis/upload.ts b/src/apis/upload.ts index 185d8d8..b4de9a6 100644 --- a/src/apis/upload.ts +++ b/src/apis/upload.ts @@ -106,4 +106,8 @@ export function validateImageFile(file: File, maxSize = 10 * 1024 * 1024): { val } return { valid: true }; +} + +export async function uploadImage(file: File): Promise> { + return upload>(API_ENDPOINTS.UPLOAD_IMAGE, file, 'image'); } \ No newline at end of file diff --git a/src/components/PeopleForm.tsx b/src/components/PeopleForm.tsx index 33b2e1d..412a246 100644 --- a/src/components/PeopleForm.tsx +++ b/src/components/PeopleForm.tsx @@ -1,9 +1,13 @@ import React, { useState, useEffect } from 'react'; -import { Form, Input, Select, InputNumber, Button, message, Row, Col } from 'antd'; +import { Form, Input, Select, InputNumber, Button, message, Row, Col, Image, Modal } from 'antd'; +import 'react-image-crop/dist/ReactCrop.css'; +import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop'; +import { UploadOutlined } from '@ant-design/icons'; import type { FormInstance } from 'antd'; + import './PeopleForm.css'; import KeyValueList from './KeyValueList.tsx' -import { createPeople, type People } from '../apis'; +import { createPeople, type People, uploadPeopleImage, uploadImage } from '../apis'; const { TextArea } = Input; @@ -18,6 +22,13 @@ interface PeopleFormProps { const PeopleForm: React.FC = ({ initialData, hideSubmitButton = false, onFormReady }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [imgSrc, setImgSrc] = React.useState('') + const [crop, setCrop] = React.useState() + const [completedCrop, setCompletedCrop] = React.useState() + const [modalVisible, setModalVisible] = React.useState(false) + const imgRef = React.useRef(null) + const previewCanvasRef = React.useRef(null) // 当 initialData 变化时,自动填充表单 useEffect(() => { @@ -97,6 +108,136 @@ const PeopleForm: React.FC = ({ initialData, hideSubmitButton = } }; + const onSelectFile = (file: File) => { + if (file) { + const reader = new FileReader() + reader.addEventListener('load', () => { + setImgSrc(reader.result?.toString() || '') + setModalVisible(true) + }) + reader.readAsDataURL(file) + } + return false + } + + const onImageLoad = (e: React.SyntheticEvent) => { + const { width, height } = e.currentTarget + const crop = centerCrop( + makeAspectCrop( + { + unit: '%', + width: 100, + }, + 1, + width, + height, + ), + width, + height, + ) + setCrop(crop) + } + + function canvasPreview( + image: HTMLImageElement, + canvas: HTMLCanvasElement, + crop: Crop, + ) { + const ctx = canvas.getContext('2d') + + if (!ctx) { + throw new Error('No 2d context') + } + + const scaleX = image.naturalWidth / image.width + const scaleY = image.naturalHeight / image.height + const pixelRatio = window.devicePixelRatio + canvas.width = Math.floor(crop.width * scaleX * pixelRatio) + canvas.height = Math.floor(crop.height * scaleY * pixelRatio) + + ctx.scale(pixelRatio, pixelRatio) + ctx.imageSmoothingQuality = 'high' + + const cropX = crop.x * scaleX + const cropY = crop.y * scaleY + + const centerX = image.naturalWidth / 2 + const centerY = image.naturalHeight / 2 + + ctx.save() + ctx.translate(-cropX, -cropY) + ctx.translate(centerX, centerY) + ctx.translate(-centerX, -centerY) + ctx.drawImage( + image, + 0, + 0, + image.naturalWidth, + image.naturalHeight, + 0, + 0, + image.naturalWidth, + image.naturalHeight, + ) + + ctx.restore() + } + + const onOk = async () => { + if (completedCrop && previewCanvasRef.current && imgRef.current) { + canvasPreview(imgRef.current, previewCanvasRef.current, completedCrop) + previewCanvasRef.current.toBlob(async (blob) => { + if (blob) { + setUploading(true) + try { + const response = initialData?.id + ? await uploadPeopleImage(initialData.id, blob as File) + : await uploadImage(blob as File); + + if (response.data) { + form.setFieldsValue({ cover: response.data }) + } + } catch { + message.error('图片上传失败') + } finally { + setUploading(false) + setModalVisible(false) + } + } + }, 'image/png') + } + } + + const coverUrl = Form.useWatch('cover', form); + + const coverPreviewNode = ( +
+ {coverUrl ? ( + 封面预览 + ) : ( +
封面预览
+ )} +
+ ); + return (
= ({ initialData, hideSubmitButton = size="large" onFinish={onFinish} > - - + @@ -119,48 +259,83 @@ const PeopleForm: React.FC = ({ initialData, hideSubmitButton = - - - - - - - + + {/* Left Side: Form Fields */} + - - - - } type="text" size="small" loading={uploading} onClick={() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const target = e.target as HTMLInputElement; + if (target.files?.[0]) { + onSelectFile(target.files[0]); + } + }; + input.click(); + }} /> + } + /> + + + {/* Mobile Only Preview */} + + + {coverPreviewNode} + + + + + + + + + + + - - - - - - - - - - - - - - - + {/* Right Side: Cover Preview (PC) */} + + + {coverPreviewNode} @@ -181,8 +356,41 @@ const PeopleForm: React.FC = ({ initialData, hideSubmitButton = )} + setModalVisible(false)} + okText="上传" + cancelText="取消" + + > + {imgSrc && ( + setCrop(percentCrop)} + onComplete={(c) => setCompletedCrop(c)} + aspect={1} + > + Crop me + + )} + +
); }; -export default PeopleForm; \ No newline at end of file +export default PeopleForm;