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:
@@ -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;
|
||||
|
||||
@@ -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
76
src/apis/custom.ts
Normal 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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -165,6 +165,7 @@ const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, conta
|
||||
containerEl={containerEl}
|
||||
showUpload
|
||||
mode={'batch-image'}
|
||||
targetModel="people"
|
||||
/>
|
||||
</Content>
|
||||
)
|
||||
|
||||
40
src/components/CustomForm.css
Normal file
40
src/components/CustomForm.css
Normal 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);
|
||||
}
|
||||
377
src/components/CustomForm.tsx
Normal file
377
src/components/CustomForm.tsx
Normal 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;
|
||||
613
src/components/CustomList.tsx
Normal file
613
src/components/CustomList.tsx
Normal 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;
|
||||
42
src/components/CustomRegister.tsx
Normal file
42
src/components/CustomRegister.tsx
Normal 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;
|
||||
176
src/components/ImageCropperModal.tsx
Normal file
176
src/components/ImageCropperModal.tsx
Normal 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;
|
||||
329
src/components/ImageInputGroup.tsx
Normal file
329
src/components/ImageInputGroup.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
56
src/components/ImagePreview.tsx
Normal file
56
src/components/ImagePreview.tsx
Normal 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;
|
||||
90
src/components/ImageSelectorModal.css
Normal file
90
src/components/ImageSelectorModal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
115
src/components/ImageSelectorModal.tsx
Normal file
115
src/components/ImageSelectorModal.tsx
Normal 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;
|
||||
@@ -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; /* 移动端更紧凑 */
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
94
src/components/NumberRangeFilterDropdown.tsx
Normal file
94
src/components/NumberRangeFilterDropdown.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
304
src/utils/shareImageGenerator.ts
Normal file
304
src/utils/shareImageGenerator.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user