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
This commit is contained in:
2025-11-25 14:47:31 +08:00
parent 0256f0cf22
commit cb59b3693d
4 changed files with 279 additions and 46 deletions

View File

@@ -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',

View File

@@ -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<ApiResponse> {
return del<ApiResponse>(API_ENDPOINTS.PEOPLE_REMARK_BY_ID(peopleId));
}
/**
* 上传人员照片
* @param peopleId 人员ID
* @param file 照片文件
* @returns Promise<ApiResponse>
*/
export async function uploadPeopleImage(peopleId: string, file: File): Promise<ApiResponse<string>> {
return upload<ApiResponse<string>>(API_ENDPOINTS.PEOPLE_IMAGE_BY_ID(peopleId), file, 'image');
}
/**
* 删除人员照片
* @param peopleId 人员ID
* @returns Promise<ApiResponse>
*/
export async function deletePeopleImage(peopleId: string): Promise<ApiResponse> {
return del<ApiResponse>(API_ENDPOINTS.PEOPLE_IMAGE_BY_ID(peopleId));
}
/**
* 批量创建人员信息
* @param peopleList 人员信息数组

View File

@@ -106,4 +106,8 @@ export function validateImageFile(file: File, maxSize = 10 * 1024 * 1024): { val
}
return { valid: true };
}
export async function uploadImage(file: File): Promise<ApiResponse<string>> {
return upload<ApiResponse<string>>(API_ENDPOINTS.UPLOAD_IMAGE, file, 'image');
}

View File

@@ -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<PeopleFormProps> = ({ 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<Crop>()
const [completedCrop, setCompletedCrop] = React.useState<Crop>()
const [modalVisible, setModalVisible] = React.useState(false)
const imgRef = React.useRef<HTMLImageElement>(null)
const previewCanvasRef = React.useRef<HTMLCanvasElement>(null)
// 当 initialData 变化时,自动填充表单
useEffect(() => {
@@ -97,6 +108,136 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ 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<HTMLImageElement>) => {
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 = (
<div style={{
width: '100%',
height: '100%',
minHeight: '264px', // 预览区固定高度,与表单保持高度对齐
maxHeight: '264px', // 预览区固定高度,与表单保持高度对齐
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed #d9d9d9',
borderRadius: '8px',
background: '#fafafa',
padding: '8px'
}}>
{coverUrl ? (
<Image
src={coverUrl}
alt="封面预览"
style={{ height: '100%', maxHeight: '248px', objectFit: 'contain' }}
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2IChhEhKKTMoZ0hTwIQYUXOgjpAhLwzpDbQCBCwh_gswOQDz12JoLPj+7YM..."
preview={false}
/>
) : (
<div style={{ color: '#999' }}></div>
)}
</div>
);
return (
<div className="people-form">
<Form
@@ -105,10 +246,9 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
size="large"
onFinish={onFinish}
>
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="如:张三" />
</Form.Item>
</Col>
@@ -119,48 +259,83 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
</Col>
</Row>
<Row gutter={[12, 12]}>
<Col xs={24}>
<Form.Item name="cover" label="人物封面">
<Input placeholder="请输入图片链接(可留空)" />
</Form.Item>
</Col>
</Row>
<Row gutter={[24, 24]}>
{/* Left Side: Form Fields */}
<Col xs={24} md={12}>
<Row gutter={[12, 12]}>
<Col xs={24} md={6}>
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
<Select
placeholder="请选择性别"
options={[
{ label: '男', value: '男' },
{ label: '女', value: '女' },
{ label: '其他/保密', value: '其他/保密' },
]}
/>
</Form.Item>
<Row gutter={[12, 12]}>
<Col xs={24}>
<Form.Item name="cover" label="人物封面">
<Input
placeholder="请输入图片链接(可留空)"
suffix={
<Button icon={<UploadOutlined />} 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();
}} />
}
/>
</Form.Item>
</Col>
{/* Mobile Only Preview */}
<Col xs={24} md={0} className="ant-visible-xs">
<Form.Item label="封面预览">
{coverPreviewNode}
</Form.Item>
</Col>
</Row>
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
<Select
placeholder="请选择性别"
options={[
{ label: '男', value: '男' },
{ label: '女', value: '女' },
{ label: '其他/保密', value: '其他/保密' },
]}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="age" label="年龄" rules={[{ required: true, message: '请输入年龄' }]}>
<InputNumber min={0} max={120} style={{ width: '100%' }} placeholder="如28" />
</Form.Item>
</Col>
</Row>
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="height" label="身高(cm)">
<InputNumber
min={0}
max={250}
style={{ width: '100%' }}
placeholder="如175可留空"
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="marital_status" label="婚姻状况">
<Input placeholder="可自定义输入,例如:未婚、已婚、离异等" />
</Form.Item>
</Col>
</Row>
</Col>
<Col xs={24} md={6}>
<Form.Item name="age" label="年龄" rules={[{ required: true, message: '请输入年龄' }]}>
<InputNumber min={0} max={120} style={{ width: '100%' }} placeholder="如28" />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name="height" label="身高(cm)">
<InputNumber
min={0}
max={250}
style={{ width: '100%' }}
placeholder="如175可留空"
/>
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name="marital_status" label="婚姻状况">
<Input placeholder="可自定义输入,例如:未婚、已婚、离异等" />
{/* Right Side: Cover Preview (PC) */}
<Col xs={0} md={12} className="ant-hidden-xs">
<Form.Item label="封面预览">
{coverPreviewNode}
</Form.Item>
</Col>
</Row>
@@ -181,8 +356,41 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
</Form.Item>
)}
</Form>
<Modal
title="裁剪图片"
open={modalVisible}
onOk={onOk}
onCancel={() => setModalVisible(false)}
okText="上传"
cancelText="取消"
>
{imgSrc && (
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(c) => setCompletedCrop(c)}
aspect={1}
>
<img
ref={imgRef}
alt="Crop me"
src={imgSrc}
onLoad={onImageLoad}
/>
</ReactCrop>
)}
</Modal>
<canvas
ref={previewCanvasRef}
style={{
display: 'none',
width: Math.round(completedCrop?.width ?? 0),
height: Math.round(completedCrop?.height ?? 0),
}}
/>
</div>
);
};
export default PeopleForm;
export default PeopleForm;