From fee01abb60dc0def4178556ebab1113cffd088b2 Mon Sep 17 00:00:00 2001 From: mamamiyear Date: Wed, 3 Dec 2025 09:03:52 +0800 Subject: [PATCH] feat: support custom management - add page to register a custom - add page to show custom table - custom can be deleted and updated - custom can be shared by a generated image - refactor recognization api for both people and custom --- src/App.tsx | 8 +- src/apis/config.ts | 10 +- src/apis/custom.ts | 76 +++ src/apis/index.ts | 7 +- src/apis/input.ts | 8 +- src/apis/types.ts | 50 +- src/apis/upload.ts | 9 +- src/components/BatchRegister.tsx | 1 + src/components/CustomForm.css | 40 ++ src/components/CustomForm.tsx | 377 ++++++++++++ src/components/CustomList.tsx | 613 +++++++++++++++++++ src/components/CustomRegister.tsx | 42 ++ src/components/ImageCropperModal.tsx | 176 ++++++ src/components/ImageInputGroup.tsx | 329 ++++++++++ src/components/ImageModal.tsx | 149 ++++- src/components/ImagePreview.tsx | 56 ++ src/components/ImageSelectorModal.css | 90 +++ src/components/ImageSelectorModal.tsx | 115 ++++ src/components/InputDrawer.css | 8 +- src/components/InputDrawer.tsx | 5 +- src/components/InputPanel.tsx | 9 +- src/components/LayoutWrapper.tsx | 57 +- src/components/NumberRangeFilterDropdown.tsx | 94 +++ src/components/PeopleForm.tsx | 35 +- src/components/ResourceList.tsx | 43 +- src/components/SiderMenu.tsx | 4 +- src/components/TopBar.css | 4 +- src/styles/layout.css | 6 +- src/utils/shareImageGenerator.ts | 304 +++++++++ 29 files changed, 2617 insertions(+), 108 deletions(-) create mode 100644 src/apis/custom.ts create mode 100644 src/components/CustomForm.css create mode 100644 src/components/CustomForm.tsx create mode 100644 src/components/CustomList.tsx create mode 100644 src/components/CustomRegister.tsx create mode 100644 src/components/ImageCropperModal.tsx create mode 100644 src/components/ImageInputGroup.tsx create mode 100644 src/components/ImagePreview.tsx create mode 100644 src/components/ImageSelectorModal.css create mode 100644 src/components/ImageSelectorModal.tsx create mode 100644 src/components/NumberRangeFilterDropdown.tsx create mode 100644 src/utils/shareImageGenerator.ts diff --git a/src/App.tsx b/src/App.tsx index 3617b8d..a83e94e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,13 @@ import LayoutWrapper from './components/LayoutWrapper'; +import { ConfigProvider } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; function App() { - return ; + return ( + + + + ); } export default App; diff --git a/src/apis/config.ts b/src/apis/config.ts index 4e95bfc..4efaf6b 100644 --- a/src/apis/config.ts +++ b/src/apis/config.ts @@ -10,8 +10,8 @@ export const API_CONFIG = { // API 端点 export const API_ENDPOINTS = { - INPUT: '/recognition/input', - INPUT_IMAGE: '/recognition/image', + RECOGNITION_INPUT: (model: 'people' | 'custom') => `/recognition/${model}/input`, + RECOGNITION_IMAGE: (model: 'people' | 'custom') => `/recognition/${model}/image`, // 人员列表查询仍为 /peoples PEOPLES: '/peoples', // 新增单个资源路径 /people @@ -30,4 +30,8 @@ export const API_ENDPOINTS = { DELETE_USER: '/user/me', UPDATE_PHONE: '/user/me/phone', UPDATE_EMAIL: '/user/me/email', -} as const; \ No newline at end of file + // 客户相关 + CUSTOM: '/custom', // 假设的端点 + CUSTOMS: '/customs', // 假设的端点 + CUSTOM_IMAGE_BY_ID: (id: string) => `/custom/${id}/image`, +} as const; diff --git a/src/apis/custom.ts b/src/apis/custom.ts new file mode 100644 index 0000000..886536c --- /dev/null +++ b/src/apis/custom.ts @@ -0,0 +1,76 @@ +// 客户管理相关 API + +import { get, post, put, del, upload } from './request'; +import { API_ENDPOINTS } from './config'; +import type { + PostCustomRequest, + Custom, + ApiResponse, + PaginatedResponse, +} from './types'; + +/** + * 获取客户列表 + * @param params 查询参数 + * @returns Promise>> + */ +export async function getCustoms(params?: Record): Promise>> { + const response = await get>>(API_ENDPOINTS.CUSTOMS, params); + + // 兼容处理:如果后端返回的是数组,封装为分页结构 + if (Array.isArray(response.data)) { + return { + ...response, + data: { + items: response.data, + total: response.data.length, + limit: Number(params?.limit) || 1000, + offset: Number(params?.offset) || 0, + } + }; + } + + // 如果后端返回的就是分页结构,直接返回 + return response as ApiResponse>; +} + +/** + * 创建客户信息 + * @param custom 客户信息对象 + * @returns Promise + */ +export async function createCustom(custom: Custom): Promise { + const requestData: PostCustomRequest = { custom }; + console.log('创建客户请求数据:', requestData); + return post(API_ENDPOINTS.CUSTOM, requestData); +} + +/** + * 更新客户信息 + * @param id 客户ID + * @param custom 客户信息对象 + * @returns Promise + */ +export async function updateCustom(id: string, custom: Custom): Promise { + const requestData: PostCustomRequest = { custom }; + return put(`${API_ENDPOINTS.CUSTOM}/${id}`, requestData); +} + +/** + * 删除客户 + * @param id 客户ID + * @returns Promise + */ +export async function deleteCustom(id: string): Promise { + return del(`${API_ENDPOINTS.CUSTOM}/${id}`); +} + +/** + * 上传客户图片 + * @param id 客户ID + * @param file 图片文件 + * @returns Promise> + */ +export async function uploadCustomImage(id: string, file: File): Promise> { + return upload>(API_ENDPOINTS.CUSTOM_IMAGE_BY_ID(id), file, 'image'); +} diff --git a/src/apis/index.ts b/src/apis/index.ts index 2ec4549..047ff06 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -10,12 +10,14 @@ export * from './input'; export * from './upload'; export * from './people'; export * from './user'; +export * from './custom'; // 默认导出所有API函数 import * as inputApi from './input'; import * as uploadApi from './upload'; import * as peopleApi from './people'; import * as userApi from './user'; +import * as customApi from './custom'; export const api = { // 文本输入相关 @@ -29,6 +31,9 @@ export const api = { // 用户管理相关 user: userApi, + + // 客户管理相关 + custom: customApi, }; -export default api; \ No newline at end of file +export default api; diff --git a/src/apis/input.ts b/src/apis/input.ts index fd433a0..703023f 100644 --- a/src/apis/input.ts +++ b/src/apis/input.ts @@ -9,10 +9,10 @@ import type { PostInputRequest, ApiResponse } from './types'; * @param text 输入的文本内容 * @returns Promise */ -export async function postInput(text: string): Promise { +export async function postInput(text: string, model: 'people' | 'custom' = 'people'): Promise { const requestData: PostInputRequest = { text }; // 为 postInput 设置 30 秒超时时间 - return post(API_ENDPOINTS.INPUT, requestData, { timeout: 120000 }); + return post(API_ENDPOINTS.RECOGNITION_INPUT(model), requestData, { timeout: 120000 }); } /** @@ -20,7 +20,7 @@ export async function postInput(text: string): Promise { * @param data 包含文本的请求对象 * @returns Promise */ -export async function postInputData(data: PostInputRequest): Promise { +export async function postInputData(data: PostInputRequest, model: 'people' | 'custom' = 'people'): Promise { // 为 postInputData 设置 30 秒超时时间 - return post(API_ENDPOINTS.INPUT, data, { timeout: 120000 }); + return post(API_ENDPOINTS.RECOGNITION_INPUT(model), data, { timeout: 120000 }); } \ No newline at end of file diff --git a/src/apis/types.ts b/src/apis/types.ts index eed361d..b656662 100644 --- a/src/apis/types.ts +++ b/src/apis/types.ts @@ -28,6 +28,11 @@ export interface PostPeopleRequest { people: People; } +// 客户信息请求类型 +export interface PostCustomRequest { + custom: Custom; +} + // 人员查询参数类型 export interface GetPeoplesParams { name?: string; @@ -58,6 +63,49 @@ export interface People { comments?: { remark?: { content: string; updated_at: number } }; } +// 客户信息类型 +export interface Custom { + id?: string; + // 基本信息 + name: string; + gender: string; + birth: number; // int, 对应年龄转换 + phone?: string; + email?: string; + + // 外貌信息 + height?: number; + weight?: number; + images?: string[]; // List[str] + scores?: number; + + // 学历职业 + degree?: string; + academy?: string; + occupation?: string; + income?: number; + assets?: number; + current_assets?: number; // 流动资产 + house?: string; // 房产情况 + car?: string; // 汽车情况 + is_public?: boolean; // 是否公开 + + // 户口家庭 + registered_city?: string; // 户籍城市 + live_city?: string; // 常住城市 + native_place?: string; // 籍贯 + original_family?: string; + is_single_child?: boolean; + + match_requirement?: string; + + introductions?: Record; // Dict[str, str] + + // 客户信息 + custom_level?: string; // '普通','VIP', '高级VIP' + comments?: Record; // Dict[str, str] +} + // 分页响应类型 export interface PaginatedResponse { items: T[]; @@ -113,4 +161,4 @@ export interface UpdatePhoneRequest { export interface UpdateEmailRequest { email: string; code: string; -} \ No newline at end of file +} diff --git a/src/apis/upload.ts b/src/apis/upload.ts index b4de9a6..b9d3ff6 100644 --- a/src/apis/upload.ts +++ b/src/apis/upload.ts @@ -9,13 +9,13 @@ import type { ApiResponse } from './types'; * @param file 要上传的图片文件 * @returns Promise */ -export async function postInputImage(file: File): Promise { +export async function postInputImage(file: File, model: 'people' | 'custom' = 'people'): Promise { // 验证文件类型 if (!file.type.startsWith('image/')) { throw new Error('只能上传图片文件'); } - return upload(API_ENDPOINTS.INPUT_IMAGE, file, 'image', { timeout: 120000 }); + return upload(API_ENDPOINTS.RECOGNITION_IMAGE(model), file, 'image', { timeout: 120000 }); } /** @@ -26,7 +26,8 @@ export async function postInputImage(file: File): Promise { */ export async function postInputImageWithProgress( file: File, - onProgress?: (progress: number) => void + onProgress?: (progress: number) => void, + model: 'people' | 'custom' = 'people' ): Promise { // 验证文件类型 if (!file.type.startsWith('image/')) { @@ -75,7 +76,7 @@ export async function postInputImageWithProgress( }); // 发送请求 - xhr.open('POST', `http://127.0.0.1:8099${API_ENDPOINTS.INPUT_IMAGE}`); + xhr.open('POST', `http://127.0.0.1:8099${API_ENDPOINTS.RECOGNITION_IMAGE(model)}`); xhr.timeout = 120000; // 30秒超时 xhr.send(formData); }); diff --git a/src/components/BatchRegister.tsx b/src/components/BatchRegister.tsx index ae889f4..0e57313 100644 --- a/src/components/BatchRegister.tsx +++ b/src/components/BatchRegister.tsx @@ -165,6 +165,7 @@ const BatchRegister: React.FC = ({ inputOpen = false, onCloseInput, conta containerEl={containerEl} showUpload mode={'batch-image'} + targetModel="people" /> ) diff --git a/src/components/CustomForm.css b/src/components/CustomForm.css new file mode 100644 index 0000000..c31526b --- /dev/null +++ b/src/components/CustomForm.css @@ -0,0 +1,40 @@ +/* 客户信息录入表单样式 */ +.custom-form { + margin-top: 16px; + padding: 20px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-card); + color: var(--text-primary); +} + +.custom-form .ant-form-item-label > label { + color: var(--text-secondary); +} + +.custom-form .ant-input, +.custom-form .ant-input-affix-wrapper, +.custom-form .ant-select-selector, +.custom-form .ant-input-number, +.custom-form .ant-input-number-input { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.custom-form .ant-select-selection-item, +.custom-form .ant-select-selection-placeholder { + color: var(--placeholder); +} +.custom-form .ant-select-selection-item { color: var(--text-primary); } + +/* 输入占位与聚焦态 */ +.custom-form .ant-input::placeholder { color: var(--placeholder); } +.custom-form .ant-input-number-input::placeholder { color: var(--placeholder); } +.custom-form .ant-input:focus, +.custom-form .ant-input-affix-wrapper-focused, +.custom-form .ant-select-focused .ant-select-selector, +.custom-form .ant-input-number-focused { + border-color: var(--color-primary-600) !important; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); +} diff --git a/src/components/CustomForm.tsx b/src/components/CustomForm.tsx new file mode 100644 index 0000000..906e2d3 --- /dev/null +++ b/src/components/CustomForm.tsx @@ -0,0 +1,377 @@ +import React, { useState, useEffect } from 'react'; +import { Form, Input, Select, InputNumber, Button, message, Row, Col, Radio, Typography, Grid } from 'antd'; +import 'react-image-crop/dist/ReactCrop.css'; +import type { FormInstance } from 'antd'; + +import './CustomForm.css'; +import KeyValueList from './KeyValueList.tsx'; +import ImageInputGroup from './ImageInputGroup.tsx'; +import { createCustom, updateCustom, type Custom } from '../apis'; + +const { TextArea } = Input; +const { useBreakpoint } = Grid; + +interface CustomFormProps { + initialData?: Partial; + hideSubmitButton?: boolean; + onFormReady?: (form: FormInstance) => void; + onSuccess?: () => void; +} + +const CustomForm: React.FC = ({ initialData, hideSubmitButton = false, onFormReady, onSuccess }) => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (initialData) { + // 需要处理一些数据转换,例如 birth -> age (如果后端给的是 birth year) + // 这里假设 initialData 里已经处理好了,或者先直接透传 + const formData = { ...initialData }; + // 如果有 birth,转换为 age 显示 + if (formData.birth) { + // 如果 birth 小于 100,假设直接存的年龄,直接显示 + if (formData.birth < 100) { + // @ts-expect-error: 临时给 form 设置 age 字段 + formData.age = formData.birth; + } else { + // 否则假设是年份,计算年龄 + const currentYear = new Date().getFullYear(); + // @ts-expect-error: 临时给 form 设置 age 字段 + formData.age = currentYear - formData.birth; + } + } + // images 数组处理,确保是字符串数组 + if (formData.images && Array.isArray(formData.images)) { + if (formData.images.length === 0) { + formData.images = ['']; + } + } else { + formData.images = ['']; + } + form.setFieldsValue(formData); + } else { + // 初始化空状态 + form.setFieldsValue({ + images: [''] + }); + } + }, [initialData, form]); + + useEffect(() => { + onFormReady?.(form); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onFinish = async (values: Custom & { age?: number }) => { + setLoading(true); + + try { + const currentYear = new Date().getFullYear(); + const birth = currentYear - (values.age || 0); + + const customData: Custom = { + name: values.name, + gender: values.gender, + birth: birth, + phone: values.phone || undefined, + email: values.email || undefined, + + height: values.height || undefined, + weight: values.weight || undefined, + images: values.images?.filter((url: string) => !!url) || [], + scores: values.scores || undefined, + + degree: values.degree || undefined, + academy: values.academy || undefined, + occupation: values.occupation || undefined, + income: values.income || undefined, + assets: values.assets || undefined, + current_assets: values.current_assets || undefined, + house: values.house || undefined, + car: values.car || undefined, + + registered_city: values.registered_city || undefined, + live_city: values.live_city || undefined, + native_place: values.native_place || undefined, + original_family: values.original_family || undefined, + is_single_child: values.is_single_child, + + match_requirement: values.match_requirement || undefined, + introductions: values.introductions || {}, + + custom_level: values.custom_level || '普通', + comments: values.comments || {}, + }; + + console.log('提交客户数据:', customData); + + let response; + if (initialData?.id) { + response = await updateCustom(initialData.id, customData); + } else { + response = await createCustom(customData); + } + + if (response.error_code === 0) { + message.success(initialData?.id ? '客户信息已更新!' : '客户信息已成功提交到后端!'); + if (!initialData?.id) { + form.resetFields(); + } + onSuccess?.(); + } else { + message.error(response.error_info || '提交失败,请重试'); + } + + } catch (e) { + const err = e as { status?: number; message?: string }; + if (err.status === 422) { + message.error('表单数据格式有误,请检查输入内容'); + } else if ((err.status ?? 0) >= 500) { + message.error('服务器错误,请稍后重试'); + } else { + message.error(err.message || '提交失败,请重试'); + } + } finally { + setLoading(false); + } + }; + + const screens = useBreakpoint(); + const isMobile = !screens.md; + + const scoresNode = ( + + + + ); + + const heightNode = ( + + + + ); + + const weightNode = ( + + + + ); + + const degreeNode = ( + + + + ); + + const academyNode = ( + + + + ); + + return ( +
+ {!hideSubmitButton && ( + <> + 客户录入 + 录入客户基本信息 + + )} + +
+ {/* Row 1: 姓名、性别、年龄 */} + + + + + + + + + + + + + + + + + + + {/* Row 3: Image Group */} + + + + + {/* Row 4: Physical Info */} + {isMobile ? ( + // Mobile Layout +
+ {/* 1. Scores (Full width) */} + {scoresNode} + + {/* 2. Height & Weight */} + + {heightNode} + {weightNode} + + + {/* 3. Degree & Academy */} + + {degreeNode} + {academyNode} + +
+ ) : ( + // PC Layout + + {scoresNode} + {heightNode} + {weightNode} + + )} + + {/* Row 5a: 学历、院校 */} + + {degreeNode} + {academyNode} + + + {/* Row 5b: 职业、收入 */} + + + + + + + + + + + + + + {/* Row 5c: 资产、流动资产 */} + + + + + + + + + + + + + + {/* Row 5d: 房产情况、汽车情况 */} + + + + ({ label: v, value: v }))} /> + + + + + {/* Row 6: 户口城市、常住城市、籍贯 */} + + + + + + + + + + + + + + + + + + + {/* Row 7: 是否独生子 */} + + + + + + + + {/* Row 8: 原生家庭 */} + +