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 LayoutWrapper from './components/LayoutWrapper';
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return <LayoutWrapper />;
|
return (
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<LayoutWrapper />
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export const API_CONFIG = {
|
|||||||
|
|
||||||
// API 端点
|
// API 端点
|
||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
INPUT: '/recognition/input',
|
RECOGNITION_INPUT: (model: 'people' | 'custom') => `/recognition/${model}/input`,
|
||||||
INPUT_IMAGE: '/recognition/image',
|
RECOGNITION_IMAGE: (model: 'people' | 'custom') => `/recognition/${model}/image`,
|
||||||
// 人员列表查询仍为 /peoples
|
// 人员列表查询仍为 /peoples
|
||||||
PEOPLES: '/peoples',
|
PEOPLES: '/peoples',
|
||||||
// 新增单个资源路径 /people
|
// 新增单个资源路径 /people
|
||||||
@@ -30,4 +30,8 @@ export const API_ENDPOINTS = {
|
|||||||
DELETE_USER: '/user/me',
|
DELETE_USER: '/user/me',
|
||||||
UPDATE_PHONE: '/user/me/phone',
|
UPDATE_PHONE: '/user/me/phone',
|
||||||
UPDATE_EMAIL: '/user/me/email',
|
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 './upload';
|
||||||
export * from './people';
|
export * from './people';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './custom';
|
||||||
|
|
||||||
// 默认导出所有API函数
|
// 默认导出所有API函数
|
||||||
import * as inputApi from './input';
|
import * as inputApi from './input';
|
||||||
import * as uploadApi from './upload';
|
import * as uploadApi from './upload';
|
||||||
import * as peopleApi from './people';
|
import * as peopleApi from './people';
|
||||||
import * as userApi from './user';
|
import * as userApi from './user';
|
||||||
|
import * as customApi from './custom';
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// 文本输入相关
|
// 文本输入相关
|
||||||
@@ -29,6 +31,9 @@ export const api = {
|
|||||||
|
|
||||||
// 用户管理相关
|
// 用户管理相关
|
||||||
user: userApi,
|
user: userApi,
|
||||||
|
|
||||||
|
// 客户管理相关
|
||||||
|
custom: customApi,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import type { PostInputRequest, ApiResponse } from './types';
|
|||||||
* @param text 输入的文本内容
|
* @param text 输入的文本内容
|
||||||
* @returns Promise<ApiResponse>
|
* @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 };
|
const requestData: PostInputRequest = { text };
|
||||||
// 为 postInput 设置 30 秒超时时间
|
// 为 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 包含文本的请求对象
|
* @param data 包含文本的请求对象
|
||||||
* @returns Promise<ApiResponse>
|
* @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 秒超时时间
|
// 为 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;
|
people: People;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 客户信息请求类型
|
||||||
|
export interface PostCustomRequest {
|
||||||
|
custom: Custom;
|
||||||
|
}
|
||||||
|
|
||||||
// 人员查询参数类型
|
// 人员查询参数类型
|
||||||
export interface GetPeoplesParams {
|
export interface GetPeoplesParams {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -58,6 +63,49 @@ export interface People {
|
|||||||
comments?: { remark?: { content: string; updated_at: number } };
|
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> {
|
export interface PaginatedResponse<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
@@ -113,4 +161,4 @@ export interface UpdatePhoneRequest {
|
|||||||
export interface UpdateEmailRequest {
|
export interface UpdateEmailRequest {
|
||||||
email: string;
|
email: string;
|
||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import type { ApiResponse } from './types';
|
|||||||
* @param file 要上传的图片文件
|
* @param file 要上传的图片文件
|
||||||
* @returns Promise<ApiResponse>
|
* @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/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
throw new Error('只能上传图片文件');
|
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(
|
export async function postInputImageWithProgress(
|
||||||
file: File,
|
file: File,
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void,
|
||||||
|
model: 'people' | 'custom' = 'people'
|
||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse> {
|
||||||
// 验证文件类型
|
// 验证文件类型
|
||||||
if (!file.type.startsWith('image/')) {
|
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.timeout = 120000; // 30秒超时
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, conta
|
|||||||
containerEl={containerEl}
|
containerEl={containerEl}
|
||||||
showUpload
|
showUpload
|
||||||
mode={'batch-image'}
|
mode={'batch-image'}
|
||||||
|
targetModel="people"
|
||||||
/>
|
/>
|
||||||
</Content>
|
</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 React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Modal, Spin } from 'antd';
|
import { Modal, Spin, Button } from 'antd';
|
||||||
import { CloseOutlined } from '@ant-design/icons';
|
import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import './ImageModal.css';
|
import './ImageModal.css';
|
||||||
|
|
||||||
interface ImageModalProps {
|
interface ImageModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
imageUrl: string;
|
images: string[];
|
||||||
|
initialIndex?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片缓存
|
// 图片缓存
|
||||||
const imageCache = new Set<string>();
|
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 [loading, setLoading] = useState(false);
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -33,11 +47,18 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
|||||||
|
|
||||||
// 预加载图片
|
// 预加载图片
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && imageUrl) {
|
if (visible && currentImageUrl) {
|
||||||
// 如果图片已缓存,直接显示
|
// 如果图片已缓存,直接显示
|
||||||
if (imageCache.has(imageUrl)) {
|
if (imageCache.has(currentImageUrl)) {
|
||||||
setImageLoaded(true);
|
setImageLoaded(true);
|
||||||
setLoading(false);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +68,7 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
|||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imageCache.add(imageUrl);
|
imageCache.add(currentImageUrl);
|
||||||
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
|
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
setImageLoaded(true);
|
setImageLoaded(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -56,9 +77,9 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
|||||||
setImageError(true);
|
setImageError(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
img.src = imageUrl;
|
img.src = currentImageUrl;
|
||||||
}
|
}
|
||||||
}, [visible, imageUrl]);
|
}, [visible, currentImageUrl]);
|
||||||
|
|
||||||
// 重置状态当弹窗关闭时
|
// 重置状态当弹窗关闭时
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,6 +129,7 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
|
position: 'relative' as const, // For arrows
|
||||||
} : {
|
} : {
|
||||||
padding: 0,
|
padding: 0,
|
||||||
height: '66vh',
|
height: '66vh',
|
||||||
@@ -115,6 +137,43 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: '#000',
|
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 (
|
return (
|
||||||
@@ -163,8 +222,71 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
|||||||
<CloseOutlined />
|
<CloseOutlined />
|
||||||
</div>
|
</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 && (
|
{loading && (
|
||||||
<Spin size="large" style={{ color: '#fff' }} />
|
<Spin size="large" style={{ color: '#fff' }} />
|
||||||
)}
|
)}
|
||||||
@@ -178,8 +300,9 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
|||||||
|
|
||||||
{imageLoaded && !loading && !imageError && (
|
{imageLoaded && !loading && !imageError && (
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={currentImageUrl}
|
||||||
alt="预览图片"
|
alt="预览图片"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 挂载到标题栏下方的容器 */
|
/* 抽屉与遮罩不再额外向下偏移,依赖 getContainer 挂载到标题栏下方的容器 */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.layout-mobile .input-drawer-box {
|
||||||
.input-drawer-box {
|
max-width: 100%;
|
||||||
max-width: 100%;
|
padding: 14px; /* 移动端更紧凑 */
|
||||||
padding: 14px; /* 移动端更紧凑 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -11,9 +11,10 @@ type Props = {
|
|||||||
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
|
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
|
||||||
showUpload?: boolean; // 透传到输入面板,控制图片上传按钮
|
showUpload?: boolean; // 透传到输入面板,控制图片上传按钮
|
||||||
mode?: 'input' | 'search' | 'batch-image'; // 透传到输入面板,控制工作模式
|
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 screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const [topbarHeight, setTopbarHeight] = React.useState<number>(56);
|
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-inner">
|
||||||
<div className="input-drawer-title">AI FIND U</div>
|
<div className="input-drawer-title">AI FIND U</div>
|
||||||
<div className="input-drawer-box">
|
<div className="input-drawer-box">
|
||||||
<InputPanel onResult={handleResult} showUpload={showUpload} mode={mode} />
|
<InputPanel onResult={handleResult} showUpload={showUpload} mode={mode} targetModel={targetModel} />
|
||||||
<HintText showUpload={showUpload} />
|
<HintText showUpload={showUpload} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ interface InputPanelProps {
|
|||||||
onResult?: (data: unknown) => void;
|
onResult?: (data: unknown) => void;
|
||||||
showUpload?: boolean; // 是否显示图片上传按钮,默认显示
|
showUpload?: boolean; // 是否显示图片上传按钮,默认显示
|
||||||
mode?: 'input' | 'search' | 'batch-image'; // 输入面板工作模式,新增批量图片模式
|
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 [value, setValue] = React.useState('');
|
||||||
const [fileList, setFileList] = React.useState<UploadFile[]>([]);
|
const [fileList, setFileList] = React.useState<UploadFile[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
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++) {
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
const f = fileList[i].originFileObj as RcFile | undefined;
|
const f = fileList[i].originFileObj as RcFile | undefined;
|
||||||
if (!f) continue;
|
if (!f) continue;
|
||||||
const resp = await postInputImage(f);
|
const resp = await postInputImage(f, targetModel);
|
||||||
if (resp && resp.error_code === 0 && resp.data) {
|
if (resp && resp.error_code === 0 && resp.data) {
|
||||||
results.push(resp.data);
|
results.push(resp.data);
|
||||||
}
|
}
|
||||||
@@ -103,11 +104,11 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('上传图片:', file.name);
|
console.log('上传图片:', file.name);
|
||||||
response = await postInputImage(file);
|
response = await postInputImage(file, targetModel);
|
||||||
} else {
|
} else {
|
||||||
// 只有文本时,调用文本处理 API
|
// 只有文本时,调用文本处理 API
|
||||||
console.log('处理文本:', trimmed);
|
console.log('处理文本:', trimmed);
|
||||||
response = await postInput(trimmed);
|
response = await postInput(trimmed, targetModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('API响应:', response);
|
console.log('API响应:', response);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Layout } from 'antd';
|
import { Layout, Grid } from 'antd';
|
||||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||||
import SiderMenu from './SiderMenu.tsx';
|
import SiderMenu from './SiderMenu.tsx';
|
||||||
import MainContent from './MainContent.tsx';
|
import MainContent from './MainContent.tsx';
|
||||||
|
import CustomRegister from './CustomRegister.tsx';
|
||||||
import ResourceList from './ResourceList.tsx';
|
import ResourceList from './ResourceList.tsx';
|
||||||
|
import CustomList from './CustomList.tsx';
|
||||||
import BatchRegister from './BatchRegister.tsx';
|
import BatchRegister from './BatchRegister.tsx';
|
||||||
import TopBar from './TopBar.tsx';
|
import TopBar from './TopBar.tsx';
|
||||||
import '../styles/base.css';
|
import '../styles/base.css';
|
||||||
@@ -13,23 +15,33 @@ import UserProfile from './UserProfile.tsx';
|
|||||||
const LayoutWrapper: React.FC = () => {
|
const LayoutWrapper: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||||
const [inputOpen, setInputOpen] = 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 isList = location.pathname === '/resources';
|
||||||
const isBatch = location.pathname === '/batch-register';
|
const isBatch = location.pathname === '/batch-register';
|
||||||
const layoutShellRef = React.useRef<HTMLDivElement>(null);
|
const layoutShellRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const pathToKey = (path: string) => {
|
const pathToKey = (path: string) => {
|
||||||
switch (path) {
|
switch (path) {
|
||||||
|
case '/custom-register':
|
||||||
|
return 'custom';
|
||||||
case '/resources':
|
case '/resources':
|
||||||
return 'menu1';
|
return 'menu1';
|
||||||
case '/batch-register':
|
case '/batch-register':
|
||||||
return 'batch';
|
return 'batch';
|
||||||
|
case '/custom-list':
|
||||||
|
return 'custom-list';
|
||||||
case '/menu2':
|
case '/menu2':
|
||||||
return 'menu2';
|
return 'menu2';
|
||||||
default:
|
case '/resource-input':
|
||||||
return 'home';
|
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) => {
|
const handleNavigate = (key: string) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'home':
|
case 'home':
|
||||||
navigate('/');
|
navigate('/resource-input');
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
navigate('/custom-register');
|
||||||
break;
|
break;
|
||||||
case 'batch':
|
case 'batch':
|
||||||
navigate('/batch-register');
|
navigate('/batch-register');
|
||||||
break;
|
break;
|
||||||
|
case 'custom-list':
|
||||||
|
navigate('/custom-list');
|
||||||
|
break;
|
||||||
case 'menu1':
|
case 'menu1':
|
||||||
navigate('/resources');
|
navigate('/resources');
|
||||||
break;
|
break;
|
||||||
@@ -50,7 +68,7 @@ const LayoutWrapper: React.FC = () => {
|
|||||||
navigate('/menu2');
|
navigate('/menu2');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
navigate('/');
|
navigate('/custom-list');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// 切换页面时收起输入抽屉
|
// 切换页面时收起输入抽屉
|
||||||
@@ -58,12 +76,12 @@ const LayoutWrapper: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="layout-wrapper app-root">
|
<Layout className={`layout-wrapper app-root ${isMobile ? 'layout-mobile' : 'layout-desktop'}`}>
|
||||||
{/* 顶部标题栏,位于左侧菜单栏之上 */}
|
{/* 顶部标题栏,位于左侧菜单栏之上 */}
|
||||||
<TopBar
|
<TopBar
|
||||||
onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
|
onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
|
||||||
onToggleInput={() => {if (isHome || isList || isBatch) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
|
onToggleInput={() => {if (isResourceInput || isList || isBatch) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
|
||||||
showInput={isHome || isList || isBatch}
|
showInput={isResourceInput || isList || isBatch}
|
||||||
/>
|
/>
|
||||||
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
|
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
|
||||||
<Layout ref={layoutShellRef} className="layout-shell">
|
<Layout ref={layoutShellRef} className="layout-shell">
|
||||||
@@ -75,8 +93,9 @@ const LayoutWrapper: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/custom-list" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/resource-input"
|
||||||
element={
|
element={
|
||||||
<MainContent
|
<MainContent
|
||||||
inputOpen={inputOpen}
|
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
|
<Route
|
||||||
path="/batch-register"
|
path="/batch-register"
|
||||||
element={
|
element={
|
||||||
@@ -95,6 +124,12 @@ const LayoutWrapper: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/custom-list"
|
||||||
|
element={
|
||||||
|
<CustomList />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/resources"
|
path="/resources"
|
||||||
element={
|
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 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 'react-image-crop/dist/ReactCrop.css';
|
||||||
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop';
|
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
@@ -7,6 +7,7 @@ import type { FormInstance } from 'antd';
|
|||||||
|
|
||||||
import './PeopleForm.css';
|
import './PeopleForm.css';
|
||||||
import KeyValueList from './KeyValueList.tsx'
|
import KeyValueList from './KeyValueList.tsx'
|
||||||
|
import ImagePreview from './ImagePreview.tsx';
|
||||||
import { createPeople, type People, uploadPeopleImage, uploadImage } from '../apis';
|
import { createPeople, type People, uploadPeopleImage, uploadImage } from '../apis';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -211,33 +212,13 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
|
|||||||
const coverUrl = Form.useWatch('cover', form);
|
const coverUrl = Form.useWatch('cover', form);
|
||||||
|
|
||||||
const coverPreviewNode = (
|
const coverPreviewNode = (
|
||||||
<div style={{
|
<ImagePreview
|
||||||
width: '100%',
|
url={coverUrl}
|
||||||
height: '100%',
|
minHeight="264px"
|
||||||
minHeight: '264px', // 预览区固定高度,与表单保持高度对齐
|
maxHeight="264px"
|
||||||
maxHeight: '264px', // 预览区固定高度,与表单保持高度对齐
|
// backgroundColor="#0b0101ff"
|
||||||
display: 'flex',
|
/>
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
border: '1px dashed #d9d9d9',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: '#fafafa',
|
|
||||||
padding: '8px'
|
|
||||||
}}>
|
|
||||||
{coverUrl ? (
|
|
||||||
<Image
|
|
||||||
src={coverUrl}
|
|
||||||
alt="封面预览"
|
|
||||||
style={{ height: '100%', maxHeight: '248px', objectFit: 'contain' }}
|
|
||||||
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2IChhEhKKTMoZ0hTwIQYUXOgjpAhLwzpDbQCBCwh_gswOQDz12JoLPj+7YM..."
|
|
||||||
preview={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div style={{ color: '#999' }}>封面预览</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="people-form">
|
<div className="people-form">
|
||||||
<Form
|
<Form
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { FormInstance } from 'antd';
|
||||||
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||||
import type { FilterDropdownProps } from 'antd/es/table/interface';
|
import type { FilterDropdownProps } from 'antd/es/table/interface';
|
||||||
@@ -9,6 +9,7 @@ import './MainContent.css';
|
|||||||
import InputDrawer from './InputDrawer.tsx';
|
import InputDrawer from './InputDrawer.tsx';
|
||||||
import ImageModal from './ImageModal.tsx';
|
import ImageModal from './ImageModal.tsx';
|
||||||
import PeopleForm from './PeopleForm.tsx';
|
import PeopleForm from './PeopleForm.tsx';
|
||||||
|
import NumberRangeFilterDropdown from './NumberRangeFilterDropdown';
|
||||||
import { getPeoples } from '../apis';
|
import { getPeoples } from '../apis';
|
||||||
import type { People } from '../apis';
|
import type { People } from '../apis';
|
||||||
import { addOrUpdateRemark, deletePeople, deleteRemark, updatePeople } from '../apis/people';
|
import { addOrUpdateRemark, deletePeople, deleteRemark, updatePeople } from '../apis/people';
|
||||||
@@ -463,23 +464,7 @@ async function fetchResources(): Promise<Resource[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 数字范围筛选下拉
|
// 数字范围筛选下拉
|
||||||
function NumberRangeFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) {
|
// 已替换为引入的 NumberRangeFilterDropdown 组件
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): ColumnType<Resource> {
|
function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): ColumnType<Resource> {
|
||||||
return {
|
return {
|
||||||
@@ -490,15 +475,25 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
|
|||||||
const bv = b[dataIndex] as number | undefined;
|
const bv = b[dataIndex] as number | undefined;
|
||||||
return Number(av ?? 0) - Number(bv ?? 0);
|
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) => {
|
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 min = minStr ? Number(minStr) : undefined;
|
||||||
const max = maxStr ? Number(maxStr) : undefined;
|
const max = maxStr ? Number(maxStr) : undefined;
|
||||||
const val = record[dataIndex] as number | 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;
|
return true;
|
||||||
},
|
},
|
||||||
} as ColumnType<Resource>;
|
} as ColumnType<Resource>;
|
||||||
@@ -1083,7 +1078,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
{/* 图片预览弹窗 */}
|
{/* 图片预览弹窗 */}
|
||||||
<ImageModal
|
<ImageModal
|
||||||
visible={imageModalVisible}
|
visible={imageModalVisible}
|
||||||
imageUrl={currentImageUrl}
|
images={currentImageUrl ? [currentImageUrl] : []}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setImageModalVisible(false);
|
setImageModalVisible(false);
|
||||||
setCurrentImageUrl('');
|
setCurrentImageUrl('');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import RegisterModal from './RegisterModal';
|
|||||||
import { useAuth } from '../contexts/useAuth';
|
import { useAuth } from '../contexts/useAuth';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
|
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 './SiderMenu.css';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -50,6 +50,8 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
|||||||
}, [selectedKey]);
|
}, [selectedKey]);
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
|
{ key: 'custom-list', label: '客户列表', icon: <TeamOutlined /> },
|
||||||
|
{ key: 'custom', label: '客户录入', icon: <UserOutlined /> },
|
||||||
{ key: 'home', label: '录入资源', icon: <FormOutlined /> },
|
{ key: 'home', label: '录入资源', icon: <FormOutlined /> },
|
||||||
{ key: 'batch', label: '批量录入', icon: <CopyOutlined /> },
|
{ key: 'batch', label: '批量录入', icon: <CopyOutlined /> },
|
||||||
{ key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },
|
{ key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },
|
||||||
|
|||||||
@@ -41,6 +41,4 @@
|
|||||||
}
|
}
|
||||||
.icon-btn:hover { background: rgba(16,185,129,0.35); }
|
.icon-btn:hover { background: rgba(16,185,129,0.35); }
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
.layout-desktop .topbar { grid-template-columns: 56px 1fr 56px; }
|
||||||
.topbar { grid-template-columns: 56px 1fr 56px; }
|
|
||||||
}
|
|
||||||
@@ -63,7 +63,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 小屏优化:输入区域内边距更紧凑 */
|
/* 小屏优化:输入区域内边距更紧凑 */
|
||||||
@media (max-width: 768px) {
|
.layout-mobile .content-body { padding: 16px; border-radius: 0; }
|
||||||
.content-body { padding: 16px; border-radius: 0; }
|
.layout-mobile .input-panel-wrapper { padding: 12px 12px 16px 12px; }
|
||||||
.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