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
This commit is contained in:
2025-12-03 09:03:52 +08:00
parent cb59b3693d
commit fee01abb60
29 changed files with 2617 additions and 108 deletions

View File

@@ -1,7 +1,13 @@
import LayoutWrapper from './components/LayoutWrapper';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
function App() {
return <LayoutWrapper />;
return (
<ConfigProvider locale={zhCN}>
<LayoutWrapper />
</ConfigProvider>
);
}
export default App;

View File

@@ -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;
// 客户相关
CUSTOM: '/custom', // 假设的端点
CUSTOMS: '/customs', // 假设的端点
CUSTOM_IMAGE_BY_ID: (id: string) => `/custom/${id}/image`,
} as const;

76
src/apis/custom.ts Normal file
View File

@@ -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<ApiResponse<PaginatedResponse<Custom>>>
*/
export async function getCustoms(params?: Record<string, string | number>): Promise<ApiResponse<PaginatedResponse<Custom>>> {
const response = await get<ApiResponse<Custom[] | PaginatedResponse<Custom>>>(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<PaginatedResponse<Custom>>;
}
/**
* 创建客户信息
* @param custom 客户信息对象
* @returns Promise<ApiResponse>
*/
export async function createCustom(custom: Custom): Promise<ApiResponse> {
const requestData: PostCustomRequest = { custom };
console.log('创建客户请求数据:', requestData);
return post<ApiResponse>(API_ENDPOINTS.CUSTOM, requestData);
}
/**
* 更新客户信息
* @param id 客户ID
* @param custom 客户信息对象
* @returns Promise<ApiResponse>
*/
export async function updateCustom(id: string, custom: Custom): Promise<ApiResponse> {
const requestData: PostCustomRequest = { custom };
return put<ApiResponse>(`${API_ENDPOINTS.CUSTOM}/${id}`, requestData);
}
/**
* 删除客户
* @param id 客户ID
* @returns Promise<ApiResponse>
*/
export async function deleteCustom(id: string): Promise<ApiResponse> {
return del<ApiResponse>(`${API_ENDPOINTS.CUSTOM}/${id}`);
}
/**
* 上传客户图片
* @param id 客户ID
* @param file 图片文件
* @returns Promise<ApiResponse<string>>
*/
export async function uploadCustomImage(id: string, file: File): Promise<ApiResponse<string>> {
return upload<ApiResponse<string>>(API_ENDPOINTS.CUSTOM_IMAGE_BY_ID(id), file, 'image');
}

View File

@@ -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;
export default api;

View File

@@ -9,10 +9,10 @@ import type { PostInputRequest, ApiResponse } from './types';
* @param text 输入的文本内容
* @returns Promise<ApiResponse>
*/
export async function postInput(text: string): Promise<ApiResponse> {
export async function postInput(text: string, model: 'people' | 'custom' = 'people'): Promise<ApiResponse> {
const requestData: PostInputRequest = { text };
// 为 postInput 设置 30 秒超时时间
return post<ApiResponse>(API_ENDPOINTS.INPUT, requestData, { timeout: 120000 });
return post<ApiResponse>(API_ENDPOINTS.RECOGNITION_INPUT(model), requestData, { timeout: 120000 });
}
/**
@@ -20,7 +20,7 @@ export async function postInput(text: string): Promise<ApiResponse> {
* @param data 包含文本的请求对象
* @returns Promise<ApiResponse>
*/
export async function postInputData(data: PostInputRequest): Promise<ApiResponse> {
export async function postInputData(data: PostInputRequest, model: 'people' | 'custom' = 'people'): Promise<ApiResponse> {
// 为 postInputData 设置 30 秒超时时间
return post<ApiResponse>(API_ENDPOINTS.INPUT, data, { timeout: 120000 });
return post<ApiResponse>(API_ENDPOINTS.RECOGNITION_INPUT(model), data, { timeout: 120000 });
}

View File

@@ -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<string, string>; // Dict[str, str]
// 客户信息
custom_level?: string; // '普通''VIP', '高级VIP'
comments?: Record<string, string>; // Dict[str, str]
}
// 分页响应类型
export interface PaginatedResponse<T> {
items: T[];
@@ -113,4 +161,4 @@ export interface UpdatePhoneRequest {
export interface UpdateEmailRequest {
email: string;
code: string;
}
}

View File

@@ -9,13 +9,13 @@ import type { ApiResponse } from './types';
* @param file 要上传的图片文件
* @returns Promise<ApiResponse>
*/
export async function postInputImage(file: File): Promise<ApiResponse> {
export async function postInputImage(file: File, model: 'people' | 'custom' = 'people'): Promise<ApiResponse> {
// 验证文件类型
if (!file.type.startsWith('image/')) {
throw new Error('只能上传图片文件');
}
return upload<ApiResponse>(API_ENDPOINTS.INPUT_IMAGE, file, 'image', { timeout: 120000 });
return upload<ApiResponse>(API_ENDPOINTS.RECOGNITION_IMAGE(model), file, 'image', { timeout: 120000 });
}
/**
@@ -26,7 +26,8 @@ export async function postInputImage(file: File): Promise<ApiResponse> {
*/
export async function postInputImageWithProgress(
file: File,
onProgress?: (progress: number) => void
onProgress?: (progress: number) => void,
model: 'people' | 'custom' = 'people'
): Promise<ApiResponse> {
// 验证文件类型
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);
});

View File

@@ -165,6 +165,7 @@ const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, conta
containerEl={containerEl}
showUpload
mode={'batch-image'}
targetModel="people"
/>
</Content>
)

View File

@@ -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);
}

View File

@@ -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<Custom>;
hideSubmitButton?: boolean;
onFormReady?: (form: FormInstance) => void;
onSuccess?: () => void;
}
const CustomForm: React.FC<CustomFormProps> = ({ 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 = (
<Form.Item name="scores" label="外貌评分">
<InputNumber min={0} max={100} style={{ width: '100%' }} placeholder="分" />
</Form.Item>
);
const heightNode = (
<Form.Item name="height" label="身高(cm)">
<InputNumber min={0} max={250} style={{ width: '100%' }} placeholder="cm" />
</Form.Item>
);
const weightNode = (
<Form.Item name="weight" label="体重(kg)">
<InputNumber min={0} max={200} style={{ width: '100%' }} placeholder="kg" />
</Form.Item>
);
const degreeNode = (
<Form.Item name="degree" label="学历">
<Input placeholder="如:本科" />
</Form.Item>
);
const academyNode = (
<Form.Item name="academy" label="院校">
<Input placeholder="如:清华大学" />
</Form.Item>
);
return (
<div className="custom-form">
{!hideSubmitButton && (
<>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}></Typography.Text>
</>
)}
<Form
form={form}
layout="vertical"
size="large"
onFinish={onFinish}
>
{/* Row 1: 姓名、性别、年龄 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={8}>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
<Select placeholder="请选择性别" options={[{ label: '男', value: '男' }, { label: '女', value: '女' }]} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="age" label="年龄" rules={[{ required: true, message: '请输入年龄' }]}>
<InputNumber min={0} max={120} style={{ width: '100%' }} placeholder="请输入年龄" />
</Form.Item>
</Col>
</Row>
{/* Row 2: 电话、邮箱 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="phone" label="电话">
<Input placeholder="请输入电话" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="email" label="邮箱">
<Input placeholder="请输入邮箱" />
</Form.Item>
</Col>
</Row>
{/* Row 3: Image Group */}
<Form.Item name="images" label="照片图片">
<ImageInputGroup customId={initialData?.id} />
</Form.Item>
{/* Row 4: Physical Info */}
{isMobile ? (
// Mobile Layout
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* 1. Scores (Full width) */}
{scoresNode}
{/* 2. Height & Weight */}
<Row gutter={[12, 12]}>
<Col xs={12}>{heightNode}</Col>
<Col xs={12}>{weightNode}</Col>
</Row>
{/* 3. Degree & Academy */}
<Row gutter={[12, 12]}>
<Col xs={12}>{degreeNode}</Col>
<Col xs={12}>{academyNode}</Col>
</Row>
</div>
) : (
// PC Layout
<Row gutter={[24, 24]}>
<Col span={8}>{scoresNode}</Col>
<Col span={8}>{heightNode}</Col>
<Col span={8}>{weightNode}</Col>
</Row>
)}
{/* Row 5a: 学历、院校 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>{degreeNode}</Col>
<Col xs={24} md={12}>{academyNode}</Col>
</Row>
{/* Row 5b: 职业、收入 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="occupation" label="职业">
<Input placeholder="如:工程师" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="income" label="收入(万/年)">
<InputNumber style={{ width: '100%' }} placeholder="万" />
</Form.Item>
</Col>
</Row>
{/* Row 5c: 资产、流动资产 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="assets" label="资产(万)">
<InputNumber style={{ width: '100%' }} placeholder="万" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="current_assets" label="流动资产(万)">
<InputNumber style={{ width: '100%' }} placeholder="万" />
</Form.Item>
</Col>
</Row>
{/* Row 5d: 房产情况、汽车情况 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="house" label="房产情况">
<Select placeholder="请选择房产情况" options={['无房', '有房有贷', '有房无贷'].map(v => ({ label: v, value: v }))} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="car" label="汽车情况">
<Select placeholder="请选择汽车情况" options={['无车', '有车有贷', '有车无贷'].map(v => ({ label: v, value: v }))} />
</Form.Item>
</Col>
</Row>
{/* Row 6: 户口城市、常住城市、籍贯 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={8}>
<Form.Item name="registered_city" label="户口城市">
<Input placeholder="如:北京" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="live_city" label="常住城市">
<Input placeholder="如:上海" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="native_place" label="籍贯">
<Input placeholder="如:江苏" />
</Form.Item>
</Col>
</Row>
{/* Row 7: 是否独生子 */}
<Form.Item name="is_single_child" label="是否独生子">
<Radio.Group>
<Radio value={true}></Radio>
<Radio value={false}></Radio>
</Radio.Group>
</Form.Item>
{/* Row 8: 原生家庭 */}
<Form.Item name="original_family" label="原生家庭">
<TextArea autoSize={{ minRows: 3, maxRows: 5 }} placeholder="请输入原生家庭情况" />
</Form.Item>
{/* Row 9: 择偶要求 */}
<Form.Item name="match_requirement" label="择偶要求">
<TextArea autoSize={{ minRows: 3, maxRows: 5 }} placeholder="请输入择偶要求" />
</Form.Item>
{/* Row 10: 其他信息 (KeyValueList) */}
<Form.Item name="introductions" label="其他信息">
<KeyValueList />
</Form.Item>
{/* Row 11: 客户等级、是否公开 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="custom_level" label="客户等级">
<Select options={['普通', 'VIP', '高级VIP'].map(v => ({ label: v, value: v }))} placeholder="请选择" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="is_public" label="是否公开">
<Radio.Group>
<Radio value={true}></Radio>
<Radio value={false}></Radio>
</Radio.Group>
</Form.Item>
</Col>
</Row>
{/* Row 13: 备注评论 (KeyValueList) */}
<Form.Item name="comments" label="备注评论">
<KeyValueList />
</Form.Item>
{!hideSubmitButton && (
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large">
{initialData?.id ? '保存修改' : '提交客户信息'}
</Button>
</Form.Item>
)}
</Form>
</div>
);
};
export default CustomForm;

View File

@@ -0,0 +1,613 @@
import React, { useEffect, useState, useRef } from 'react';
import { Layout, Typography, Table, Grid, Button, Space, message, Descriptions, Tag, Modal, Popconfirm, Dropdown } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd';
import {
ManOutlined,
WomanOutlined,
PictureOutlined,
DeleteOutlined,
EditOutlined,
UserOutlined,
EllipsisOutlined,
ShareAltOutlined,
DownloadOutlined,
CopyOutlined
} from '@ant-design/icons';
import { getCustoms, deleteCustom } from '../apis/custom';
import type { Custom } from '../apis/types';
import './MainContent.css';
import ImageModal from './ImageModal.tsx';
import NumberRangeFilterDropdown from './NumberRangeFilterDropdown';
import CustomForm from './CustomForm.tsx';
import ImageSelectorModal from './ImageSelectorModal';
import ImageCropperModal from './ImageCropperModal';
import { generateShareImage, type ShareData } from '../utils/shareImageGenerator';
import { useAuth } from '../contexts/useAuth';
const { Content } = Layout;
const { useBreakpoint } = Grid;
// 扩展 Custom 类型以确保 id 存在
type CustomResource = Custom & { id: string };
const CustomList: React.FC = () => {
const { user } = useAuth();
const screens = useBreakpoint();
const [data, setData] = useState<CustomResource[]>([]);
const [loading, setLoading] = useState(false);
// 图片弹窗状态
const [imageModalVisible, setImageModalVisible] = useState(false);
const [currentImages, setCurrentImages] = useState<string[]>([]);
const [initialImageIndex, setInitialImageIndex] = useState(0);
// 编辑弹窗状态
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingRecord, setEditingRecord] = useState<CustomResource | null>(null);
const editFormRef = useRef<FormInstance | null>(null);
// 分享相关状态
const [shareSelectorVisible, setShareSelectorVisible] = useState(false);
const [shareCropperVisible, setShareCropperVisible] = useState(false);
const [shareResultVisible, setShareResultVisible] = useState(false);
const [selectedShareImage, setSelectedShareImage] = useState<string>('');
const [generatedShareImage, setGeneratedShareImage] = useState<string>('');
const [sharingCustom, setSharingCustom] = useState<CustomResource | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const response = await getCustoms({ limit: 1000, offset: 0 });
if (response.error_code === 0 && response.data) {
// 确保 items 存在且 id 存在
const list = (response.data.items || []).map(item => ({
...item,
id: item.id || `custom-${Date.now()}-${Math.random()}`
}));
setData(list);
} else {
message.error(response.error_info || '获取客户列表失败');
}
} catch (error) {
console.error('获取客户列表失败:', error);
message.error('获取客户列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
// 删除客户
const handleDelete = async (id: string) => {
try {
const response = await deleteCustom(id);
if (response.error_code === 0) {
message.success('删除成功');
fetchData(); // 重新加载数据
} else {
message.error(response.error_info || '删除失败');
}
} catch (error) {
console.error('删除失败:', error);
message.error('删除失败');
}
};
// 计算年龄
const calculateAge = (birth?: number) => {
if (!birth) return '未知';
// 如果 birth 小于 100假设直接存的年龄
if (birth < 100) return birth;
// 否则假设是年份
const currentYear = new Date().getFullYear();
return currentYear - birth;
};
// 通用数字范围筛选逻辑生成器
const createNumberRangeOnFilter = (getValue: (record: CustomResource) => number) => {
return (value: React.Key | boolean, record: CustomResource) => {
const [min, max] = (value as string).split(',').map(Number);
const val = getValue(record);
if (typeof val !== 'number') return false;
if (!isNaN(min) && !isNaN(max)) {
return val >= min && val <= max;
}
if (!isNaN(min)) {
return val >= min;
}
if (!isNaN(max)) {
return val <= max;
}
return true;
};
};
const ageOnFilter = createNumberRangeOnFilter((record) => {
const age = calculateAge(record.birth);
return typeof age === 'number' ? age : -1;
});
const heightOnFilter = createNumberRangeOnFilter((record) => record.height || 0);
// 修改 NumberRangeFilterDropdown 以适配 "min,max" 格式
// 等等,修改组件太麻烦,我们直接在 CustomList 里处理 props
// 实际上Antd 的 filterDropdown 接收的 selectedKeys 是一个数组。
// 我们可以只用 selectedKeys[0] 来存储 "min,max" 字符串。
// 处理分享点击
const handleShare = (record: CustomResource) => {
setSharingCustom(record);
setShareSelectorVisible(true);
};
// 处理图片选择完成
const handleShareImageSelect = (imageUrl: string) => {
setSelectedShareImage(imageUrl);
setShareSelectorVisible(false);
setShareCropperVisible(true);
};
// 处理裁切完成
const handleShareImageCrop = async (blob: Blob) => {
setShareCropperVisible(false);
if (sharingCustom) {
// 辅助函数:计算资产等级
const getAssetLevel = (val: number | undefined, prefix: string) => {
if (val === undefined || val === null) return '-';
// 假设单位为万
if (val < 100) return `${prefix}6`;
if (val < 1000) return `${prefix}7`;
if (val < 10000) return `${prefix}8`;
return `${prefix}9`;
};
// 准备分享数据
const shareData: ShareData = {
imageBlob: blob,
tags: {
assets: getAssetLevel(sharingCustom.assets, 'A'),
liquidAssets: getAssetLevel(sharingCustom.current_assets, 'C'),
income: sharingCustom.income ? `${sharingCustom.income}` : '-'
},
basicInfo: {
age: calculateAge(sharingCustom.birth) === '未知' ? '-' : `${calculateAge(sharingCustom.birth)}`,
height: sharingCustom.height ? `${sharingCustom.height}cm` : '-',
degree: sharingCustom.degree || '-'
},
details: {
city: sharingCustom.live_city || '-',
isSingleChild: sharingCustom.is_single_child === undefined ? '-' : (sharingCustom.is_single_child ? '是' : '否'),
houseCar: [sharingCustom.house, sharingCustom.car].filter(Boolean).join('; ') || '-'
},
introduction: sharingCustom.introductions
? Object.entries(sharingCustom.introductions).map(([k, v]) => `${k}: ${v}`).join('; ')
: '-',
matchmaker: {
orgName: 'IF.U',
name: user?.nickname || '红娘'
}
};
try {
const resultUrl = await generateShareImage(shareData);
setGeneratedShareImage(resultUrl);
setShareResultVisible(true);
} catch (error) {
message.error('生成图片失败');
console.error(error);
}
}
};
// 编辑客户
const handleEdit = (record: CustomResource) => {
setEditingRecord(record);
setEditModalVisible(true);
};
// 渲染图片图标(仿 ResourceList 逻辑)
const renderPictureIcon = (images?: string[]) => {
const hasCover = images && images.length > 0 && images[0];
return (
<PictureOutlined
style={{
color: hasCover ? '#1677ff' : '#9ca3af',
cursor: hasCover ? 'pointer' : 'default',
fontSize: 16,
}}
onClick={hasCover ? (e) => {
e.stopPropagation();
setCurrentImages(images || []);
setInitialImageIndex(0);
setImageModalVisible(true);
} : undefined}
/>
);
};
// 渲染性别图标
const renderGender = (gender: string) => {
if (gender === '男') return <ManOutlined style={{ color: '#1890ff' }} />;
if (gender === '女') return <WomanOutlined style={{ color: '#eb2f96' }} />;
return <UserOutlined />;
};
// 移动端姓名列渲染
const renderMobileName = (text: string, record: CustomResource) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'row', gap: 4 }}>
<span style={{ fontWeight: 600 }}>{text}</span>
<span style={{ color: '#888' }}>
{renderGender(record.gender)}
</span>
</div>
{renderPictureIcon(record.images)}
</div>
);
// PC端姓名列渲染
const renderPCName = (text: string, record: CustomResource) => (
<Space>
<span style={{ fontWeight: 600 }}>{text}</span>
{renderPictureIcon(record.images)}
</Space>
);
// 表格列定义
const columns: ColumnsType<CustomResource> = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 120, // 固定宽度
ellipsis: true,
render: screens.xs ? renderMobileName : renderPCName,
// 移动端在姓名列增加性别筛选
...(screens.xs ? {
filters: [
{ text: '男', value: '男' },
{ text: '女', value: '女' },
],
onFilter: (value: React.Key | boolean, record: CustomResource) => record.gender === value,
} : {}),
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
width: 100,
hidden: !screens.md,
render: (text: string) => {
const color = text === '男' ? 'blue' : text === '女' ? 'magenta' : 'default';
return <Tag color={color}>{text}</Tag>;
},
// PC端在性别列增加筛选
filters: [
{ text: '男', value: '男' },
{ text: '女', value: '女' },
],
onFilter: (value: React.Key | boolean, record: CustomResource) => record.gender === value,
},
{
title: '年龄',
dataIndex: 'birth',
key: 'age',
width: 100,
render: (birth: number) => calculateAge(birth),
filterDropdown: (props) => (
<NumberRangeFilterDropdown
{...props}
clearFilters={() => props.clearFilters?.()}
/>
),
onFilter: ageOnFilter,
sorter: (a, b) => {
const ageA = typeof calculateAge(a.birth) === 'number' ? calculateAge(a.birth) as number : -1;
const ageB = typeof calculateAge(b.birth) === 'number' ? calculateAge(b.birth) as number : -1;
return ageA - ageB;
},
},
{
title: '身高',
dataIndex: 'height',
key: 'height',
width: 80,
hidden: !screens.xl,
render: (val: number) => val ? `${val}cm` : '-',
filterDropdown: (props) => (
<NumberRangeFilterDropdown
{...props}
clearFilters={() => props.clearFilters?.()}
/>
),
onFilter: heightOnFilter, // 复用数字范围筛选逻辑
sorter: (a: CustomResource, b: CustomResource) => (a.height || 0) - (b.height || 0),
},
{
title: '常住城市',
dataIndex: 'live_city',
key: 'live_city',
width: 120,
ellipsis: true,
hidden: !screens.lg,
render: (val: string) => val || '-',
},
{
title: '操作',
key: 'action',
width: screens.xs ? 60 : 140, // 增加宽度以容纳分享按钮
render: (_, record) => {
// PC 端直接显示按钮
if (!screens.xs) {
return (
<Space size="small">
<Button
type="text"
icon={<ShareAltOutlined />}
onClick={() => handleShare(record)}
title="分享"
/>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
<Popconfirm
title="确定要删除吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
);
}
// 移动端显示更多菜单
return (
<Dropdown
menu={{
items: [
{
key: 'share',
label: '分享',
icon: <ShareAltOutlined />,
onClick: () => handleShare(record),
},
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => handleEdit(record),
},
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确定要删除吗?',
content: '删除后无法恢复',
okText: '确定',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => handleDelete(record.id),
});
},
},
],
}}
trigger={['click']}
>
<Button type="text" icon={<EllipsisOutlined />} />
</Dropdown>
);
},
},
];
// 展开行渲染
const expandedRowRender = (record: CustomResource) => {
return (
<div style={{ padding: '0 24px', backgroundColor: '#fafafa' }}>
<Descriptions title="基础信息" bordered size="small" column={{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}>
<Descriptions.Item label="身高">{record.height ? `${record.height}cm` : '-'}</Descriptions.Item>
<Descriptions.Item label="体重">{record.weight ? `${record.weight}kg` : '-'}</Descriptions.Item>
<Descriptions.Item label="电话">{record.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{record.email || '-'}</Descriptions.Item>
</Descriptions>
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
<Descriptions title="学历工作" bordered size="small" column={{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}>
<Descriptions.Item label="学位">{record.degree || '-'}</Descriptions.Item>
<Descriptions.Item label="学校">{record.academy || '-'}</Descriptions.Item>
<Descriptions.Item label="职业">{record.occupation || '-'}</Descriptions.Item>
<Descriptions.Item label="收入">{record.income ? `${record.income}` : '-'}</Descriptions.Item>
<Descriptions.Item label="资产">{record.assets ? `${record.assets}` : '-'}</Descriptions.Item>
<Descriptions.Item label="流动资产">{record.current_assets ? `${record.current_assets}` : '-'}</Descriptions.Item>
<Descriptions.Item label="房产情况">{record.house || '-'}</Descriptions.Item>
<Descriptions.Item label="汽车情况">{record.car || '-'}</Descriptions.Item>
</Descriptions>
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
<Descriptions title="所在城市" bordered size="small" column={{ xs: 1, sm: 2, md: 3 }}>
<Descriptions.Item label="户籍城市">{record.registered_city || '-'}</Descriptions.Item>
<Descriptions.Item label="常住城市">{record.live_city || '-'}</Descriptions.Item>
<Descriptions.Item label="籍贯城市">{record.native_place || '-'}</Descriptions.Item>
</Descriptions>
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
<Descriptions title="原生家庭" bordered size="small" column={{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}>
<Descriptions.Item label="独生子女">{record.is_single_child ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="家庭情况">{record.original_family || '-'}</Descriptions.Item>
</Descriptions>
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
<Descriptions title="其他信息" bordered size="small" column={1}>
{record.introductions && Object.entries(record.introductions).map(([key, value]) => (
<Descriptions.Item label={key} key={key}>{value}</Descriptions.Item>
))}
</Descriptions>
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
<Descriptions title="择偶要求" bordered size="small" column={1}>
<Descriptions.Item label="要求内容">
<div style={{ whiteSpace: 'pre-wrap' }}>{record.match_requirement || '-'}</div>
</Descriptions.Item>
</Descriptions>
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
<Descriptions title="管理信息" bordered size="small" column={1}>
<Descriptions.Item label="客户等级">{record.custom_level || '-'}</Descriptions.Item>
<Descriptions.Item label="是否公开">{record.is_public ? '是' : '否'}</Descriptions.Item>
{record.comments && Object.entries(record.comments).map(([key, value]) => (
<Descriptions.Item label={key} key={`comment-${key}`}>{value}</Descriptions.Item>
))}
</Descriptions>
</div>
);
};
return (
<Content className="main-content">
<div className="content-body">
<Typography.Title level={4} style={{ marginBottom: 16 }}></Typography.Title>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
expandable={{ expandedRowRender }}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
scroll={{ x: 'max-content' }}
/>
</div>
{/* 分享流程 Modals */}
<ImageSelectorModal
visible={shareSelectorVisible}
images={sharingCustom?.images || []}
onCancel={() => setShareSelectorVisible(false)}
onSelect={handleShareImageSelect}
/>
<ImageCropperModal
visible={shareCropperVisible}
imageUrl={selectedShareImage}
onCancel={() => setShareCropperVisible(false)}
onConfirm={handleShareImageCrop}
/>
<Modal
title="分享图片生成结果"
open={shareResultVisible}
onCancel={() => setShareResultVisible(false)}
footer={[
<Button key="close" onClick={() => setShareResultVisible(false)}>
</Button>,
<Button
key="copy"
icon={<CopyOutlined />}
onClick={async () => {
try {
const response = await fetch(generatedShareImage);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
message.success('图片已复制到剪切板');
} catch (err) {
console.error('复制失败:', err);
message.error('复制失败,请尝试下载');
}
}}
>
</Button>,
<Button
key="download"
type="primary"
icon={<DownloadOutlined />}
onClick={() => {
const link = document.createElement('a');
link.download = `share-${sharingCustom?.name || 'custom'}.png`;
link.href = generatedShareImage;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
>
</Button>
]}
width={screens.lg ? 1000 : screens.md ? 700 : '95%'}
destroyOnClose
centered
>
<div style={{ display: 'flex', justifyContent: 'center', background: '#f0f2f5', padding: '16px' }}>
{generatedShareImage && (
<img
src={generatedShareImage}
alt="Generated Share"
style={{ maxWidth: '100%', maxHeight: '80vh', boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}
/>
)}
</div>
</Modal>
{/* 编辑模态框 */}
<Modal
title="编辑客户信息"
open={editModalVisible}
onCancel={() => setEditModalVisible(false)}
onOk={() => {
editFormRef.current?.submit();
}}
width={800}
maskClosable={false}
destroyOnClose
>
<CustomForm
initialData={editingRecord || undefined}
hideSubmitButton
onFormReady={(form) => { editFormRef.current = form; }}
onSuccess={() => {
setEditModalVisible(false);
fetchData();
}}
/>
</Modal>
<ImageModal
visible={imageModalVisible}
images={currentImages}
initialIndex={initialImageIndex}
onClose={() => setImageModalVisible(false)}
/>
</Content>
);
};
export default CustomList;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Layout } from 'antd';
import CustomForm from './CustomForm.tsx';
import InputDrawer from './InputDrawer.tsx';
import './MainContent.css'; // Reuse MainContent styles
const { Content } = Layout;
type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null };
const CustomRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
// 暂时不需要反向填充表单,或者后续如果有需求再加
const handleInputResult = (_data: unknown) => {
// setFormData(data as Partial<Custom>);
// TODO: 如果需要支持从 InputDrawer 自动填充 CustomForm可以在这里实现
console.log('InputDrawer result for custom:', _data);
};
return (
<Content className="main-content">
<div className="content-body">
{/* 标题在 CustomForm 内部已经有了,或者在这里统一控制?
CustomForm 内部写了 Title 和 Paragraph这里就不重复了。
PeopleForm 内部没有 Title是在 MainContent 里写的。
刚才我在 CustomForm 里加了 Title。
*/}
<CustomForm />
</div>
{/* 复用 InputDrawer虽然可能暂时没用 */}
<InputDrawer
open={inputOpen}
onClose={onCloseInput || (() => {})}
onResult={handleInputResult}
containerEl={containerEl}
targetModel="custom"
/>
</Content>
);
};
export default CustomRegister;

View File

@@ -0,0 +1,176 @@
import React, { useState, useRef } from 'react';
import { Modal, Button, message } from 'antd';
import ReactCrop, { centerCrop, makeAspectCrop, type Crop, type PixelCrop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
interface ImageCropperModalProps {
visible: boolean;
imageUrl: string;
onCancel: () => void;
onConfirm: (blob: Blob) => void;
}
const ImageCropperModal: React.FC<ImageCropperModalProps> = ({
visible,
imageUrl,
onCancel,
onConfirm,
}) => {
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const imgRef = useRef<HTMLImageElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// 初始化裁切区域
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const { width, height } = e.currentTarget;
// 默认生成正方形裁切
const crop = centerCrop(
makeAspectCrop(
{
unit: '%',
width: 90,
},
1, // aspect ratio 1:1
width,
height,
),
width,
height,
);
setCrop(crop);
// 手动设置初始 completedCrop防止用户直接点击确定时为空
// 将百分比转换为像素
setCompletedCrop({
unit: 'px',
x: (crop.x / 100) * width,
y: (crop.y / 100) * height,
width: (crop.width / 100) * width,
height: (crop.height / 100) * height,
});
};
const handleConfirm = async () => {
if (completedCrop && imgRef.current && canvasRef.current) {
const image = imgRef.current;
const canvas = canvasRef.current;
const crop = completedCrop;
// 这里的 scale 是指:图片显示尺寸 / 图片原始尺寸
// 注意completedCrop 是基于显示尺寸的像素值
// 我们需要将其映射回原始图片的像素值
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const pixelRatio = window.devicePixelRatio || 1;
// 设置 canvas 的绘制尺寸(基于原始图片分辨率)
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
const ctx = canvas.getContext('2d');
if (!ctx) {
message.error('无法获取 Canvas 上下文');
return;
}
// 处理高 DPI 屏幕
ctx.scale(pixelRatio, pixelRatio);
ctx.imageSmoothingQuality = 'high';
// 计算源图像上的裁切区域
const sourceX = crop.x * scaleX;
const sourceY = crop.y * scaleY;
const sourceWidth = crop.width * scaleX;
const sourceHeight = crop.height * scaleY;
// 计算目标绘制区域(即整个 Canvas
const destX = 0;
const destY = 0;
const destWidth = crop.width * scaleX;
const destHeight = crop.height * scaleY;
ctx.save();
try {
ctx.drawImage(
image,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
destX,
destY,
destWidth,
destHeight
);
} catch (e) {
console.error('Canvas drawImage failed:', e);
message.error('图片裁切失败,请重试');
ctx.restore();
return;
}
ctx.restore();
try {
canvas.toBlob((blob) => {
if (!blob) {
console.error('Canvas toBlob returned null');
message.error('图片生成失败');
return;
}
onConfirm(blob);
}, 'image/png');
} catch (e) {
console.error('Canvas toBlob failed:', e);
message.error('无法导出图片,可能存在跨域问题');
}
} else {
// 如果没有进行任何裁切操作(比如直接点确定),尝试使用默认全图或当前状态
message.warning('请调整裁切区域');
}
};
return (
<Modal
open={visible}
onCancel={onCancel}
title="图片裁切"
width={600}
footer={[
<Button key="cancel" onClick={onCancel}>
</Button>,
<Button key="confirm" type="primary" onClick={handleConfirm}>
</Button>,
]}
destroyOnClose
>
<div style={{ display: 'flex', justifyContent: 'center', maxHeight: '60vh', overflow: 'auto' }}>
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(c) => setCompletedCrop(c)}
aspect={1} // 强制正方形
circularCrop={false}
>
<img
ref={imgRef}
alt="Crop me"
src={imageUrl}
onLoad={onImageLoad}
crossOrigin="anonymous"
style={{ maxWidth: '100%', maxHeight: '50vh' }}
/>
</ReactCrop>
</div>
<canvas
ref={canvasRef}
style={{ display: 'none' }}
/>
</Modal>
);
};
export default ImageCropperModal;

View File

@@ -0,0 +1,329 @@
import React, { useState, useRef, useEffect } from 'react';
import { Input, Button, Row, Col, Grid, Modal, message } from 'antd';
import { UploadOutlined, PlusOutlined, DeleteOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
import ImagePreview from './ImagePreview';
import { uploadCustomImage, uploadImage } from '../apis';
const { useBreakpoint } = Grid;
interface ImageInputGroupProps {
value?: string[];
onChange?: (value: string[]) => void;
customId?: string; // Optional: needed for uploadCustomImage if editing
}
const ImageInputGroup: React.FC<ImageInputGroupProps> = ({ value = [], onChange, customId }) => {
const [uploading, setUploading] = useState(false);
const [currentUploadIndex, setCurrentUploadIndex] = useState<number>(-1);
const [imgSrc, setImgSrc] = useState('');
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<Crop>();
const [modalVisible, setModalVisible] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const touchStartRef = useRef<number | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const screens = useBreakpoint();
const isMobile = !screens.md;
// Internal state to manage the list of images
const [images, setImages] = useState<string[]>(value);
useEffect(() => {
setImages(value);
}, [value]);
// Ensure previewIndex is valid
useEffect(() => {
if (images.length > 0 && previewIndex >= images.length) {
setPreviewIndex(Math.max(0, images.length - 1));
}
}, [images.length, previewIndex]);
const currentPreviewUrl = images[previewIndex] || '';
const triggerChange = (newImages: string[]) => {
setImages(newImages);
onChange?.(newImages);
};
const handleInputChange = (index: number, newValue: string) => {
const newImages = [...images];
newImages[index] = newValue;
triggerChange(newImages);
};
const handleAdd = () => {
const newImages = [...images, ''];
triggerChange(newImages);
setPreviewIndex(newImages.length - 1);
};
const handleRemove = (index: number) => {
const newImages = images.filter((_, i) => i !== index);
triggerChange(newImages);
if (previewIndex >= newImages.length) {
setPreviewIndex(Math.max(0, newImages.length - 1));
}
};
// File Selection
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 = customId
? await uploadCustomImage(customId, blob as File)
: await uploadImage(blob as File);
if (response.data) {
const newImages = [...images];
if (currentUploadIndex >= 0 && currentUploadIndex < newImages.length) {
newImages[currentUploadIndex] = response.data;
triggerChange(newImages);
setPreviewIndex(currentUploadIndex);
}
}
} catch {
message.error('图片上传失败');
} finally {
setUploading(false);
setModalVisible(false);
}
}
}, 'image/png');
}
};
// Touch handlers for carousel
const handleTouchStart = (e: React.TouchEvent) => {
touchStartRef.current = e.touches[0].clientX;
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartRef.current === null) return;
const touchEnd = e.changedTouches[0].clientX;
const diff = touchStartRef.current - touchEnd;
if (Math.abs(diff) > 50) {
if (diff > 0) {
// Next
if (images.length > 0 && previewIndex < images.length - 1) {
setPreviewIndex(previewIndex + 1);
}
} else {
// Prev
if (previewIndex > 0) {
setPreviewIndex(previewIndex - 1);
}
}
}
touchStartRef.current = null;
};
const coverPreviewNode = (
<div
style={{ position: 'relative' }}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<ImagePreview
url={currentPreviewUrl}
minHeight="272px"
maxHeight="272px"
/>
{/* Navigation Buttons for PC */}
{images.length > 1 && (
<>
<Button
shape="circle"
icon={<LeftOutlined />}
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', zIndex: 1, opacity: 0.7 }}
onClick={() => setPreviewIndex(prev => Math.max(0, prev - 1))}
disabled={previewIndex === 0}
className="desktop-only-btn"
/>
<Button
shape="circle"
icon={<RightOutlined />}
style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', zIndex: 1, opacity: 0.7 }}
onClick={() => setPreviewIndex(prev => Math.min(images.length - 1, prev + 1))}
disabled={previewIndex === images.length - 1}
className="desktop-only-btn"
/>
<div style={{ textAlign: 'center', marginTop: 8 }}>
{previewIndex + 1} / {images.length}
</div>
</>
)}
</div>
);
const imagesListNode = (
<div style={{ marginBottom: 0 }}>
{images.map((url, index) => (
<Row key={index} gutter={8} align="middle" style={{
marginBottom: 8,
background: index === previewIndex ? '#f6ffed' : 'transparent',
padding: '4px',
borderRadius: '4px',
border: index === previewIndex ? '1px solid #b7eb8f' : '1px solid transparent'
}}>
<Col flex="auto">
<Input
placeholder="输入链接"
value={url}
onChange={(e) => handleInputChange(index, e.target.value)}
onFocus={() => setPreviewIndex(index)}
suffix={
<Button icon={<UploadOutlined />} type="text" size="small" loading={uploading && currentUploadIndex === index} onClick={() => {
setCurrentUploadIndex(index);
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();
}} />
}
/>
</Col>
<Col>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleRemove(index)}
/>
</Col>
</Row>
))}
<Button type="dashed" onClick={handleAdd} block icon={<PlusOutlined />}>
</Button>
</div>
);
return (
<>
<Row gutter={[24, 24]}>
{isMobile ? (
<>
{/* Mobile: Preview Top, Inputs Bottom */}
<Col span={24}>
{coverPreviewNode}
</Col>
<Col span={24}>
{imagesListNode}
</Col>
</>
) : (
<>
{/* PC: Inputs Left, Preview Right */}
<Col span={12}>
{imagesListNode}
</Col>
<Col span={12}>
{coverPreviewNode}
</Col>
</>
)}
</Row>
{/* Crop Modal */}
<Modal
title="裁剪图片"
open={modalVisible}
onOk={onOk}
onCancel={() => setModalVisible(false)}
confirmLoading={uploading}
destroyOnClose
>
{imgSrc && (
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(c) => setCompletedCrop(c)}
aspect={1}
>
<img
ref={imgRef}
alt="Crop me"
src={imgSrc}
onLoad={onImageLoad}
style={{ maxWidth: '100%', maxHeight: '60vh' }}
/>
</ReactCrop>
)}
<canvas
ref={previewCanvasRef}
style={{
display: 'none',
objectFit: 'contain',
width: completedCrop?.width,
height: completedCrop?.height,
}}
/>
</Modal>
</>
);
};
export default ImageInputGroup;

View File

@@ -1,23 +1,37 @@
import React, { useState, useEffect } from 'react';
import { Modal, Spin } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useRef } from 'react';
import { Modal, Spin, Button } from 'antd';
import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import './ImageModal.css';
interface ImageModalProps {
visible: boolean;
imageUrl: string;
images: string[];
initialIndex?: number;
onClose: () => void;
}
// 图片缓存
const imageCache = new Set<string>();
const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) => {
const ImageModal: React.FC<ImageModalProps> = ({ visible, images, initialIndex = 0, onClose }) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [loading, setLoading] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
const touchStartRef = useRef<number | null>(null);
// Initialize index
useEffect(() => {
if (visible) {
setCurrentIndex(initialIndex);
}
}, [visible, initialIndex]);
// Current URL
const currentImageUrl = images && images.length > 0 ? images[currentIndex] : '';
// 检测是否为移动端
useEffect(() => {
@@ -33,11 +47,18 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
// 预加载图片
useEffect(() => {
if (visible && imageUrl) {
if (visible && currentImageUrl) {
// 如果图片已缓存,直接显示
if (imageCache.has(imageUrl)) {
if (imageCache.has(currentImageUrl)) {
setImageLoaded(true);
setLoading(false);
// Still need dimensions for mobile height calc?
// Ideally we should cache dimensions too, but for now let's reload to be safe or accept slight jump
const img = new Image();
img.src = currentImageUrl;
img.onload = () => {
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
}
return;
}
@@ -47,7 +68,7 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
const img = new Image();
img.onload = () => {
imageCache.add(imageUrl);
imageCache.add(currentImageUrl);
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
setImageLoaded(true);
setLoading(false);
@@ -56,9 +77,9 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
setImageError(true);
setLoading(false);
};
img.src = imageUrl;
img.src = currentImageUrl;
}
}, [visible, imageUrl]);
}, [visible, currentImageUrl]);
// 重置状态当弹窗关闭时
useEffect(() => {
@@ -108,6 +129,7 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
position: 'relative' as const, // For arrows
} : {
padding: 0,
height: '66vh',
@@ -115,6 +137,43 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
position: 'relative' as const,
};
// Navigation Logic
const handlePrev = (e?: React.MouseEvent) => {
e?.stopPropagation();
if (currentIndex > 0) {
setCurrentIndex(prev => prev - 1);
}
};
const handleNext = (e?: React.MouseEvent) => {
e?.stopPropagation();
if (images && currentIndex < images.length - 1) {
setCurrentIndex(prev => prev + 1);
}
};
const handleTouchStart = (e: React.TouchEvent) => {
touchStartRef.current = e.touches[0].clientX;
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartRef.current === null) return;
const touchEnd = e.changedTouches[0].clientX;
const diff = touchStartRef.current - touchEnd;
if (Math.abs(diff) > 50) { // Threshold
if (diff > 0) {
// Swiped Left -> Next
handleNext();
} else {
// Swiped Right -> Prev
handlePrev();
}
}
touchStartRef.current = null;
};
return (
@@ -163,8 +222,71 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
<CloseOutlined />
</div>
{/* Navigation Buttons (PC only mostly, but logic is generic) */}
{!isMobile && images.length > 1 && (
<>
<Button
shape="circle"
icon={<LeftOutlined />}
style={{
position: 'absolute',
left: 20,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1000,
opacity: currentIndex === 0 ? 0.3 : 0.8,
border: 'none',
backgroundColor: 'rgba(255,255,255,0.3)',
color: '#fff'
}}
onClick={handlePrev}
disabled={currentIndex === 0}
/>
<Button
shape="circle"
icon={<RightOutlined />}
style={{
position: 'absolute',
right: 20,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1000,
opacity: currentIndex === images.length - 1 ? 0.3 : 0.8,
border: 'none',
backgroundColor: 'rgba(255,255,255,0.3)',
color: '#fff'
}}
onClick={handleNext}
disabled={currentIndex === images.length - 1}
/>
</>
)}
{/* Image Count Indicator */}
{images.length > 1 && (
<div style={{
position: 'absolute',
bottom: 20,
left: '50%',
transform: 'translateX(-50%)',
color: '#fff',
backgroundColor: 'rgba(0,0,0,0.5)',
padding: '4px 12px',
borderRadius: '12px',
zIndex: 1000,
fontSize: '14px'
}}>
{currentIndex + 1} / {images.length}
</div>
)}
{/* 图片内容 */}
<div className="image-modal-container">
<div
className="image-modal-container"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{ width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
{loading && (
<Spin size="large" style={{ color: '#fff' }} />
)}
@@ -178,8 +300,9 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
{imageLoaded && !loading && !imageError && (
<img
src={imageUrl}
src={currentImageUrl}
alt="预览图片"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
)}
</div>
@@ -187,4 +310,4 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
);
};
export default ImageModal;
export default ImageModal;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Image } from 'antd';
interface ImagePreviewProps {
url?: string;
alt?: string;
minHeight?: string | number;
maxHeight?: string | number;
placeholder?: string;
backgroundColor?: string;
}
const ImagePreview: React.FC<ImagePreviewProps> = ({
url,
alt = '封面预览',
minHeight,
maxHeight,
placeholder = '封面预览',
backgroundColor = '#fafafa',
}) => {
return (
<div style={{
width: '100%',
height: '100%',
minHeight: minHeight,
maxHeight: maxHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed #d9d9d9',
borderRadius: '8px',
background: backgroundColor,
padding: '8px',
overflow: 'hidden'
}}>
{url ? (
<Image
src={url}
alt={alt}
style={{
height: '100%',
width: '100%',
maxHeight: typeof maxHeight === 'string' ? `calc(${maxHeight} - 16px)` : maxHeight ? (maxHeight as number) - 16 : undefined,
objectFit: 'contain'
}}
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+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' }}>{placeholder}</div>
)}
</div>
);
};
export default ImagePreview;

View File

@@ -0,0 +1,90 @@
.image-selector-modal .ant-modal-body {
padding: 0;
overflow: hidden;
}
.image-selector-container {
position: relative;
width: 100%;
height: 400px; /* 固定高度,或者根据需要调整 */
background-color: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.image-selector-container .no-image-placeholder {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.image-selector-container .image-wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.image-selector-container .image-wrapper img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.nav-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
background-color: rgba(0, 0, 0, 0.3);
color: white;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.3s;
z-index: 10;
}
.nav-arrow:hover {
background-color: rgba(0, 0, 0, 0.6);
}
.nav-arrow.left {
left: 16px;
}
.nav-arrow.right {
right: 16px;
}
.image-counter {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 14px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.nav-arrow {
display: none; /* 移动端隐藏箭头,使用滑动 */
}
.image-selector-container {
height: 300px;
}
}

View File

@@ -0,0 +1,115 @@
import React, { useState, useEffect, useRef } from 'react';
import { Modal, Button, Empty } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import './ImageSelectorModal.css';
interface ImageSelectorModalProps {
visible: boolean;
images?: string[];
onCancel: () => void;
onSelect: (imageUrl: string) => void;
}
const ImageSelectorModal: React.FC<ImageSelectorModalProps> = ({
visible,
images = [],
onCancel,
onSelect,
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const touchStartRef = useRef<number | null>(null);
useEffect(() => {
if (visible) {
setCurrentIndex(0);
}
}, [visible]);
const handlePrev = () => {
if (!images || images.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
};
const handleNext = () => {
if (!images || images.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % images.length);
};
const handleTouchStart = (e: React.TouchEvent) => {
touchStartRef.current = e.touches[0].clientX;
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartRef.current === null) return;
const touchEnd = e.changedTouches[0].clientX;
const diff = touchStartRef.current - touchEnd;
if (Math.abs(diff) > 50) { // Threshold for swipe
if (diff > 0) {
handleNext();
} else {
handlePrev();
}
}
touchStartRef.current = null;
};
const hasImages = images && images.length > 0;
const currentImage = hasImages ? images[currentIndex] : null;
return (
<Modal
open={visible}
onCancel={onCancel}
title="选择分享图片"
footer={[
<Button key="cancel" onClick={onCancel}>
</Button>,
<Button
key="confirm"
type="primary"
onClick={() => currentImage && onSelect(currentImage)}
disabled={!hasImages}
>
</Button>,
]}
width={600}
centered
className="image-selector-modal"
>
<div
className="image-selector-container"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{!hasImages ? (
<div className="no-image-placeholder">
<Empty description="暂无图片" />
</div>
) : (
<>
<div className="image-wrapper">
<img src={currentImage!} alt={`preview-${currentIndex}`} />
</div>
{/* PC端显示的箭头 */}
<div className="nav-arrow left" onClick={handlePrev}>
<LeftOutlined />
</div>
<div className="nav-arrow right" onClick={handleNext}>
<RightOutlined />
</div>
<div className="image-counter">
{currentIndex + 1} / {images.length}
</div>
</>
)}
</div>
</Modal>
);
};
export default ImageSelectorModal;

View File

@@ -35,9 +35,7 @@
/* 抽屉与遮罩不再额外向下偏移,依赖 getContainer 挂载到标题栏下方的容器 */
@media (max-width: 768px) {
.input-drawer-box {
max-width: 100%;
padding: 14px; /* 移动端更紧凑 */
}
.layout-mobile .input-drawer-box {
max-width: 100%;
padding: 14px; /* 移动端更紧凑 */
}

View File

@@ -11,9 +11,10 @@ type Props = {
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
showUpload?: boolean; // 透传到输入面板,控制图片上传按钮
mode?: 'input' | 'search' | 'batch-image'; // 透传到输入面板,控制工作模式
targetModel?: 'people' | 'custom'; // 透传到输入面板,识别目标模型
};
const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, showUpload = true, mode = 'input' }) => {
const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, showUpload = true, mode = 'input', targetModel = 'people' }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [topbarHeight, setTopbarHeight] = React.useState<number>(56);
@@ -66,7 +67,7 @@ const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, sh
<div className="input-drawer-inner">
<div className="input-drawer-title">AI FIND U</div>
<div className="input-drawer-box">
<InputPanel onResult={handleResult} showUpload={showUpload} mode={mode} />
<InputPanel onResult={handleResult} showUpload={showUpload} mode={mode} targetModel={targetModel} />
<HintText showUpload={showUpload} />
</div>
</div>

View File

@@ -11,9 +11,10 @@ interface InputPanelProps {
onResult?: (data: unknown) => void;
showUpload?: boolean; // 是否显示图片上传按钮,默认显示
mode?: 'input' | 'search' | 'batch-image'; // 输入面板工作模式,新增批量图片模式
targetModel?: 'people' | 'custom'; // 识别目标模型,默认为 people
}
const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mode = 'input' }) => {
const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mode = 'input', targetModel = 'people' }) => {
const [value, setValue] = React.useState('');
const [fileList, setFileList] = React.useState<UploadFile[]>([]);
const [loading, setLoading] = React.useState(false);
@@ -68,7 +69,7 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
for (let i = 0; i < fileList.length; i++) {
const f = fileList[i].originFileObj as RcFile | undefined;
if (!f) continue;
const resp = await postInputImage(f);
const resp = await postInputImage(f, targetModel);
if (resp && resp.error_code === 0 && resp.data) {
results.push(resp.data);
}
@@ -103,11 +104,11 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
}
console.log('上传图片:', file.name);
response = await postInputImage(file);
response = await postInputImage(file, targetModel);
} else {
// 只有文本时,调用文本处理 API
console.log('处理文本:', trimmed);
response = await postInput(trimmed);
response = await postInput(trimmed, targetModel);
}
console.log('API响应:', response);

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { Layout } from 'antd';
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Grid } from 'antd';
import { Routes, Route, useNavigate, useLocation, Navigate } from 'react-router-dom';
import SiderMenu from './SiderMenu.tsx';
import MainContent from './MainContent.tsx';
import CustomRegister from './CustomRegister.tsx';
import ResourceList from './ResourceList.tsx';
import CustomList from './CustomList.tsx';
import BatchRegister from './BatchRegister.tsx';
import TopBar from './TopBar.tsx';
import '../styles/base.css';
@@ -13,23 +15,33 @@ import UserProfile from './UserProfile.tsx';
const LayoutWrapper: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
const [inputOpen, setInputOpen] = React.useState(false);
const isHome = location.pathname === '/';
const isResourceInput = location.pathname === '/resource-input';
const isList = location.pathname === '/resources';
const isBatch = location.pathname === '/batch-register';
const layoutShellRef = React.useRef<HTMLDivElement>(null);
const pathToKey = (path: string) => {
switch (path) {
case '/custom-register':
return 'custom';
case '/resources':
return 'menu1';
case '/batch-register':
return 'batch';
case '/custom-list':
return 'custom-list';
case '/menu2':
return 'menu2';
default:
case '/resource-input':
return 'home';
default:
// 根路径重定向到客户列表,所以默认可以选中 custom-list或者不做处理
if (path === '/') return 'custom-list';
return 'custom-list';
}
};
@@ -38,11 +50,17 @@ const LayoutWrapper: React.FC = () => {
const handleNavigate = (key: string) => {
switch (key) {
case 'home':
navigate('/');
navigate('/resource-input');
break;
case 'custom':
navigate('/custom-register');
break;
case 'batch':
navigate('/batch-register');
break;
case 'custom-list':
navigate('/custom-list');
break;
case 'menu1':
navigate('/resources');
break;
@@ -50,7 +68,7 @@ const LayoutWrapper: React.FC = () => {
navigate('/menu2');
break;
default:
navigate('/');
navigate('/custom-list');
break;
}
// 切换页面时收起输入抽屉
@@ -58,12 +76,12 @@ const LayoutWrapper: React.FC = () => {
};
return (
<Layout className="layout-wrapper app-root">
<Layout className={`layout-wrapper app-root ${isMobile ? 'layout-mobile' : 'layout-desktop'}`}>
{/* 顶部标题栏,位于左侧菜单栏之上 */}
<TopBar
onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
onToggleInput={() => {if (isHome || isList || isBatch) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
showInput={isHome || isList || isBatch}
onToggleInput={() => {if (isResourceInput || isList || isBatch) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
showInput={isResourceInput || isList || isBatch}
/>
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
<Layout ref={layoutShellRef} className="layout-shell">
@@ -75,8 +93,9 @@ const LayoutWrapper: React.FC = () => {
/>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/custom-list" replace />} />
<Route
path="/"
path="/resource-input"
element={
<MainContent
inputOpen={inputOpen}
@@ -85,6 +104,16 @@ const LayoutWrapper: React.FC = () => {
/>
}
/>
<Route
path="/custom-register"
element={
<CustomRegister
inputOpen={inputOpen}
onCloseInput={() => setInputOpen(false)}
containerEl={layoutShellRef.current}
/>
}
/>
<Route
path="/batch-register"
element={
@@ -95,6 +124,12 @@ const LayoutWrapper: React.FC = () => {
/>
}
/>
<Route
path="/custom-list"
element={
<CustomList />
}
/>
<Route
path="/resources"
element={
@@ -114,4 +149,4 @@ const LayoutWrapper: React.FC = () => {
);
};
export default LayoutWrapper;
export default LayoutWrapper;

View File

@@ -0,0 +1,94 @@
import React, { useState } from 'react';
import { Input, Button, Space } from 'antd';
import type { FilterConfirmProps } from 'antd/es/table/interface';
interface NumberRangeFilterProps {
setSelectedKeys: (selectedKeys: React.Key[]) => void;
selectedKeys: React.Key[];
confirm: (param?: FilterConfirmProps) => void;
clearFilters: () => void;
close: () => void;
}
const NumberRangeFilterDropdown: React.FC<NumberRangeFilterProps> = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
close,
}) => {
const [min, setMin] = useState<string>(selectedKeys[0] ? String(selectedKeys[0]) : '');
const [max, setMax] = useState<string>(selectedKeys[1] ? String(selectedKeys[1]) : '');
const handleSearch = () => {
// 将两个值合并为一个字符串存储,方便 onFilter 处理
if (min || max) {
setSelectedKeys([`${min},${max}`]);
} else {
setSelectedKeys([]);
}
confirm();
close();
};
const handleReset = () => {
setMin('');
setMax('');
clearFilters();
confirm();
close();
};
// 初始化状态时,解析 selectedKeys[0]
React.useEffect(() => {
if (selectedKeys[0]) {
const [initialMin, initialMax] = (selectedKeys[0] as string).split(',');
setMin(initialMin);
setMax(initialMax);
}
}, [selectedKeys]);
return (
<div style={{ padding: 8 }} onKeyDown={(e) => e.stopPropagation()}>
<Space direction="vertical" size={8}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input
placeholder="最小"
value={min}
onChange={(e) => setMin(e.target.value)}
style={{ width: 80 }}
/>
<span>-</span>
<Input
placeholder="最大"
value={max}
onChange={(e) => setMax(e.target.value)}
style={{ width: 80 }}
/>
</div>
<Space style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
color={min || max ? 'primary' : 'default'}
variant="text"
onClick={handleReset}
size="small"
style={{ width: 80 }}
>
</Button>
<Button
type="primary"
onClick={handleSearch}
icon={null}
size="small"
style={{ width: 80 }}
>
</Button>
</Space>
</Space>
</div>
);
};
export default NumberRangeFilterDropdown;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Select, InputNumber, Button, message, Row, Col, Image, Modal } from 'antd';
import { Form, Input, Select, InputNumber, Button, message, Row, Col, 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';
@@ -7,6 +7,7 @@ import type { FormInstance } from 'antd';
import './PeopleForm.css';
import KeyValueList from './KeyValueList.tsx'
import ImagePreview from './ImagePreview.tsx';
import { createPeople, type People, uploadPeopleImage, uploadImage } from '../apis';
const { TextArea } = Input;
@@ -211,33 +212,13 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
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>
<ImagePreview
url={coverUrl}
minHeight="264px"
maxHeight="264px"
// backgroundColor="#0b0101ff"
/>
);
return (
<div className="people-form">
<Form

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag, message, Modal, Dropdown, Input, Select } from 'antd';
import { Layout, Typography, Table, Grid, Button, Space, Tag, message, Modal, Dropdown, Input, Select } from 'antd';
import type { FormInstance } from 'antd';
import type { ColumnsType, ColumnType } from 'antd/es/table';
import type { FilterDropdownProps } from 'antd/es/table/interface';
@@ -9,6 +9,7 @@ import './MainContent.css';
import InputDrawer from './InputDrawer.tsx';
import ImageModal from './ImageModal.tsx';
import PeopleForm from './PeopleForm.tsx';
import NumberRangeFilterDropdown from './NumberRangeFilterDropdown';
import { getPeoples } from '../apis';
import type { People } from '../apis';
import { addOrUpdateRemark, deletePeople, deleteRemark, updatePeople } from '../apis/people';
@@ -463,23 +464,7 @@ async function fetchResources(): Promise<Resource[]> {
}
// 数字范围筛选下拉
function NumberRangeFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) {
const [min, max] = String(selectedKeys?.[0] ?? ':').split(':');
const [localMin, setLocalMin] = React.useState<number | undefined>(min ? Number(min) : undefined);
const [localMax, setLocalMax] = React.useState<number | undefined>(max ? Number(max) : undefined);
return (
<div style={{ padding: 8 }}>
<Space direction="vertical" style={{ width: 200 }}>
<InputNumber placeholder="最小值" value={localMin} onChange={(v) => setLocalMin(v ?? undefined)} style={{ width: '100%' }} />
<InputNumber placeholder="最大值" value={localMax} onChange={(v) => setLocalMax(v ?? undefined)} style={{ width: '100%' }} />
<Space>
<Button type="primary" size="small" icon={<SearchOutlined />} onClick={() => { const key = `${localMin ?? ''}:${localMax ?? ''}`; setSelectedKeys?.([key]); confirm?.({ closeDropdown: true }); }}></Button>
<Button size="small" onClick={() => { setLocalMin(undefined); setLocalMax(undefined); setSelectedKeys?.([]); clearFilters?.(); confirm?.({ closeDropdown: true }); }}></Button>
</Space>
</Space>
</div>
);
}
// 已替换为引入的 NumberRangeFilterDropdown 组件
function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): ColumnType<Resource> {
return {
@@ -490,15 +475,25 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
const bv = b[dataIndex] as number | undefined;
return Number(av ?? 0) - Number(bv ?? 0);
},
filterDropdown: (props) => <NumberRangeFilterDropdown {...props} />,
filterDropdown: (props) => (
<NumberRangeFilterDropdown
{...props}
clearFilters={() => props.clearFilters?.()}
/>
),
onFilter: (filterValue: React.Key | boolean, record: Resource) => {
const [minStr, maxStr] = String(filterValue).split(':');
// 适配新组件的逗号分隔格式
const [minStr, maxStr] = String(filterValue).split(',');
const min = minStr ? Number(minStr) : undefined;
const max = maxStr ? Number(maxStr) : undefined;
const val = record[dataIndex] as number | undefined;
if (val === undefined || Number.isNaN(Number(val))) return false;
if (min !== undefined && Number(val) < min) return false;
if (max !== undefined && Number(val) > max) return false;
// 如果值无效,视为不匹配(或者根据需求调整)
if (val === undefined || val === null || Number.isNaN(Number(val))) return false;
const numVal = Number(val);
if (min !== undefined && !isNaN(min) && numVal < min) return false;
if (max !== undefined && !isNaN(max) && numVal > max) return false;
return true;
},
} as ColumnType<Resource>;
@@ -1083,7 +1078,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
{/* 图片预览弹窗 */}
<ImageModal
visible={imageModalVisible}
imageUrl={currentImageUrl}
images={currentImageUrl ? [currentImageUrl] : []}
onClose={() => {
setImageModalVisible(false);
setCurrentImageUrl('');

View File

@@ -3,7 +3,7 @@ import RegisterModal from './RegisterModal';
import { useAuth } from '../contexts/useAuth';
import React from 'react';
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
import { FormOutlined, UnorderedListOutlined, MenuOutlined, CopyOutlined, UserOutlined, SettingOutlined } from '@ant-design/icons';
import { FormOutlined, UnorderedListOutlined, MenuOutlined, CopyOutlined, UserOutlined, SettingOutlined, TeamOutlined } from '@ant-design/icons';
import './SiderMenu.css';
import { useNavigate } from 'react-router-dom';
@@ -50,6 +50,8 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
}, [selectedKey]);
const items = [
{ key: 'custom-list', label: '客户列表', icon: <TeamOutlined /> },
{ key: 'custom', label: '客户录入', icon: <UserOutlined /> },
{ key: 'home', label: '录入资源', icon: <FormOutlined /> },
{ key: 'batch', label: '批量录入', icon: <CopyOutlined /> },
{ key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },

View File

@@ -41,6 +41,4 @@
}
.icon-btn:hover { background: rgba(16,185,129,0.35); }
@media (min-width: 768px) {
.topbar { grid-template-columns: 56px 1fr 56px; }
}
.layout-desktop .topbar { grid-template-columns: 56px 1fr 56px; }

View File

@@ -63,7 +63,5 @@
}
/* 小屏优化:输入区域内边距更紧凑 */
@media (max-width: 768px) {
.content-body { padding: 16px; border-radius: 0; }
.input-panel-wrapper { padding: 12px 12px 16px 12px; }
}
.layout-mobile .content-body { padding: 16px; border-radius: 0; }
.layout-mobile .input-panel-wrapper { padding: 12px 12px 16px 12px; }

View File

@@ -0,0 +1,304 @@
export interface ShareData {
// 1. 照片
imageBlob: Blob;
// 2. 标签区 (左下)
tags: {
assets: string; // 资产等级 (A6, A7...)
liquidAssets: string; // 流动资产 (C6, C7...)
income: string; // 收入 (50万)
};
// 3. 信息区 (右上)
basicInfo: {
age: string; // 30岁
height: string; // 175cm
degree: string; // 本科
};
details: {
city: string; // 常住城市
isSingleChild: string; // 是/否/-
houseCar: string; // 有房有贷; 有车无贷
};
introduction: string; // 详细介绍文本
// 4. 红娘区 (右下)
matchmaker: {
orgName: string; // IF.U
name: string; // 红娘昵称
};
}
export const generateShareImage = (data: ShareData): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(data.imageBlob);
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas context not supported'));
return;
}
// 1. 设置画布尺寸 960x540
const totalWidth = 960;
const totalHeight = 540;
canvas.width = totalWidth;
canvas.height = totalHeight;
// 填充白色背景
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// ==========================================
// 1. 左上:照片区域 (0, 0, 480, 480)
// ==========================================
const photoSize = 480;
// 绘制照片,缩放至 480x480
ctx.save();
// 绘制圆角遮罩(如果需要圆角,用户示意图左上角似乎是直角,只有整个卡片可能有圆角,这里先直角)
ctx.drawImage(img, 0, 0, photoSize, photoSize);
// 添加一个内阴影或边框让照片更清晰(可选,参考示意图有浅蓝边框)
ctx.strokeStyle = '#d9e8ff'; // 浅蓝色边框
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, photoSize, photoSize);
// 左上角 "照片" 字样 (已移除)
ctx.restore();
// ==========================================
// 2. 左下:标签区域 (0, 480, 480, 60)
// ==========================================
const tagAreaY = 480;
const tagAreaHeight = 60;
// 区域宽 480平均分给3个标签
// 左右留白 20px -> 可用 440
// 间距 20px * 2 = 40
// 标签宽 (440 - 40) / 3 = 133.33 -> 取 130
const tagWidth = 130;
const tagHeight = 40; // 略微加高
const tagY = tagAreaY + (tagAreaHeight - tagHeight) / 2;
// 标签配置
const tags = [
{ label: data.tags.assets || '-', bg: '#E6E6FA' }, // 资产 - 淡紫
{ label: data.tags.liquidAssets || '-', bg: '#E6E6FA' }, // 流动 - 淡紫
{ label: data.tags.income || '-', bg: '#E6E6FA' } // 收入 - 淡紫
];
// 计算起始 X居中排列
// 总宽 480, 内容宽 3 * 130 + 2 * 20 = 390 + 40 = 430
// 剩余 50, padding-left 25
const startX = 25;
const gap = 20;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 字体适当调大
ctx.font = 'bold 18px sans-serif';
tags.forEach((tag, index) => {
const x = startX + index * (tagWidth + gap);
// 绘制胶囊背景
ctx.fillStyle = tag.bg;
ctx.beginPath();
ctx.roundRect(x, tagY, tagWidth, tagHeight, tagHeight / 2);
ctx.fill();
// 绘制黑色边框
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1.5;
ctx.stroke();
// 绘制文字
ctx.fillStyle = '#000000';
ctx.fillText(tag.label, x + tagWidth / 2, tagY + tagHeight / 2 + 1);
});
ctx.restore();
// ==========================================
// 3. 右上:信息区域 (480, 0, 480, 480)
// ==========================================
const infoX = 480;
// 3.1 顶部三个便签 (年龄, 身高, 学历)
// y: 30
const noteY = 30;
// 宽度增加
const noteWidth = 130;
const noteHeight = 50;
const noteGap = 20;
// 480 - (130*3 + 20*2) = 480 - 430 = 50, padding 25
const noteStartX = infoX + 25;
const notes = [
{ label: data.basicInfo.age || '-', title: '年龄' },
{ label: data.basicInfo.height || '-', title: '身高' },
{ label: data.basicInfo.degree || '-', title: '学历' }
];
ctx.save();
notes.forEach((note, index) => {
const x = noteStartX + index * (noteWidth + noteGap);
// 模拟便签纸样式 (淡黄色背景 + 阴影)
ctx.fillStyle = '#FFF8DC'; // Cornsilk
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
ctx.beginPath();
// 简单矩形
ctx.fillRect(x, noteY, noteWidth, noteHeight);
// 清除阴影绘制边框
ctx.shadowColor = 'transparent';
ctx.strokeStyle = '#DAA520'; // GoldenRod
ctx.lineWidth = 1;
ctx.strokeRect(x, noteY, noteWidth, noteHeight);
// 绘制文字
ctx.fillStyle = '#000000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 字体不变或微调,需求说信息显示区文字大小可以不变,但便签比较大,稍微大一点点好看
ctx.font = 'bold 20px sans-serif';
ctx.fillText(note.label, x + noteWidth / 2, noteY + noteHeight / 2);
});
ctx.restore();
// 3.2 列表信息 (城市, 独生, 房车)
// y 从 120 开始 (noteY + noteHeight + gap)
let currentY = 130;
const labelX = infoX + 40;
const valueX = infoX + 160;
const lineHeight = 50; // 行高增加
ctx.save();
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const listItems = [
{ label: '【城市】', value: data.details.city || '常住城市' },
{ label: '【独生子女】', value: data.details.isSingleChild || '-' },
{ label: '【房车情况】', value: data.details.houseCar || '-' },
];
listItems.forEach(item => {
// 标签 (加粗) - 保持大小或微调
ctx.font = 'bold 18px sans-serif';
ctx.fillStyle = '#333333';
ctx.fillText(item.label, labelX, currentY);
// 值
ctx.font = '18px sans-serif';
ctx.fillStyle = '#000000';
ctx.fillText(item.value, valueX, currentY);
currentY += lineHeight;
});
// 3.3 详细介绍
// currentY 约为 130 + 50*3 = 280
currentY += 10; // 增加一点间距
ctx.font = 'bold 18px sans-serif';
ctx.fillStyle = '#333333';
ctx.fillText('【详细介绍】', labelX, currentY);
currentY += 35;
ctx.font = '18px sans-serif';
ctx.fillStyle = '#555555';
// 文本换行与截断处理
const maxWidth = 400; // 480 - 80 padding
// 空间计算: 480(底线) - 280(当前Y) = 200px 剩余高度
// 行高 30px -> 约 6 行
const maxLines = 6;
const textX = labelX; // 对齐
const words = (data.introduction || '').split('');
let line = '';
let linesDrawn = 0;
for (let i = 0; i < words.length; i++) {
if (linesDrawn >= maxLines) break;
const testLine = line + words[i];
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && i > 0) {
// 这一行满了
if (linesDrawn === maxLines - 1) {
// 最后一行,需要截断加...
line = line.substring(0, line.length - 1) + '...';
ctx.fillText(line, textX, currentY);
linesDrawn++;
break;
} else {
ctx.fillText(line, textX, currentY);
line = words[i];
currentY += 30; // 行高
linesDrawn++;
}
} else {
line = testLine;
}
}
// 绘制最后一行 (如果没有超限)
if (linesDrawn < maxLines && line.length > 0) {
ctx.fillText(line, textX, currentY);
}
ctx.restore();
// ==========================================
// 4. 右下:红娘区域 (480, 480, 480, 60)
// ==========================================
const footerX = 480;
const footerY = 480;
ctx.save();
// 背景
ctx.fillStyle = '#E6E6FA'; // Lavender (淡紫色)
// 绘制带边框的圆角矩形
const footerRectX = footerX + 20;
const footerRectY = footerY + 10;
const footerRectW = 440;
const footerRectH = 40;
ctx.roundRect(footerRectX, footerRectY, footerRectW, footerRectH, 20);
ctx.fill();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1.5;
ctx.stroke();
// 文字
ctx.fillStyle = '#000000';
ctx.font = '18px sans-serif'; // 调大字体
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const footerText = `${data.matchmaker.orgName} - ${data.matchmaker.name}`;
ctx.fillText(footerText, footerRectX + footerRectW / 2, footerRectY + footerRectH / 2);
ctx.restore();
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => {
reject(new Error('Failed to load base image'));
};
img.src = url;
});
};