Compare commits
5 Commits
83f8091e15
...
fee01abb60
| Author | SHA1 | Date | |
|---|---|---|---|
| fee01abb60 | |||
| cb59b3693d | |||
| 0256f0cf22 | |||
| b1fb054714 | |||
| c923244a68 |
5
.env
5
.env
@@ -1,2 +1,5 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8099
|
||||
# VITE_API_BASE_URL=http://localhost:8099/api
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_HTTPS_KEY_PATH=./certs/localhost-key.pem
|
||||
VITE_HTTPS_CERT_PATH=./certs/localhost.pem
|
||||
@@ -1,2 +1,2 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443
|
||||
VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443/api
|
||||
@@ -15,6 +15,7 @@
|
||||
"antd": "^5.27.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import LayoutWrapper from './components/LayoutWrapper';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
function App() {
|
||||
return <LayoutWrapper />;
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<LayoutWrapper />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// API 配置
|
||||
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8099',
|
||||
BASE_URL: import.meta.env.VITE_API_BASE_URL,
|
||||
TIMEOUT: 10000,
|
||||
HEADERS: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -10,12 +10,28 @@ export const API_CONFIG = {
|
||||
|
||||
// API 端点
|
||||
export const API_ENDPOINTS = {
|
||||
INPUT: '/recognition/input',
|
||||
INPUT_IMAGE: '/recognition/image',
|
||||
RECOGNITION_INPUT: (model: 'people' | 'custom') => `/recognition/${model}/input`,
|
||||
RECOGNITION_IMAGE: (model: 'people' | 'custom') => `/recognition/${model}/image`,
|
||||
// 人员列表查询仍为 /peoples
|
||||
PEOPLES: '/peoples',
|
||||
// 新增单个资源路径 /people
|
||||
PEOPLE: '/people',
|
||||
PEOPLE_BY_ID: (id: string) => `/people/${id}`,
|
||||
PEOPLE_IMAGE_BY_ID: (id: string) => `/people/${id}/image`,
|
||||
PEOPLE_REMARK_BY_ID: (id: string) => `/people/${id}/remark`,
|
||||
} as const;
|
||||
UPLOAD_IMAGE: '/upload/image',
|
||||
// 用户相关
|
||||
SEND_CODE: '/user/send_code',
|
||||
REGISTER: '/user',
|
||||
LOGIN: '/user/login',
|
||||
LOGOUT: '/user/me/login',
|
||||
ME: '/user/me',
|
||||
AVATAR: '/user/me/avatar',
|
||||
DELETE_USER: '/user/me',
|
||||
UPDATE_PHONE: '/user/me/phone',
|
||||
UPDATE_EMAIL: '/user/me/email',
|
||||
// 客户相关
|
||||
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');
|
||||
}
|
||||
@@ -9,11 +9,15 @@ export * from './types';
|
||||
export * from './input';
|
||||
export * from './upload';
|
||||
export * from './people';
|
||||
export * from './user';
|
||||
export * from './custom';
|
||||
|
||||
// 默认导出所有API函数
|
||||
import * as inputApi from './input';
|
||||
import * as uploadApi from './upload';
|
||||
import * as peopleApi from './people';
|
||||
import * as userApi from './user';
|
||||
import * as customApi from './custom';
|
||||
|
||||
export const api = {
|
||||
// 文本输入相关
|
||||
@@ -24,6 +28,12 @@ export const api = {
|
||||
|
||||
// 人员管理相关
|
||||
people: peopleApi,
|
||||
|
||||
// 用户管理相关
|
||||
user: userApi,
|
||||
|
||||
// 客户管理相关
|
||||
custom: customApi,
|
||||
};
|
||||
|
||||
export default api;
|
||||
export default api;
|
||||
|
||||
@@ -9,10 +9,10 @@ import type { PostInputRequest, ApiResponse } from './types';
|
||||
* @param text 输入的文本内容
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function postInput(text: string): Promise<ApiResponse> {
|
||||
export async function postInput(text: string, model: 'people' | 'custom' = 'people'): Promise<ApiResponse> {
|
||||
const requestData: PostInputRequest = { text };
|
||||
// 为 postInput 设置 30 秒超时时间
|
||||
return post<ApiResponse>(API_ENDPOINTS.INPUT, requestData, { timeout: 120000 });
|
||||
return post<ApiResponse>(API_ENDPOINTS.RECOGNITION_INPUT(model), requestData, { timeout: 120000 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ export async function postInput(text: string): Promise<ApiResponse> {
|
||||
* @param data 包含文本的请求对象
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function postInputData(data: PostInputRequest): Promise<ApiResponse> {
|
||||
export async function postInputData(data: PostInputRequest, model: 'people' | 'custom' = 'people'): Promise<ApiResponse> {
|
||||
// 为 postInputData 设置 30 秒超时时间
|
||||
return post<ApiResponse>(API_ENDPOINTS.INPUT, data, { timeout: 120000 });
|
||||
return post<ApiResponse>(API_ENDPOINTS.RECOGNITION_INPUT(model), data, { timeout: 120000 });
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
// 人员管理相关 API
|
||||
|
||||
import { get, post, del, put } from './request';
|
||||
import { get, post, del, put, upload } from './request';
|
||||
import { API_ENDPOINTS } from './config';
|
||||
import type {
|
||||
PostPeopleRequest,
|
||||
GetPeoplesParams,
|
||||
People,
|
||||
ApiResponse,
|
||||
PaginatedResponse
|
||||
PaginatedResponse
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -140,6 +140,25 @@ export async function deleteRemark(peopleId: string): Promise<ApiResponse> {
|
||||
return del<ApiResponse>(API_ENDPOINTS.PEOPLE_REMARK_BY_ID(peopleId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传人员照片
|
||||
* @param peopleId 人员ID
|
||||
* @param file 照片文件
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function uploadPeopleImage(peopleId: string, file: File): Promise<ApiResponse<string>> {
|
||||
return upload<ApiResponse<string>>(API_ENDPOINTS.PEOPLE_IMAGE_BY_ID(peopleId), file, 'image');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除人员照片
|
||||
* @param peopleId 人员ID
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function deletePeopleImage(peopleId: string): Promise<ApiResponse> {
|
||||
return del<ApiResponse>(API_ENDPOINTS.PEOPLE_IMAGE_BY_ID(peopleId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建人员信息
|
||||
* @param peopleList 人员信息数组
|
||||
|
||||
@@ -6,16 +6,16 @@ import { API_CONFIG } from './config';
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
body?: unknown;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// 自定义错误类
|
||||
export class ApiError extends Error {
|
||||
status?: number;
|
||||
data?: any;
|
||||
data?: unknown;
|
||||
|
||||
constructor(message: string, status?: number, data?: any) {
|
||||
constructor(message: string, status?: number, data?: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
@@ -24,7 +24,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
// 基础请求函数
|
||||
export async function request<T = any>(
|
||||
export async function request<T = unknown>(
|
||||
url: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
@@ -60,24 +60,26 @@ export async function request<T = any>(
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: requestBody,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: any;
|
||||
let errorData: unknown;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = { message: response.statusText };
|
||||
}
|
||||
|
||||
throw new ApiError(
|
||||
errorData.message || `HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
let messageText = `HTTP ${response.status}: ${response.statusText}`;
|
||||
if (errorData && typeof errorData === 'object') {
|
||||
const maybe = errorData as { message?: string };
|
||||
messageText = maybe.message ?? messageText;
|
||||
}
|
||||
throw new ApiError(messageText, response.status, errorData);
|
||||
}
|
||||
|
||||
// 检查响应是否有内容
|
||||
@@ -107,7 +109,8 @@ export async function request<T = any>(
|
||||
}
|
||||
|
||||
// GET 请求
|
||||
export function get<T = any>(url: string, params?: Record<string, any>): Promise<T> {
|
||||
type QueryParamValue = string | number | boolean | null | undefined;
|
||||
export function get<T = unknown>(url: string, params?: Record<string, QueryParamValue>): Promise<T> {
|
||||
let fullUrl = url;
|
||||
|
||||
if (params) {
|
||||
@@ -128,7 +131,7 @@ export function get<T = any>(url: string, params?: Record<string, any>): Promise
|
||||
}
|
||||
|
||||
// POST 请求
|
||||
export function post<T = any>(url: string, data?: any, options?: Partial<RequestOptions>): Promise<T> {
|
||||
export function post<T = unknown>(url: string, data?: unknown, options?: Partial<RequestOptions>): Promise<T> {
|
||||
return request<T>(url, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
@@ -137,7 +140,7 @@ export function post<T = any>(url: string, data?: any, options?: Partial<Request
|
||||
}
|
||||
|
||||
// PUT 请求
|
||||
export function put<T = any>(url: string, data?: any): Promise<T> {
|
||||
export function put<T = unknown>(url: string, data?: unknown): Promise<T> {
|
||||
return request<T>(url, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
@@ -145,12 +148,12 @@ export function put<T = any>(url: string, data?: any): Promise<T> {
|
||||
}
|
||||
|
||||
// DELETE 请求
|
||||
export function del<T = any>(url: string): Promise<T> {
|
||||
export function del<T = unknown>(url: string): Promise<T> {
|
||||
return request<T>(url, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// 文件上传请求
|
||||
export function upload<T = any>(url: string, file: File, fieldName = 'file', options?: Partial<RequestOptions>): Promise<T> {
|
||||
export function upload<T = unknown>(url: string, file: File, fieldName = 'file', options?: Partial<RequestOptions>): Promise<T> {
|
||||
const formData = new FormData();
|
||||
formData.append(fieldName, file);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// API 请求和响应类型定义
|
||||
|
||||
// 基础响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data?: T;
|
||||
error_code: number;
|
||||
error_info?: string;
|
||||
@@ -25,7 +25,12 @@ export interface PostInputRequest {
|
||||
|
||||
// 人员信息请求类型
|
||||
export interface PostPeopleRequest {
|
||||
people: Record<string, any>;
|
||||
people: People;
|
||||
}
|
||||
|
||||
// 客户信息请求类型
|
||||
export interface PostCustomRequest {
|
||||
custom: Custom;
|
||||
}
|
||||
|
||||
// 人员查询参数类型
|
||||
@@ -39,6 +44,7 @@ export interface GetPeoplesParams {
|
||||
offset?: number;
|
||||
search?: string;
|
||||
top_k?: number;
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
// 人员信息类型
|
||||
@@ -51,8 +57,53 @@ export interface People {
|
||||
height?: number;
|
||||
marital_status?: string;
|
||||
created_at?: number;
|
||||
[key: string]: any;
|
||||
match_requirement?: string;
|
||||
cover?: string;
|
||||
introduction?: Record<string, string>;
|
||||
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]
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
@@ -61,4 +112,53 @@ export interface PaginatedResponse<T> {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
}
|
||||
|
||||
// 用户相关类型
|
||||
|
||||
export interface SendCodeRequest {
|
||||
target_type: 'phone' | 'email';
|
||||
target: string;
|
||||
scene: 'register' | 'update';
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
nickname?: string;
|
||||
avatar_link?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
created_at: string;
|
||||
nickname: string;
|
||||
avatar_link?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
nickname?: string;
|
||||
avatar_link?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePhoneRequest {
|
||||
phone: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface UpdateEmailRequest {
|
||||
email: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import type { ApiResponse } from './types';
|
||||
* @param file 要上传的图片文件
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function postInputImage(file: File): Promise<ApiResponse> {
|
||||
export async function postInputImage(file: File, model: 'people' | 'custom' = 'people'): Promise<ApiResponse> {
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('只能上传图片文件');
|
||||
}
|
||||
|
||||
return upload<ApiResponse>(API_ENDPOINTS.INPUT_IMAGE, file, 'image', { timeout: 120000 });
|
||||
return upload<ApiResponse>(API_ENDPOINTS.RECOGNITION_IMAGE(model), file, 'image', { timeout: 120000 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +26,8 @@ export async function postInputImage(file: File): Promise<ApiResponse> {
|
||||
*/
|
||||
export async function postInputImageWithProgress(
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
onProgress?: (progress: number) => void,
|
||||
model: 'people' | 'custom' = 'people'
|
||||
): Promise<ApiResponse> {
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
@@ -56,8 +57,8 @@ export async function postInputImageWithProgress(
|
||||
try {
|
||||
const response = xhr.responseText ? JSON.parse(xhr.responseText) : {};
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
resolve({error_code: 1, error_info: '解析响应失败'});
|
||||
} catch {
|
||||
resolve({ error_code: 1, error_info: '解析响应失败' });
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
|
||||
@@ -75,7 +76,7 @@ export async function postInputImageWithProgress(
|
||||
});
|
||||
|
||||
// 发送请求
|
||||
xhr.open('POST', `http://127.0.0.1:8099${API_ENDPOINTS.INPUT_IMAGE}`);
|
||||
xhr.open('POST', `http://127.0.0.1:8099${API_ENDPOINTS.RECOGNITION_IMAGE(model)}`);
|
||||
xhr.timeout = 120000; // 30秒超时
|
||||
xhr.send(formData);
|
||||
});
|
||||
@@ -106,4 +107,8 @@ export function validateImageFile(file: File, maxSize = 10 * 1024 * 1024): { val
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export async function uploadImage(file: File): Promise<ApiResponse<string>> {
|
||||
return upload<ApiResponse<string>>(API_ENDPOINTS.UPLOAD_IMAGE, file, 'image');
|
||||
}
|
||||
81
src/apis/user.ts
Normal file
81
src/apis/user.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { get, post, put, del } from './request';
|
||||
import { API_ENDPOINTS } from './config';
|
||||
import type { SendCodeRequest, RegisterRequest, LoginRequest, User, ApiResponse, UpdateUserRequest, UpdatePhoneRequest, UpdateEmailRequest } from './types';
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* @param phone 手机号
|
||||
* @param usage 用途
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function sendCode(data: SendCodeRequest): Promise<ApiResponse> {
|
||||
return post<ApiResponse>(API_ENDPOINTS.SEND_CODE, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* @param data 注册信息
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function register(data: RegisterRequest): Promise<ApiResponse> {
|
||||
return post<ApiResponse>(API_ENDPOINTS.REGISTER, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param data 登录信息
|
||||
* @returns Promise<ApiResponse<{token: string}>>
|
||||
*/
|
||||
export async function login(data: LoginRequest): Promise<ApiResponse<{token: string}>> {
|
||||
return post<ApiResponse<{token: string}>>(API_ENDPOINTS.LOGIN, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function logout(): Promise<ApiResponse> {
|
||||
return del<ApiResponse>(API_ENDPOINTS.LOGOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* @returns Promise<ApiResponse<User>>
|
||||
*/
|
||||
export async function getMe(): Promise<ApiResponse<User>> {
|
||||
return get<ApiResponse<User>>(API_ENDPOINTS.ME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
* @param data 要更新的用户信息
|
||||
* @returns Promise<ApiResponse<User>>
|
||||
*/
|
||||
export async function updateMe(data: UpdateUserRequest): Promise<ApiResponse<User>> {
|
||||
return put<ApiResponse<User>>(API_ENDPOINTS.ME, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注销
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function deleteUser(): Promise<ApiResponse> {
|
||||
return del<ApiResponse>(API_ENDPOINTS.DELETE_USER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户头像
|
||||
* @param data 要更新的用户头像
|
||||
* @returns Promise<ApiResponse<User>>
|
||||
*/
|
||||
export async function uploadAvatar(data: FormData): Promise<ApiResponse<User>> {
|
||||
return put<ApiResponse<User>>(API_ENDPOINTS.AVATAR, data);
|
||||
}
|
||||
|
||||
export async function updatePhone(data: UpdatePhoneRequest): Promise<ApiResponse<User>> {
|
||||
return put<ApiResponse<User>>(API_ENDPOINTS.UPDATE_PHONE, data);
|
||||
}
|
||||
|
||||
export async function updateEmail(data: UpdateEmailRequest): Promise<ApiResponse<User>> {
|
||||
return put<ApiResponse<User>>(API_ENDPOINTS.UPDATE_EMAIL, data);
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import PeopleForm from './PeopleForm.tsx'
|
||||
import InputDrawer from './InputDrawer.tsx'
|
||||
import { createPeoplesBatch, type People } from '../apis'
|
||||
|
||||
const { Panel } = Collapse as any
|
||||
const Panel = Collapse.Panel
|
||||
const { Content } = Layout
|
||||
|
||||
type FormItem = { id: string; initialData?: any }
|
||||
type FormItem = { id: string; initialData?: Partial<People> }
|
||||
|
||||
type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null }
|
||||
|
||||
@@ -29,7 +29,18 @@ const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, conta
|
||||
delete instancesRef.current[id]
|
||||
}
|
||||
|
||||
const buildPeople = (values: any, common: any): People => {
|
||||
type FormValues = {
|
||||
name: string;
|
||||
contact?: string;
|
||||
gender: string;
|
||||
age: number;
|
||||
height?: number;
|
||||
marital_status?: string;
|
||||
introduction?: Record<string, string>;
|
||||
match_requirement?: string;
|
||||
cover?: string;
|
||||
}
|
||||
const buildPeople = (values: FormValues, common: { contact?: string }): People => {
|
||||
return {
|
||||
name: values.name,
|
||||
contact: values.contact || common.contact || undefined,
|
||||
@@ -43,9 +54,9 @@ const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, conta
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputResult = (list: any) => {
|
||||
const handleInputResult = (list: unknown) => {
|
||||
const arr = Array.isArray(list) ? list : [list]
|
||||
const next: FormItem[] = arr.map((data: any) => ({ id: `${Date.now()}-${Math.random()}`, initialData: data }))
|
||||
const next: FormItem[] = arr.map((data) => ({ id: `${Date.now()}-${Math.random()}`, initialData: data as Partial<People> }))
|
||||
setItems((prev) => [...prev, ...next])
|
||||
}
|
||||
|
||||
@@ -61,12 +72,12 @@ const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, conta
|
||||
message.error('表单未就绪')
|
||||
return
|
||||
}
|
||||
const allValues: any[] = []
|
||||
const allValues: FormValues[] = []
|
||||
for (const f of forms) {
|
||||
try {
|
||||
const v = await f.validateFields()
|
||||
allValues.push(v)
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setLoading(false)
|
||||
message.error('请完善全部表单后再提交')
|
||||
return
|
||||
@@ -87,7 +98,7 @@ const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, conta
|
||||
setItems([{ id: `${Date.now()}-${Math.random()}` }])
|
||||
commonForm.resetFields()
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
message.error('提交失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -154,6 +165,7 @@ const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, conta
|
||||
containerEl={containerEl}
|
||||
showUpload
|
||||
mode={'batch-image'}
|
||||
targetModel="people"
|
||||
/>
|
||||
</Content>
|
||||
)
|
||||
|
||||
40
src/components/CustomForm.css
Normal file
40
src/components/CustomForm.css
Normal file
@@ -0,0 +1,40 @@
|
||||
/* 客户信息录入表单样式 */
|
||||
.custom-form {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.custom-form .ant-form-item-label > label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.custom-form .ant-input,
|
||||
.custom-form .ant-input-affix-wrapper,
|
||||
.custom-form .ant-select-selector,
|
||||
.custom-form .ant-input-number,
|
||||
.custom-form .ant-input-number-input {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.custom-form .ant-select-selection-item,
|
||||
.custom-form .ant-select-selection-placeholder {
|
||||
color: var(--placeholder);
|
||||
}
|
||||
.custom-form .ant-select-selection-item { color: var(--text-primary); }
|
||||
|
||||
/* 输入占位与聚焦态 */
|
||||
.custom-form .ant-input::placeholder { color: var(--placeholder); }
|
||||
.custom-form .ant-input-number-input::placeholder { color: var(--placeholder); }
|
||||
.custom-form .ant-input:focus,
|
||||
.custom-form .ant-input-affix-wrapper-focused,
|
||||
.custom-form .ant-select-focused .ant-select-selector,
|
||||
.custom-form .ant-input-number-focused {
|
||||
border-color: var(--color-primary-600) !important;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
377
src/components/CustomForm.tsx
Normal file
377
src/components/CustomForm.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Input, Select, InputNumber, Button, message, Row, Col, Radio, Typography, Grid } from 'antd';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
import type { FormInstance } from 'antd';
|
||||
|
||||
import './CustomForm.css';
|
||||
import KeyValueList from './KeyValueList.tsx';
|
||||
import ImageInputGroup from './ImageInputGroup.tsx';
|
||||
import { createCustom, updateCustom, type Custom } from '../apis';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
interface CustomFormProps {
|
||||
initialData?: Partial<Custom>;
|
||||
hideSubmitButton?: boolean;
|
||||
onFormReady?: (form: FormInstance) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const CustomForm: React.FC<CustomFormProps> = ({ initialData, hideSubmitButton = false, onFormReady, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
// 需要处理一些数据转换,例如 birth -> age (如果后端给的是 birth year)
|
||||
// 这里假设 initialData 里已经处理好了,或者先直接透传
|
||||
const formData = { ...initialData };
|
||||
// 如果有 birth,转换为 age 显示
|
||||
if (formData.birth) {
|
||||
// 如果 birth 小于 100,假设直接存的年龄,直接显示
|
||||
if (formData.birth < 100) {
|
||||
// @ts-expect-error: 临时给 form 设置 age 字段
|
||||
formData.age = formData.birth;
|
||||
} else {
|
||||
// 否则假设是年份,计算年龄
|
||||
const currentYear = new Date().getFullYear();
|
||||
// @ts-expect-error: 临时给 form 设置 age 字段
|
||||
formData.age = currentYear - formData.birth;
|
||||
}
|
||||
}
|
||||
// images 数组处理,确保是字符串数组
|
||||
if (formData.images && Array.isArray(formData.images)) {
|
||||
if (formData.images.length === 0) {
|
||||
formData.images = [''];
|
||||
}
|
||||
} else {
|
||||
formData.images = [''];
|
||||
}
|
||||
form.setFieldsValue(formData);
|
||||
} else {
|
||||
// 初始化空状态
|
||||
form.setFieldsValue({
|
||||
images: ['']
|
||||
});
|
||||
}
|
||||
}, [initialData, form]);
|
||||
|
||||
useEffect(() => {
|
||||
onFormReady?.(form);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onFinish = async (values: Custom & { age?: number }) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const birth = currentYear - (values.age || 0);
|
||||
|
||||
const customData: Custom = {
|
||||
name: values.name,
|
||||
gender: values.gender,
|
||||
birth: birth,
|
||||
phone: values.phone || undefined,
|
||||
email: values.email || undefined,
|
||||
|
||||
height: values.height || undefined,
|
||||
weight: values.weight || undefined,
|
||||
images: values.images?.filter((url: string) => !!url) || [],
|
||||
scores: values.scores || undefined,
|
||||
|
||||
degree: values.degree || undefined,
|
||||
academy: values.academy || undefined,
|
||||
occupation: values.occupation || undefined,
|
||||
income: values.income || undefined,
|
||||
assets: values.assets || undefined,
|
||||
current_assets: values.current_assets || undefined,
|
||||
house: values.house || undefined,
|
||||
car: values.car || undefined,
|
||||
|
||||
registered_city: values.registered_city || undefined,
|
||||
live_city: values.live_city || undefined,
|
||||
native_place: values.native_place || undefined,
|
||||
original_family: values.original_family || undefined,
|
||||
is_single_child: values.is_single_child,
|
||||
|
||||
match_requirement: values.match_requirement || undefined,
|
||||
introductions: values.introductions || {},
|
||||
|
||||
custom_level: values.custom_level || '普通',
|
||||
comments: values.comments || {},
|
||||
};
|
||||
|
||||
console.log('提交客户数据:', customData);
|
||||
|
||||
let response;
|
||||
if (initialData?.id) {
|
||||
response = await updateCustom(initialData.id, customData);
|
||||
} else {
|
||||
response = await createCustom(customData);
|
||||
}
|
||||
|
||||
if (response.error_code === 0) {
|
||||
message.success(initialData?.id ? '客户信息已更新!' : '客户信息已成功提交到后端!');
|
||||
if (!initialData?.id) {
|
||||
form.resetFields();
|
||||
}
|
||||
onSuccess?.();
|
||||
} else {
|
||||
message.error(response.error_info || '提交失败,请重试');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; message?: string };
|
||||
if (err.status === 422) {
|
||||
message.error('表单数据格式有误,请检查输入内容');
|
||||
} else if ((err.status ?? 0) >= 500) {
|
||||
message.error('服务器错误,请稍后重试');
|
||||
} else {
|
||||
message.error(err.message || '提交失败,请重试');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const scoresNode = (
|
||||
<Form.Item name="scores" label="外貌评分">
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} placeholder="分" />
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const heightNode = (
|
||||
<Form.Item name="height" label="身高(cm)">
|
||||
<InputNumber min={0} max={250} style={{ width: '100%' }} placeholder="cm" />
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const weightNode = (
|
||||
<Form.Item name="weight" label="体重(kg)">
|
||||
<InputNumber min={0} max={200} style={{ width: '100%' }} placeholder="kg" />
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const degreeNode = (
|
||||
<Form.Item name="degree" label="学历">
|
||||
<Input placeholder="如:本科" />
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const academyNode = (
|
||||
<Form.Item name="academy" label="院校">
|
||||
<Input placeholder="如:清华大学" />
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="custom-form">
|
||||
{!hideSubmitButton && (
|
||||
<>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>客户录入</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>录入客户基本信息</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
onFinish={onFinish}
|
||||
>
|
||||
{/* Row 1: 姓名、性别、年龄 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input placeholder="请输入姓名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
|
||||
<Select placeholder="请选择性别" options={[{ label: '男', value: '男' }, { label: '女', value: '女' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="age" label="年龄" rules={[{ required: true, message: '请输入年龄' }]}>
|
||||
<InputNumber min={0} max={120} style={{ width: '100%' }} placeholder="请输入年龄" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Row 2: 电话、邮箱 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input placeholder="请输入电话" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Row 3: Image Group */}
|
||||
<Form.Item name="images" label="照片图片">
|
||||
<ImageInputGroup customId={initialData?.id} />
|
||||
</Form.Item>
|
||||
|
||||
{/* Row 4: Physical Info */}
|
||||
{isMobile ? (
|
||||
// Mobile Layout
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{/* 1. Scores (Full width) */}
|
||||
{scoresNode}
|
||||
|
||||
{/* 2. Height & Weight */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={12}>{heightNode}</Col>
|
||||
<Col xs={12}>{weightNode}</Col>
|
||||
</Row>
|
||||
|
||||
{/* 3. Degree & Academy */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={12}>{degreeNode}</Col>
|
||||
<Col xs={12}>{academyNode}</Col>
|
||||
</Row>
|
||||
</div>
|
||||
) : (
|
||||
// PC Layout
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col span={8}>{scoresNode}</Col>
|
||||
<Col span={8}>{heightNode}</Col>
|
||||
<Col span={8}>{weightNode}</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Row 5a: 学历、院校 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>{degreeNode}</Col>
|
||||
<Col xs={24} md={12}>{academyNode}</Col>
|
||||
</Row>
|
||||
|
||||
{/* Row 5b: 职业、收入 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="occupation" label="职业">
|
||||
<Input placeholder="如:工程师" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="income" label="收入(万/年)">
|
||||
<InputNumber style={{ width: '100%' }} placeholder="万" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Row 5c: 资产、流动资产 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="assets" label="资产(万)">
|
||||
<InputNumber style={{ width: '100%' }} placeholder="万" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="current_assets" label="流动资产(万)">
|
||||
<InputNumber style={{ width: '100%' }} placeholder="万" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Row 5d: 房产情况、汽车情况 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="house" label="房产情况">
|
||||
<Select placeholder="请选择房产情况" options={['无房', '有房有贷', '有房无贷'].map(v => ({ label: v, value: v }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="car" label="汽车情况">
|
||||
<Select placeholder="请选择汽车情况" options={['无车', '有车有贷', '有车无贷'].map(v => ({ label: v, value: v }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Row 6: 户口城市、常住城市、籍贯 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="registered_city" label="户口城市">
|
||||
<Input placeholder="如:北京" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="live_city" label="常住城市">
|
||||
<Input placeholder="如:上海" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="native_place" label="籍贯">
|
||||
<Input placeholder="如:江苏" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Row 7: 是否独生子 */}
|
||||
<Form.Item name="is_single_child" label="是否独生子">
|
||||
<Radio.Group>
|
||||
<Radio value={true}>是</Radio>
|
||||
<Radio value={false}>否</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{/* Row 8: 原生家庭 */}
|
||||
<Form.Item name="original_family" label="原生家庭">
|
||||
<TextArea autoSize={{ minRows: 3, maxRows: 5 }} placeholder="请输入原生家庭情况" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Row 9: 择偶要求 */}
|
||||
<Form.Item name="match_requirement" label="择偶要求">
|
||||
<TextArea autoSize={{ minRows: 3, maxRows: 5 }} placeholder="请输入择偶要求" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Row 10: 其他信息 (KeyValueList) */}
|
||||
<Form.Item name="introductions" label="其他信息">
|
||||
<KeyValueList />
|
||||
</Form.Item>
|
||||
|
||||
{/* Row 11: 客户等级、是否公开 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="custom_level" label="客户等级">
|
||||
<Select options={['普通', 'VIP', '高级VIP'].map(v => ({ label: v, value: v }))} placeholder="请选择" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="is_public" label="是否公开">
|
||||
<Radio.Group>
|
||||
<Radio value={true}>是</Radio>
|
||||
<Radio value={false}>否</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Row 13: 备注评论 (KeyValueList) */}
|
||||
<Form.Item name="comments" label="备注评论">
|
||||
<KeyValueList />
|
||||
</Form.Item>
|
||||
|
||||
{!hideSubmitButton && (
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block size="large">
|
||||
{initialData?.id ? '保存修改' : '提交客户信息'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomForm;
|
||||
613
src/components/CustomList.tsx
Normal file
613
src/components/CustomList.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Layout, Typography, Table, Grid, Button, Space, message, Descriptions, Tag, Modal, Popconfirm, Dropdown } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { FormInstance } from 'antd';
|
||||
import {
|
||||
ManOutlined,
|
||||
WomanOutlined,
|
||||
PictureOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
UserOutlined,
|
||||
EllipsisOutlined,
|
||||
ShareAltOutlined,
|
||||
DownloadOutlined,
|
||||
CopyOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { getCustoms, deleteCustom } from '../apis/custom';
|
||||
import type { Custom } from '../apis/types';
|
||||
import './MainContent.css';
|
||||
import ImageModal from './ImageModal.tsx';
|
||||
import NumberRangeFilterDropdown from './NumberRangeFilterDropdown';
|
||||
import CustomForm from './CustomForm.tsx';
|
||||
import ImageSelectorModal from './ImageSelectorModal';
|
||||
import ImageCropperModal from './ImageCropperModal';
|
||||
import { generateShareImage, type ShareData } from '../utils/shareImageGenerator';
|
||||
import { useAuth } from '../contexts/useAuth';
|
||||
|
||||
const { Content } = Layout;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
// 扩展 Custom 类型以确保 id 存在
|
||||
type CustomResource = Custom & { id: string };
|
||||
|
||||
const CustomList: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const screens = useBreakpoint();
|
||||
const [data, setData] = useState<CustomResource[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 图片弹窗状态
|
||||
const [imageModalVisible, setImageModalVisible] = useState(false);
|
||||
const [currentImages, setCurrentImages] = useState<string[]>([]);
|
||||
const [initialImageIndex, setInitialImageIndex] = useState(0);
|
||||
|
||||
// 编辑弹窗状态
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<CustomResource | null>(null);
|
||||
const editFormRef = useRef<FormInstance | null>(null);
|
||||
|
||||
// 分享相关状态
|
||||
const [shareSelectorVisible, setShareSelectorVisible] = useState(false);
|
||||
const [shareCropperVisible, setShareCropperVisible] = useState(false);
|
||||
const [shareResultVisible, setShareResultVisible] = useState(false);
|
||||
const [selectedShareImage, setSelectedShareImage] = useState<string>('');
|
||||
const [generatedShareImage, setGeneratedShareImage] = useState<string>('');
|
||||
const [sharingCustom, setSharingCustom] = useState<CustomResource | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getCustoms({ limit: 1000, offset: 0 });
|
||||
if (response.error_code === 0 && response.data) {
|
||||
// 确保 items 存在且 id 存在
|
||||
const list = (response.data.items || []).map(item => ({
|
||||
...item,
|
||||
id: item.id || `custom-${Date.now()}-${Math.random()}`
|
||||
}));
|
||||
setData(list);
|
||||
} else {
|
||||
message.error(response.error_info || '获取客户列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取客户列表失败:', error);
|
||||
message.error('获取客户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// 删除客户
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const response = await deleteCustom(id);
|
||||
if (response.error_code === 0) {
|
||||
message.success('删除成功');
|
||||
fetchData(); // 重新加载数据
|
||||
} else {
|
||||
message.error(response.error_info || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 计算年龄
|
||||
const calculateAge = (birth?: number) => {
|
||||
if (!birth) return '未知';
|
||||
// 如果 birth 小于 100,假设直接存的年龄
|
||||
if (birth < 100) return birth;
|
||||
// 否则假设是年份
|
||||
const currentYear = new Date().getFullYear();
|
||||
return currentYear - birth;
|
||||
};
|
||||
|
||||
// 通用数字范围筛选逻辑生成器
|
||||
const createNumberRangeOnFilter = (getValue: (record: CustomResource) => number) => {
|
||||
return (value: React.Key | boolean, record: CustomResource) => {
|
||||
const [min, max] = (value as string).split(',').map(Number);
|
||||
const val = getValue(record);
|
||||
|
||||
if (typeof val !== 'number') return false;
|
||||
|
||||
if (!isNaN(min) && !isNaN(max)) {
|
||||
return val >= min && val <= max;
|
||||
}
|
||||
if (!isNaN(min)) {
|
||||
return val >= min;
|
||||
}
|
||||
if (!isNaN(max)) {
|
||||
return val <= max;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const ageOnFilter = createNumberRangeOnFilter((record) => {
|
||||
const age = calculateAge(record.birth);
|
||||
return typeof age === 'number' ? age : -1;
|
||||
});
|
||||
|
||||
const heightOnFilter = createNumberRangeOnFilter((record) => record.height || 0);
|
||||
|
||||
// 修改 NumberRangeFilterDropdown 以适配 "min,max" 格式
|
||||
// 等等,修改组件太麻烦,我们直接在 CustomList 里处理 props
|
||||
// 实际上,Antd 的 filterDropdown 接收的 selectedKeys 是一个数组。
|
||||
// 我们可以只用 selectedKeys[0] 来存储 "min,max" 字符串。
|
||||
|
||||
// 处理分享点击
|
||||
const handleShare = (record: CustomResource) => {
|
||||
setSharingCustom(record);
|
||||
setShareSelectorVisible(true);
|
||||
};
|
||||
|
||||
// 处理图片选择完成
|
||||
const handleShareImageSelect = (imageUrl: string) => {
|
||||
setSelectedShareImage(imageUrl);
|
||||
setShareSelectorVisible(false);
|
||||
setShareCropperVisible(true);
|
||||
};
|
||||
|
||||
// 处理裁切完成
|
||||
const handleShareImageCrop = async (blob: Blob) => {
|
||||
setShareCropperVisible(false);
|
||||
|
||||
if (sharingCustom) {
|
||||
// 辅助函数:计算资产等级
|
||||
const getAssetLevel = (val: number | undefined, prefix: string) => {
|
||||
if (val === undefined || val === null) return '-';
|
||||
// 假设单位为万
|
||||
if (val < 100) return `${prefix}6`;
|
||||
if (val < 1000) return `${prefix}7`;
|
||||
if (val < 10000) return `${prefix}8`;
|
||||
return `${prefix}9`;
|
||||
};
|
||||
|
||||
// 准备分享数据
|
||||
const shareData: ShareData = {
|
||||
imageBlob: blob,
|
||||
tags: {
|
||||
assets: getAssetLevel(sharingCustom.assets, 'A'),
|
||||
liquidAssets: getAssetLevel(sharingCustom.current_assets, 'C'),
|
||||
income: sharingCustom.income ? `${sharingCustom.income}万` : '-'
|
||||
},
|
||||
basicInfo: {
|
||||
age: calculateAge(sharingCustom.birth) === '未知' ? '-' : `${calculateAge(sharingCustom.birth)}岁`,
|
||||
height: sharingCustom.height ? `${sharingCustom.height}cm` : '-',
|
||||
degree: sharingCustom.degree || '-'
|
||||
},
|
||||
details: {
|
||||
city: sharingCustom.live_city || '-',
|
||||
isSingleChild: sharingCustom.is_single_child === undefined ? '-' : (sharingCustom.is_single_child ? '是' : '否'),
|
||||
houseCar: [sharingCustom.house, sharingCustom.car].filter(Boolean).join('; ') || '-'
|
||||
},
|
||||
introduction: sharingCustom.introductions
|
||||
? Object.entries(sharingCustom.introductions).map(([k, v]) => `${k}: ${v}`).join('; ')
|
||||
: '-',
|
||||
matchmaker: {
|
||||
orgName: 'IF.U',
|
||||
name: user?.nickname || '红娘'
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const resultUrl = await generateShareImage(shareData);
|
||||
setGeneratedShareImage(resultUrl);
|
||||
setShareResultVisible(true);
|
||||
} catch (error) {
|
||||
message.error('生成图片失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑客户
|
||||
const handleEdit = (record: CustomResource) => {
|
||||
setEditingRecord(record);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
// 渲染图片图标(仿 ResourceList 逻辑)
|
||||
const renderPictureIcon = (images?: string[]) => {
|
||||
const hasCover = images && images.length > 0 && images[0];
|
||||
return (
|
||||
<PictureOutlined
|
||||
style={{
|
||||
color: hasCover ? '#1677ff' : '#9ca3af',
|
||||
cursor: hasCover ? 'pointer' : 'default',
|
||||
fontSize: 16,
|
||||
}}
|
||||
onClick={hasCover ? (e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentImages(images || []);
|
||||
setInitialImageIndex(0);
|
||||
setImageModalVisible(true);
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染性别图标
|
||||
const renderGender = (gender: string) => {
|
||||
if (gender === '男') return <ManOutlined style={{ color: '#1890ff' }} />;
|
||||
if (gender === '女') return <WomanOutlined style={{ color: '#eb2f96' }} />;
|
||||
return <UserOutlined />;
|
||||
};
|
||||
|
||||
// 移动端姓名列渲染
|
||||
const renderMobileName = (text: string, record: CustomResource) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', gap: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>{text}</span>
|
||||
<span style={{ color: '#888' }}>
|
||||
{renderGender(record.gender)}
|
||||
</span>
|
||||
</div>
|
||||
{renderPictureIcon(record.images)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// PC端姓名列渲染
|
||||
const renderPCName = (text: string, record: CustomResource) => (
|
||||
<Space>
|
||||
<span style={{ fontWeight: 600 }}>{text}</span>
|
||||
{renderPictureIcon(record.images)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<CustomResource> = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120, // 固定宽度
|
||||
ellipsis: true,
|
||||
render: screens.xs ? renderMobileName : renderPCName,
|
||||
// 移动端在姓名列增加性别筛选
|
||||
...(screens.xs ? {
|
||||
filters: [
|
||||
{ text: '男', value: '男' },
|
||||
{ text: '女', value: '女' },
|
||||
],
|
||||
onFilter: (value: React.Key | boolean, record: CustomResource) => record.gender === value,
|
||||
} : {}),
|
||||
},
|
||||
{
|
||||
title: '性别',
|
||||
dataIndex: 'gender',
|
||||
key: 'gender',
|
||||
width: 100,
|
||||
hidden: !screens.md,
|
||||
render: (text: string) => {
|
||||
const color = text === '男' ? 'blue' : text === '女' ? 'magenta' : 'default';
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
},
|
||||
// PC端在性别列增加筛选
|
||||
filters: [
|
||||
{ text: '男', value: '男' },
|
||||
{ text: '女', value: '女' },
|
||||
],
|
||||
onFilter: (value: React.Key | boolean, record: CustomResource) => record.gender === value,
|
||||
},
|
||||
{
|
||||
title: '年龄',
|
||||
dataIndex: 'birth',
|
||||
key: 'age',
|
||||
width: 100,
|
||||
render: (birth: number) => calculateAge(birth),
|
||||
filterDropdown: (props) => (
|
||||
<NumberRangeFilterDropdown
|
||||
{...props}
|
||||
clearFilters={() => props.clearFilters?.()}
|
||||
/>
|
||||
),
|
||||
onFilter: ageOnFilter,
|
||||
sorter: (a, b) => {
|
||||
const ageA = typeof calculateAge(a.birth) === 'number' ? calculateAge(a.birth) as number : -1;
|
||||
const ageB = typeof calculateAge(b.birth) === 'number' ? calculateAge(b.birth) as number : -1;
|
||||
return ageA - ageB;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '身高',
|
||||
dataIndex: 'height',
|
||||
key: 'height',
|
||||
width: 80,
|
||||
hidden: !screens.xl,
|
||||
render: (val: number) => val ? `${val}cm` : '-',
|
||||
filterDropdown: (props) => (
|
||||
<NumberRangeFilterDropdown
|
||||
{...props}
|
||||
clearFilters={() => props.clearFilters?.()}
|
||||
/>
|
||||
),
|
||||
onFilter: heightOnFilter, // 复用数字范围筛选逻辑
|
||||
sorter: (a: CustomResource, b: CustomResource) => (a.height || 0) - (b.height || 0),
|
||||
},
|
||||
{
|
||||
title: '常住城市',
|
||||
dataIndex: 'live_city',
|
||||
key: 'live_city',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
hidden: !screens.lg,
|
||||
render: (val: string) => val || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: screens.xs ? 60 : 140, // 增加宽度以容纳分享按钮
|
||||
render: (_, record) => {
|
||||
// PC 端直接显示按钮
|
||||
if (!screens.xs) {
|
||||
return (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={() => handleShare(record)}
|
||||
title="分享"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定要删除吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端显示更多菜单
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'share',
|
||||
label: '分享',
|
||||
icon: <ShareAltOutlined />,
|
||||
onClick: () => handleShare(record),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => handleEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: '确定要删除吗?',
|
||||
content: '删除后无法恢复',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => handleDelete(record.id),
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button type="text" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 展开行渲染
|
||||
const expandedRowRender = (record: CustomResource) => {
|
||||
return (
|
||||
<div style={{ padding: '0 24px', backgroundColor: '#fafafa' }}>
|
||||
<Descriptions title="基础信息" bordered size="small" column={{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}>
|
||||
<Descriptions.Item label="身高">{record.height ? `${record.height}cm` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="体重">{record.weight ? `${record.weight}kg` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="电话">{record.phone || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{record.email || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
|
||||
|
||||
<Descriptions title="学历工作" bordered size="small" column={{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}>
|
||||
<Descriptions.Item label="学位">{record.degree || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="学校">{record.academy || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="职业">{record.occupation || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="收入">{record.income ? `${record.income}万` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="资产">{record.assets ? `${record.assets}万` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="流动资产">{record.current_assets ? `${record.current_assets}万` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="房产情况">{record.house || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="汽车情况">{record.car || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
|
||||
|
||||
<Descriptions title="所在城市" bordered size="small" column={{ xs: 1, sm: 2, md: 3 }}>
|
||||
<Descriptions.Item label="户籍城市">{record.registered_city || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="常住城市">{record.live_city || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="籍贯城市">{record.native_place || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
|
||||
|
||||
<Descriptions title="原生家庭" bordered size="small" column={{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}>
|
||||
<Descriptions.Item label="独生子女">{record.is_single_child ? '是' : '否'}</Descriptions.Item>
|
||||
<Descriptions.Item label="家庭情况">{record.original_family || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
|
||||
|
||||
<Descriptions title="其他信息" bordered size="small" column={1}>
|
||||
{record.introductions && Object.entries(record.introductions).map(([key, value]) => (
|
||||
<Descriptions.Item label={key} key={key}>{value}</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
|
||||
|
||||
<Descriptions title="择偶要求" bordered size="small" column={1}>
|
||||
<Descriptions.Item label="要求内容">
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{record.match_requirement || '-'}</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ margin: '16px 0', borderBottom: '1px solid #f0f0f0' }} />
|
||||
|
||||
<Descriptions title="管理信息" bordered size="small" column={1}>
|
||||
<Descriptions.Item label="客户等级">{record.custom_level || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="是否公开">{record.is_public ? '是' : '否'}</Descriptions.Item>
|
||||
{record.comments && Object.entries(record.comments).map(([key, value]) => (
|
||||
<Descriptions.Item label={key} key={`comment-${key}`}>{value}</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Content className="main-content">
|
||||
<div className="content-body">
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>客户列表</Typography.Title>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
expandable={{ expandedRowRender }}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分享流程 Modals */}
|
||||
<ImageSelectorModal
|
||||
visible={shareSelectorVisible}
|
||||
images={sharingCustom?.images || []}
|
||||
onCancel={() => setShareSelectorVisible(false)}
|
||||
onSelect={handleShareImageSelect}
|
||||
/>
|
||||
|
||||
<ImageCropperModal
|
||||
visible={shareCropperVisible}
|
||||
imageUrl={selectedShareImage}
|
||||
onCancel={() => setShareCropperVisible(false)}
|
||||
onConfirm={handleShareImageCrop}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="分享图片生成结果"
|
||||
open={shareResultVisible}
|
||||
onCancel={() => setShareResultVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setShareResultVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch(generatedShareImage);
|
||||
const blob = await response.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob,
|
||||
}),
|
||||
]);
|
||||
message.success('图片已复制到剪切板');
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
message.error('复制失败,请尝试下载');
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制图片
|
||||
</Button>,
|
||||
<Button
|
||||
key="download"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.download = `share-${sharingCustom?.name || 'custom'}.png`;
|
||||
link.href = generatedShareImage;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
>
|
||||
下载图片
|
||||
</Button>
|
||||
]}
|
||||
width={screens.lg ? 1000 : screens.md ? 700 : '95%'}
|
||||
destroyOnClose
|
||||
centered
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', background: '#f0f2f5', padding: '16px' }}>
|
||||
{generatedShareImage && (
|
||||
<img
|
||||
src={generatedShareImage}
|
||||
alt="Generated Share"
|
||||
style={{ maxWidth: '100%', maxHeight: '80vh', boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 编辑模态框 */}
|
||||
<Modal
|
||||
title="编辑客户信息"
|
||||
open={editModalVisible}
|
||||
onCancel={() => setEditModalVisible(false)}
|
||||
onOk={() => {
|
||||
editFormRef.current?.submit();
|
||||
}}
|
||||
width={800}
|
||||
maskClosable={false}
|
||||
destroyOnClose
|
||||
>
|
||||
<CustomForm
|
||||
initialData={editingRecord || undefined}
|
||||
hideSubmitButton
|
||||
onFormReady={(form) => { editFormRef.current = form; }}
|
||||
onSuccess={() => {
|
||||
setEditModalVisible(false);
|
||||
fetchData();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ImageModal
|
||||
visible={imageModalVisible}
|
||||
images={currentImages}
|
||||
initialIndex={initialImageIndex}
|
||||
onClose={() => setImageModalVisible(false)}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomList;
|
||||
42
src/components/CustomRegister.tsx
Normal file
42
src/components/CustomRegister.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import CustomForm from './CustomForm.tsx';
|
||||
import InputDrawer from './InputDrawer.tsx';
|
||||
import './MainContent.css'; // Reuse MainContent styles
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null };
|
||||
|
||||
const CustomRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
|
||||
// 暂时不需要反向填充表单,或者后续如果有需求再加
|
||||
const handleInputResult = (_data: unknown) => {
|
||||
// setFormData(data as Partial<Custom>);
|
||||
// TODO: 如果需要支持从 InputDrawer 自动填充 CustomForm,可以在这里实现
|
||||
console.log('InputDrawer result for custom:', _data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Content className="main-content">
|
||||
<div className="content-body">
|
||||
{/* 标题在 CustomForm 内部已经有了,或者在这里统一控制?
|
||||
CustomForm 内部写了 Title 和 Paragraph,这里就不重复了。
|
||||
PeopleForm 内部没有 Title,是在 MainContent 里写的。
|
||||
刚才我在 CustomForm 里加了 Title。
|
||||
*/}
|
||||
<CustomForm />
|
||||
</div>
|
||||
|
||||
{/* 复用 InputDrawer,虽然可能暂时没用 */}
|
||||
<InputDrawer
|
||||
open={inputOpen}
|
||||
onClose={onCloseInput || (() => {})}
|
||||
onResult={handleInputResult}
|
||||
containerEl={containerEl}
|
||||
targetModel="custom"
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomRegister;
|
||||
176
src/components/ImageCropperModal.tsx
Normal file
176
src/components/ImageCropperModal.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Modal, Button, message } from 'antd';
|
||||
import ReactCrop, { centerCrop, makeAspectCrop, type Crop, type PixelCrop } from 'react-image-crop';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
|
||||
interface ImageCropperModalProps {
|
||||
visible: boolean;
|
||||
imageUrl: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: (blob: Blob) => void;
|
||||
}
|
||||
|
||||
const ImageCropperModal: React.FC<ImageCropperModalProps> = ({
|
||||
visible,
|
||||
imageUrl,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [crop, setCrop] = useState<Crop>();
|
||||
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// 初始化裁切区域
|
||||
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const { width, height } = e.currentTarget;
|
||||
// 默认生成正方形裁切
|
||||
const crop = centerCrop(
|
||||
makeAspectCrop(
|
||||
{
|
||||
unit: '%',
|
||||
width: 90,
|
||||
},
|
||||
1, // aspect ratio 1:1
|
||||
width,
|
||||
height,
|
||||
),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
setCrop(crop);
|
||||
|
||||
// 手动设置初始 completedCrop,防止用户直接点击确定时为空
|
||||
// 将百分比转换为像素
|
||||
setCompletedCrop({
|
||||
unit: 'px',
|
||||
x: (crop.x / 100) * width,
|
||||
y: (crop.y / 100) * height,
|
||||
width: (crop.width / 100) * width,
|
||||
height: (crop.height / 100) * height,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (completedCrop && imgRef.current && canvasRef.current) {
|
||||
const image = imgRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const crop = completedCrop;
|
||||
|
||||
// 这里的 scale 是指:图片显示尺寸 / 图片原始尺寸
|
||||
// 注意:completedCrop 是基于显示尺寸的像素值
|
||||
// 我们需要将其映射回原始图片的像素值
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
|
||||
// 设置 canvas 的绘制尺寸(基于原始图片分辨率)
|
||||
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
|
||||
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
message.error('无法获取 Canvas 上下文');
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理高 DPI 屏幕
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
// 计算源图像上的裁切区域
|
||||
const sourceX = crop.x * scaleX;
|
||||
const sourceY = crop.y * scaleY;
|
||||
const sourceWidth = crop.width * scaleX;
|
||||
const sourceHeight = crop.height * scaleY;
|
||||
|
||||
// 计算目标绘制区域(即整个 Canvas)
|
||||
const destX = 0;
|
||||
const destY = 0;
|
||||
const destWidth = crop.width * scaleX;
|
||||
const destHeight = crop.height * scaleY;
|
||||
|
||||
ctx.save();
|
||||
try {
|
||||
ctx.drawImage(
|
||||
image,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
destX,
|
||||
destY,
|
||||
destWidth,
|
||||
destHeight
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Canvas drawImage failed:', e);
|
||||
message.error('图片裁切失败,请重试');
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
try {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
console.error('Canvas toBlob returned null');
|
||||
message.error('图片生成失败');
|
||||
return;
|
||||
}
|
||||
onConfirm(blob);
|
||||
}, 'image/png');
|
||||
} catch (e) {
|
||||
console.error('Canvas toBlob failed:', e);
|
||||
message.error('无法导出图片,可能存在跨域问题');
|
||||
}
|
||||
} else {
|
||||
// 如果没有进行任何裁切操作(比如直接点确定),尝试使用默认全图或当前状态
|
||||
message.warning('请调整裁切区域');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
title="图片裁切"
|
||||
width={600}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="confirm" type="primary" onClick={handleConfirm}>
|
||||
确定
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', maxHeight: '60vh', overflow: 'auto' }}>
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
||||
onComplete={(c) => setCompletedCrop(c)}
|
||||
aspect={1} // 强制正方形
|
||||
circularCrop={false}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt="Crop me"
|
||||
src={imageUrl}
|
||||
onLoad={onImageLoad}
|
||||
crossOrigin="anonymous"
|
||||
style={{ maxWidth: '100%', maxHeight: '50vh' }}
|
||||
/>
|
||||
</ReactCrop>
|
||||
</div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageCropperModal;
|
||||
329
src/components/ImageInputGroup.tsx
Normal file
329
src/components/ImageInputGroup.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Input, Button, Row, Col, Grid, Modal, message } from 'antd';
|
||||
import { UploadOutlined, PlusOutlined, DeleteOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
import ImagePreview from './ImagePreview';
|
||||
import { uploadCustomImage, uploadImage } from '../apis';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
interface ImageInputGroupProps {
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
customId?: string; // Optional: needed for uploadCustomImage if editing
|
||||
}
|
||||
|
||||
const ImageInputGroup: React.FC<ImageInputGroupProps> = ({ value = [], onChange, customId }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [currentUploadIndex, setCurrentUploadIndex] = useState<number>(-1);
|
||||
const [imgSrc, setImgSrc] = useState('');
|
||||
const [crop, setCrop] = useState<Crop>();
|
||||
const [completedCrop, setCompletedCrop] = useState<Crop>();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
const touchStartRef = useRef<number | null>(null);
|
||||
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
// Internal state to manage the list of images
|
||||
const [images, setImages] = useState<string[]>(value);
|
||||
|
||||
useEffect(() => {
|
||||
setImages(value);
|
||||
}, [value]);
|
||||
|
||||
// Ensure previewIndex is valid
|
||||
useEffect(() => {
|
||||
if (images.length > 0 && previewIndex >= images.length) {
|
||||
setPreviewIndex(Math.max(0, images.length - 1));
|
||||
}
|
||||
}, [images.length, previewIndex]);
|
||||
|
||||
const currentPreviewUrl = images[previewIndex] || '';
|
||||
|
||||
const triggerChange = (newImages: string[]) => {
|
||||
setImages(newImages);
|
||||
onChange?.(newImages);
|
||||
};
|
||||
|
||||
const handleInputChange = (index: number, newValue: string) => {
|
||||
const newImages = [...images];
|
||||
newImages[index] = newValue;
|
||||
triggerChange(newImages);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
const newImages = [...images, ''];
|
||||
triggerChange(newImages);
|
||||
setPreviewIndex(newImages.length - 1);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const newImages = images.filter((_, i) => i !== index);
|
||||
triggerChange(newImages);
|
||||
if (previewIndex >= newImages.length) {
|
||||
setPreviewIndex(Math.max(0, newImages.length - 1));
|
||||
}
|
||||
};
|
||||
|
||||
// File Selection
|
||||
const onSelectFile = (file: File) => {
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => {
|
||||
setImgSrc(reader.result?.toString() || '');
|
||||
setModalVisible(true);
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const { width, height } = e.currentTarget;
|
||||
const crop = centerCrop(
|
||||
makeAspectCrop({ unit: '%', width: 100 }, 1, width, height),
|
||||
width,
|
||||
height
|
||||
);
|
||||
setCrop(crop);
|
||||
};
|
||||
|
||||
function canvasPreview(image: HTMLImageElement, canvas: HTMLCanvasElement, crop: Crop) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { throw new Error('No 2d context'); }
|
||||
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
|
||||
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const cropX = crop.x * scaleX;
|
||||
const cropY = crop.y * scaleY;
|
||||
const centerX = image.naturalWidth / 2;
|
||||
const centerY = image.naturalHeight / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(-cropX, -cropY);
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.translate(-centerX, -centerY);
|
||||
ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight, 0, 0, image.naturalWidth, image.naturalHeight);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
const onOk = async () => {
|
||||
if (completedCrop && previewCanvasRef.current && imgRef.current) {
|
||||
canvasPreview(imgRef.current, previewCanvasRef.current, completedCrop);
|
||||
previewCanvasRef.current.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
setUploading(true);
|
||||
try {
|
||||
const response = customId
|
||||
? await uploadCustomImage(customId, blob as File)
|
||||
: await uploadImage(blob as File);
|
||||
|
||||
if (response.data) {
|
||||
const newImages = [...images];
|
||||
if (currentUploadIndex >= 0 && currentUploadIndex < newImages.length) {
|
||||
newImages[currentUploadIndex] = response.data;
|
||||
triggerChange(newImages);
|
||||
setPreviewIndex(currentUploadIndex);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('图片上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setModalVisible(false);
|
||||
}
|
||||
}
|
||||
}, 'image/png');
|
||||
}
|
||||
};
|
||||
|
||||
// Touch handlers for carousel
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartRef.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStartRef.current === null) return;
|
||||
const touchEnd = e.changedTouches[0].clientX;
|
||||
const diff = touchStartRef.current - touchEnd;
|
||||
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0) {
|
||||
// Next
|
||||
if (images.length > 0 && previewIndex < images.length - 1) {
|
||||
setPreviewIndex(previewIndex + 1);
|
||||
}
|
||||
} else {
|
||||
// Prev
|
||||
if (previewIndex > 0) {
|
||||
setPreviewIndex(previewIndex - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
touchStartRef.current = null;
|
||||
};
|
||||
|
||||
const coverPreviewNode = (
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<ImagePreview
|
||||
url={currentPreviewUrl}
|
||||
minHeight="272px"
|
||||
maxHeight="272px"
|
||||
/>
|
||||
{/* Navigation Buttons for PC */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<LeftOutlined />}
|
||||
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', zIndex: 1, opacity: 0.7 }}
|
||||
onClick={() => setPreviewIndex(prev => Math.max(0, prev - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
className="desktop-only-btn"
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<RightOutlined />}
|
||||
style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', zIndex: 1, opacity: 0.7 }}
|
||||
onClick={() => setPreviewIndex(prev => Math.min(images.length - 1, prev + 1))}
|
||||
disabled={previewIndex === images.length - 1}
|
||||
className="desktop-only-btn"
|
||||
/>
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
{previewIndex + 1} / {images.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const imagesListNode = (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
{images.map((url, index) => (
|
||||
<Row key={index} gutter={8} align="middle" style={{
|
||||
marginBottom: 8,
|
||||
background: index === previewIndex ? '#f6ffed' : 'transparent',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
border: index === previewIndex ? '1px solid #b7eb8f' : '1px solid transparent'
|
||||
}}>
|
||||
<Col flex="auto">
|
||||
<Input
|
||||
placeholder="输入链接"
|
||||
value={url}
|
||||
onChange={(e) => handleInputChange(index, e.target.value)}
|
||||
onFocus={() => setPreviewIndex(index)}
|
||||
suffix={
|
||||
<Button icon={<UploadOutlined />} type="text" size="small" loading={uploading && currentUploadIndex === index} onClick={() => {
|
||||
setCurrentUploadIndex(index);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files?.[0]) onSelectFile(target.files[0]);
|
||||
};
|
||||
input.click();
|
||||
}} />
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(index)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
<Button type="dashed" onClick={handleAdd} block icon={<PlusOutlined />}>
|
||||
添加一张
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[24, 24]}>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{/* Mobile: Preview Top, Inputs Bottom */}
|
||||
<Col span={24}>
|
||||
{coverPreviewNode}
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
{imagesListNode}
|
||||
</Col>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* PC: Inputs Left, Preview Right */}
|
||||
<Col span={12}>
|
||||
{imagesListNode}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
{coverPreviewNode}
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{/* Crop Modal */}
|
||||
<Modal
|
||||
title="裁剪图片"
|
||||
open={modalVisible}
|
||||
onOk={onOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
confirmLoading={uploading}
|
||||
destroyOnClose
|
||||
>
|
||||
{imgSrc && (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
||||
onComplete={(c) => setCompletedCrop(c)}
|
||||
aspect={1}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt="Crop me"
|
||||
src={imgSrc}
|
||||
onLoad={onImageLoad}
|
||||
style={{ maxWidth: '100%', maxHeight: '60vh' }}
|
||||
/>
|
||||
</ReactCrop>
|
||||
)}
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
objectFit: 'contain',
|
||||
width: completedCrop?.width,
|
||||
height: completedCrop?.height,
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageInputGroup;
|
||||
@@ -1,23 +1,37 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Spin } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Spin, Button } from 'antd';
|
||||
import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import './ImageModal.css';
|
||||
|
||||
interface ImageModalProps {
|
||||
visible: boolean;
|
||||
imageUrl: string;
|
||||
images: string[];
|
||||
initialIndex?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 图片缓存
|
||||
const imageCache = new Set<string>();
|
||||
|
||||
const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) => {
|
||||
const ImageModal: React.FC<ImageModalProps> = ({ visible, images, initialIndex = 0, onClose }) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
const touchStartRef = useRef<number | null>(null);
|
||||
|
||||
// Initialize index
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentIndex(initialIndex);
|
||||
}
|
||||
}, [visible, initialIndex]);
|
||||
|
||||
// Current URL
|
||||
const currentImageUrl = images && images.length > 0 ? images[currentIndex] : '';
|
||||
|
||||
// 检测是否为移动端
|
||||
useEffect(() => {
|
||||
@@ -33,11 +47,18 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
||||
|
||||
// 预加载图片
|
||||
useEffect(() => {
|
||||
if (visible && imageUrl) {
|
||||
if (visible && currentImageUrl) {
|
||||
// 如果图片已缓存,直接显示
|
||||
if (imageCache.has(imageUrl)) {
|
||||
if (imageCache.has(currentImageUrl)) {
|
||||
setImageLoaded(true);
|
||||
setLoading(false);
|
||||
// Still need dimensions for mobile height calc?
|
||||
// Ideally we should cache dimensions too, but for now let's reload to be safe or accept slight jump
|
||||
const img = new Image();
|
||||
img.src = currentImageUrl;
|
||||
img.onload = () => {
|
||||
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,7 +68,7 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageCache.add(imageUrl);
|
||||
imageCache.add(currentImageUrl);
|
||||
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
setImageLoaded(true);
|
||||
setLoading(false);
|
||||
@@ -56,9 +77,9 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
||||
setImageError(true);
|
||||
setLoading(false);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
img.src = currentImageUrl;
|
||||
}
|
||||
}, [visible, imageUrl]);
|
||||
}, [visible, currentImageUrl]);
|
||||
|
||||
// 重置状态当弹窗关闭时
|
||||
useEffect(() => {
|
||||
@@ -108,6 +129,7 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#000',
|
||||
position: 'relative' as const, // For arrows
|
||||
} : {
|
||||
padding: 0,
|
||||
height: '66vh',
|
||||
@@ -115,6 +137,43 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#000',
|
||||
position: 'relative' as const,
|
||||
};
|
||||
|
||||
// Navigation Logic
|
||||
const handlePrev = (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(prev => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
if (images && currentIndex < images.length - 1) {
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartRef.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStartRef.current === null) return;
|
||||
const touchEnd = e.changedTouches[0].clientX;
|
||||
const diff = touchStartRef.current - touchEnd;
|
||||
|
||||
if (Math.abs(diff) > 50) { // Threshold
|
||||
if (diff > 0) {
|
||||
// Swiped Left -> Next
|
||||
handleNext();
|
||||
} else {
|
||||
// Swiped Right -> Prev
|
||||
handlePrev();
|
||||
}
|
||||
}
|
||||
touchStartRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -163,8 +222,71 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons (PC only mostly, but logic is generic) */}
|
||||
{!isMobile && images.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<LeftOutlined />}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1000,
|
||||
opacity: currentIndex === 0 ? 0.3 : 0.8,
|
||||
border: 'none',
|
||||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||
color: '#fff'
|
||||
}}
|
||||
onClick={handlePrev}
|
||||
disabled={currentIndex === 0}
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<RightOutlined />}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1000,
|
||||
opacity: currentIndex === images.length - 1 ? 0.3 : 0.8,
|
||||
border: 'none',
|
||||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||
color: '#fff'
|
||||
}}
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === images.length - 1}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Image Count Indicator */}
|
||||
{images.length > 1 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
color: '#fff',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '12px',
|
||||
zIndex: 1000,
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片内容 */}
|
||||
<div className="image-modal-container">
|
||||
<div
|
||||
className="image-modal-container"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{ width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}
|
||||
>
|
||||
{loading && (
|
||||
<Spin size="large" style={{ color: '#fff' }} />
|
||||
)}
|
||||
@@ -178,8 +300,9 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
||||
|
||||
{imageLoaded && !loading && !imageError && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
src={currentImageUrl}
|
||||
alt="预览图片"
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -187,4 +310,4 @@ const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) =
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageModal;
|
||||
export default ImageModal;
|
||||
|
||||
56
src/components/ImagePreview.tsx
Normal file
56
src/components/ImagePreview.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Image } from 'antd';
|
||||
|
||||
interface ImagePreviewProps {
|
||||
url?: string;
|
||||
alt?: string;
|
||||
minHeight?: string | number;
|
||||
maxHeight?: string | number;
|
||||
placeholder?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
const ImagePreview: React.FC<ImagePreviewProps> = ({
|
||||
url,
|
||||
alt = '封面预览',
|
||||
minHeight,
|
||||
maxHeight,
|
||||
placeholder = '封面预览',
|
||||
backgroundColor = '#fafafa',
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: minHeight,
|
||||
maxHeight: maxHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px dashed #d9d9d9',
|
||||
borderRadius: '8px',
|
||||
background: backgroundColor,
|
||||
padding: '8px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{url ? (
|
||||
<Image
|
||||
src={url}
|
||||
alt={alt}
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
maxHeight: typeof maxHeight === 'string' ? `calc(${maxHeight} - 16px)` : maxHeight ? (maxHeight as number) - 16 : undefined,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2IChhEhKKTMoZ0hTwIQYUXOgjpAhLwzpDbQCBCwh_gswOQDz12JoLPj+7YM..."
|
||||
preview={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ color: '#999' }}>{placeholder}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImagePreview;
|
||||
90
src/components/ImageSelectorModal.css
Normal file
90
src/components/ImageSelectorModal.css
Normal file
@@ -0,0 +1,90 @@
|
||||
.image-selector-modal .ant-modal-body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-selector-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px; /* 固定高度,或者根据需要调整 */
|
||||
background-color: #f0f2f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-selector-container .no-image-placeholder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-selector-container .image-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-selector-container .image-wrapper img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.nav-arrow:hover {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.nav-arrow.left {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.nav-arrow.right {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.image-counter {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.nav-arrow {
|
||||
display: none; /* 移动端隐藏箭头,使用滑动 */
|
||||
}
|
||||
|
||||
.image-selector-container {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
115
src/components/ImageSelectorModal.tsx
Normal file
115
src/components/ImageSelectorModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Button, Empty } from 'antd';
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import './ImageSelectorModal.css';
|
||||
|
||||
interface ImageSelectorModalProps {
|
||||
visible: boolean;
|
||||
images?: string[];
|
||||
onCancel: () => void;
|
||||
onSelect: (imageUrl: string) => void;
|
||||
}
|
||||
|
||||
const ImageSelectorModal: React.FC<ImageSelectorModalProps> = ({
|
||||
visible,
|
||||
images = [],
|
||||
onCancel,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const touchStartRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentIndex(0);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handlePrev = () => {
|
||||
if (!images || images.length === 0) return;
|
||||
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!images || images.length === 0) return;
|
||||
setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartRef.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStartRef.current === null) return;
|
||||
const touchEnd = e.changedTouches[0].clientX;
|
||||
const diff = touchStartRef.current - touchEnd;
|
||||
|
||||
if (Math.abs(diff) > 50) { // Threshold for swipe
|
||||
if (diff > 0) {
|
||||
handleNext();
|
||||
} else {
|
||||
handlePrev();
|
||||
}
|
||||
}
|
||||
touchStartRef.current = null;
|
||||
};
|
||||
|
||||
const hasImages = images && images.length > 0;
|
||||
const currentImage = hasImages ? images[currentIndex] : null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
title="选择分享图片"
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
onClick={() => currentImage && onSelect(currentImage)}
|
||||
disabled={!hasImages}
|
||||
>
|
||||
确定
|
||||
</Button>,
|
||||
]}
|
||||
width={600}
|
||||
centered
|
||||
className="image-selector-modal"
|
||||
>
|
||||
<div
|
||||
className="image-selector-container"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{!hasImages ? (
|
||||
<div className="no-image-placeholder">
|
||||
<Empty description="暂无图片" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="image-wrapper">
|
||||
<img src={currentImage!} alt={`preview-${currentIndex}`} />
|
||||
</div>
|
||||
|
||||
{/* PC端显示的箭头 */}
|
||||
<div className="nav-arrow left" onClick={handlePrev}>
|
||||
<LeftOutlined />
|
||||
</div>
|
||||
<div className="nav-arrow right" onClick={handleNext}>
|
||||
<RightOutlined />
|
||||
</div>
|
||||
|
||||
<div className="image-counter">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageSelectorModal;
|
||||
@@ -35,9 +35,7 @@
|
||||
|
||||
/* 抽屉与遮罩不再额外向下偏移,依赖 getContainer 挂载到标题栏下方的容器 */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.input-drawer-box {
|
||||
max-width: 100%;
|
||||
padding: 14px; /* 移动端更紧凑 */
|
||||
}
|
||||
.layout-mobile .input-drawer-box {
|
||||
max-width: 100%;
|
||||
padding: 14px; /* 移动端更紧凑 */
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import './InputDrawer.css';
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onResult?: (data: any) => void;
|
||||
onResult?: (data: unknown) => void;
|
||||
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
|
||||
showUpload?: boolean; // 透传到输入面板,控制图片上传按钮
|
||||
mode?: 'input' | 'search' | 'batch-image'; // 透传到输入面板,控制工作模式
|
||||
targetModel?: 'people' | 'custom'; // 透传到输入面板,识别目标模型
|
||||
};
|
||||
|
||||
const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, showUpload = true, mode = 'input' }) => {
|
||||
const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, showUpload = true, mode = 'input', targetModel = 'people' }) => {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const [topbarHeight, setTopbarHeight] = React.useState<number>(56);
|
||||
@@ -31,7 +32,7 @@ const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, sh
|
||||
|
||||
// 在输入处理成功(onResult 被调用)后,自动关闭抽屉
|
||||
const handleResult = React.useCallback(
|
||||
(data: any) => {
|
||||
(data: unknown) => {
|
||||
onResult?.(data);
|
||||
onClose();
|
||||
},
|
||||
@@ -66,7 +67,7 @@ const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, sh
|
||||
<div className="input-drawer-inner">
|
||||
<div className="input-drawer-title">AI FIND U</div>
|
||||
<div className="input-drawer-box">
|
||||
<InputPanel onResult={handleResult} showUpload={showUpload} mode={mode} />
|
||||
<InputPanel onResult={handleResult} showUpload={showUpload} mode={mode} targetModel={targetModel} />
|
||||
<HintText showUpload={showUpload} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Input, Upload, message, Button, Spin, Tag } from 'antd';
|
||||
import type { UploadFile, RcFile } from 'antd/es/upload/interface';
|
||||
import { PictureOutlined, SendOutlined, LoadingOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { postInput, postInputImage, getPeoples } from '../apis';
|
||||
import './InputPanel.css';
|
||||
@@ -7,14 +8,15 @@ import './InputPanel.css';
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface InputPanelProps {
|
||||
onResult?: (data: any) => void;
|
||||
onResult?: (data: unknown) => void;
|
||||
showUpload?: boolean; // 是否显示图片上传按钮,默认显示
|
||||
mode?: 'input' | 'search' | 'batch-image'; // 输入面板工作模式,新增批量图片模式
|
||||
targetModel?: 'people' | 'custom'; // 识别目标模型,默认为 people
|
||||
}
|
||||
|
||||
const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mode = 'input' }) => {
|
||||
const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mode = 'input', targetModel = 'people' }) => {
|
||||
const [value, setValue] = React.useState('');
|
||||
const [fileList, setFileList] = React.useState<any[]>([]);
|
||||
const [fileList, setFileList] = React.useState<UploadFile[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
// 批量模式不保留文本内容
|
||||
|
||||
@@ -63,11 +65,11 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const results: any[] = [];
|
||||
const results: unknown[] = [];
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const f = fileList[i].originFileObj || fileList[i];
|
||||
const f = fileList[i].originFileObj as RcFile | undefined;
|
||||
if (!f) continue;
|
||||
const resp = await postInputImage(f);
|
||||
const resp = await postInputImage(f, targetModel);
|
||||
if (resp && resp.error_code === 0 && resp.data) {
|
||||
results.push(resp.data);
|
||||
}
|
||||
@@ -95,18 +97,18 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
|
||||
|
||||
// 如果有图片,优先处理图片上传
|
||||
if (hasImage) {
|
||||
const file = fileList[0].originFileObj || fileList[0];
|
||||
const file = fileList[0].originFileObj as RcFile | undefined;
|
||||
if (!file) {
|
||||
message.error('图片文件无效,请重新选择');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('上传图片:', file.name);
|
||||
response = await postInputImage(file);
|
||||
response = await postInputImage(file, targetModel);
|
||||
} else {
|
||||
// 只有文本时,调用文本处理 API
|
||||
console.log('处理文本:', trimmed);
|
||||
response = await postInput(trimmed);
|
||||
response = await postInput(trimmed, targetModel);
|
||||
}
|
||||
|
||||
console.log('API响应:', response);
|
||||
@@ -152,18 +154,18 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
if (mode === 'batch-image') {
|
||||
const newEntries: any[] = [];
|
||||
const newEntries: UploadFile[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const entry = {
|
||||
const entry: UploadFile<RcFile> = {
|
||||
uid: `${Date.now()}-${Math.random()}`,
|
||||
name: 'image',
|
||||
status: 'done',
|
||||
originFileObj: file,
|
||||
} as any;
|
||||
originFileObj: file as unknown as RcFile,
|
||||
};
|
||||
newEntries.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -190,14 +192,13 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
|
||||
if (firstImage) {
|
||||
e.preventDefault();
|
||||
setValue('');
|
||||
setFileList([
|
||||
{
|
||||
uid: `${Date.now()}-${Math.random()}`,
|
||||
name: 'image',
|
||||
status: 'done',
|
||||
originFileObj: firstImage,
|
||||
} as any,
|
||||
]);
|
||||
const item: UploadFile<RcFile> = {
|
||||
uid: `${Date.now()}-${Math.random()}`,
|
||||
name: 'image',
|
||||
status: 'done',
|
||||
originFileObj: firstImage as unknown as RcFile,
|
||||
};
|
||||
setFileList([item]);
|
||||
message.success('已添加剪贴板图片');
|
||||
}
|
||||
}
|
||||
@@ -278,9 +279,9 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
|
||||
fileList={fileList}
|
||||
onChange={({ fileList: nextFileList }) => {
|
||||
if (mode === 'batch-image') {
|
||||
const normalized = nextFileList.map((entry: any) => {
|
||||
const raw = entry.originFileObj || entry;
|
||||
return { ...entry, name: 'image', originFileObj: raw };
|
||||
const normalized: UploadFile<RcFile>[] = nextFileList.map((entry) => {
|
||||
const raw = (entry as UploadFile<RcFile>).originFileObj;
|
||||
return { ...(entry as UploadFile<RcFile>), name: 'image', originFileObj: raw };
|
||||
});
|
||||
setValue('');
|
||||
setFileList(normalized);
|
||||
@@ -290,15 +291,15 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
|
||||
return;
|
||||
}
|
||||
// 仅添加第一张
|
||||
const first = nextFileList[0] as any;
|
||||
const raw = first.originFileObj || first;
|
||||
const renamed = { ...first, name: 'image', originFileObj: raw };
|
||||
const first = nextFileList[0] as UploadFile<RcFile>;
|
||||
const raw = first.originFileObj;
|
||||
const renamed: UploadFile<RcFile> = { ...first, name: 'image', originFileObj: raw };
|
||||
setValue('');
|
||||
setFileList([renamed]);
|
||||
}
|
||||
}}
|
||||
onRemove={(file) => {
|
||||
setFileList((prev) => prev.filter((x) => x.uid !== (file as any).uid));
|
||||
setFileList((prev) => prev.filter((x) => x.uid !== (file as UploadFile<RcFile>).uid));
|
||||
return true;
|
||||
}}
|
||||
showUploadList={false}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Row, Col, Input, Button } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import './KeyValueList.css';
|
||||
@@ -15,11 +15,8 @@ type Props = {
|
||||
const KeyValueList: React.FC<Props> = ({ value, onChange }) => {
|
||||
const [rows, setRows] = useState<KeyValuePair[]>([]);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// 初始化时提供一行空输入;之后只合并父值,不再自动新增空行
|
||||
const initializedRef = (KeyValueList as any)._initializedRef || { current: false };
|
||||
(KeyValueList as any)._initializedRef = initializedRef;
|
||||
|
||||
setRows((prev) => {
|
||||
const existingIdByKey = new Map(prev.filter((r) => r.k).map((r) => [r.k, r.id]));
|
||||
const valuePairs: KeyValuePair[] = value
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Grid } from 'antd';
|
||||
import { Routes, Route, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||
import SiderMenu from './SiderMenu.tsx';
|
||||
import MainContent from './MainContent.tsx';
|
||||
import CustomRegister from './CustomRegister.tsx';
|
||||
import ResourceList from './ResourceList.tsx';
|
||||
import CustomList from './CustomList.tsx';
|
||||
import BatchRegister from './BatchRegister.tsx';
|
||||
import TopBar from './TopBar.tsx';
|
||||
import '../styles/base.css';
|
||||
import '../styles/layout.css';
|
||||
import UserProfile from './UserProfile.tsx';
|
||||
|
||||
const LayoutWrapper: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
const [inputOpen, setInputOpen] = React.useState(false);
|
||||
const isHome = location.pathname === '/';
|
||||
const isResourceInput = location.pathname === '/resource-input';
|
||||
const isList = location.pathname === '/resources';
|
||||
const isBatch = location.pathname === '/batch-register';
|
||||
const layoutShellRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const pathToKey = (path: string) => {
|
||||
switch (path) {
|
||||
case '/custom-register':
|
||||
return 'custom';
|
||||
case '/resources':
|
||||
return 'menu1';
|
||||
case '/batch-register':
|
||||
return 'batch';
|
||||
case '/custom-list':
|
||||
return 'custom-list';
|
||||
case '/menu2':
|
||||
return 'menu2';
|
||||
default:
|
||||
case '/resource-input':
|
||||
return 'home';
|
||||
default:
|
||||
// 根路径重定向到客户列表,所以默认可以选中 custom-list,或者不做处理
|
||||
if (path === '/') return 'custom-list';
|
||||
return 'custom-list';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,11 +50,17 @@ const LayoutWrapper: React.FC = () => {
|
||||
const handleNavigate = (key: string) => {
|
||||
switch (key) {
|
||||
case 'home':
|
||||
navigate('/');
|
||||
navigate('/resource-input');
|
||||
break;
|
||||
case 'custom':
|
||||
navigate('/custom-register');
|
||||
break;
|
||||
case 'batch':
|
||||
navigate('/batch-register');
|
||||
break;
|
||||
case 'custom-list':
|
||||
navigate('/custom-list');
|
||||
break;
|
||||
case 'menu1':
|
||||
navigate('/resources');
|
||||
break;
|
||||
@@ -49,7 +68,7 @@ const LayoutWrapper: React.FC = () => {
|
||||
navigate('/menu2');
|
||||
break;
|
||||
default:
|
||||
navigate('/');
|
||||
navigate('/custom-list');
|
||||
break;
|
||||
}
|
||||
// 切换页面时收起输入抽屉
|
||||
@@ -57,15 +76,15 @@ const LayoutWrapper: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout className="layout-wrapper app-root">
|
||||
<Layout className={`layout-wrapper app-root ${isMobile ? 'layout-mobile' : 'layout-desktop'}`}>
|
||||
{/* 顶部标题栏,位于左侧菜单栏之上 */}
|
||||
<TopBar
|
||||
onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
|
||||
onToggleInput={() => {if (isHome || isList || isBatch) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
|
||||
showInput={isHome || isList || isBatch}
|
||||
onToggleInput={() => {if (isResourceInput || isList || isBatch) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
|
||||
showInput={isResourceInput || isList || isBatch}
|
||||
/>
|
||||
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
|
||||
<Layout ref={layoutShellRef as any} className="layout-shell">
|
||||
<Layout ref={layoutShellRef} className="layout-shell">
|
||||
<SiderMenu
|
||||
onNavigate={handleNavigate}
|
||||
selectedKey={selectedKey}
|
||||
@@ -74,8 +93,9 @@ const LayoutWrapper: React.FC = () => {
|
||||
/>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/custom-list" replace />} />
|
||||
<Route
|
||||
path="/"
|
||||
path="/resource-input"
|
||||
element={
|
||||
<MainContent
|
||||
inputOpen={inputOpen}
|
||||
@@ -84,6 +104,16 @@ const LayoutWrapper: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/custom-register"
|
||||
element={
|
||||
<CustomRegister
|
||||
inputOpen={inputOpen}
|
||||
onCloseInput={() => setInputOpen(false)}
|
||||
containerEl={layoutShellRef.current}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/batch-register"
|
||||
element={
|
||||
@@ -94,6 +124,12 @@ const LayoutWrapper: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/custom-list"
|
||||
element={
|
||||
<CustomList />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resources"
|
||||
element={
|
||||
@@ -105,6 +141,7 @@ const LayoutWrapper: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
<Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>菜单2的内容暂未实现</div>} />
|
||||
<Route path="/user" element={<UserProfile />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Layout>
|
||||
@@ -112,4 +149,4 @@ const LayoutWrapper: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutWrapper;
|
||||
export default LayoutWrapper;
|
||||
|
||||
78
src/components/LoginModal.tsx
Normal file
78
src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Input, Button, message } from 'antd';
|
||||
import type { LoginRequest } from '../apis/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onOk: (values: LoginRequest) => Promise<void>;
|
||||
title: string;
|
||||
username?: string;
|
||||
usernameReadOnly?: boolean;
|
||||
okText?: string;
|
||||
hideSuccessMessage?: boolean;
|
||||
}
|
||||
|
||||
const LoginModal: React.FC<Props> = ({ open, onCancel, onOk, title, username, usernameReadOnly, okText, hideSuccessMessage }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.setFieldsValue({ username });
|
||||
}
|
||||
}, [open, username, form]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const username: string = values.username;
|
||||
const password: string = values.password;
|
||||
const isEmail = /\S+@\S+\.\S+/.test(username);
|
||||
const payload = isEmail
|
||||
? { email: username, password }
|
||||
: { phone: username, password };
|
||||
await onOk(payload as LoginRequest);
|
||||
if (!hideSuccessMessage) {
|
||||
message.success('登录成功');
|
||||
}
|
||||
onCancel();
|
||||
} catch {
|
||||
message.error('登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="back" onClick={onCancel}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleLogin}>
|
||||
{okText || '登录'}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="手机号 / 邮箱"
|
||||
rules={[{ required: true, message: '请输入手机号 / 邮箱' }]}
|
||||
>
|
||||
<Input readOnly={usernameReadOnly} style={{ backgroundColor: usernameReadOnly ? '#f5f5f5' : '' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
@@ -3,15 +3,16 @@ import { Layout, Typography } from 'antd';
|
||||
import PeopleForm from './PeopleForm.tsx';
|
||||
import InputDrawer from './InputDrawer.tsx';
|
||||
import './MainContent.css';
|
||||
import type { People } from '../apis';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null };
|
||||
const MainContent: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
|
||||
const [formData, setFormData] = React.useState<any>(null);
|
||||
const [formData, setFormData] = React.useState<Partial<People> | null>(null);
|
||||
|
||||
const handleInputResult = (data: any) => {
|
||||
setFormData(data);
|
||||
const handleInputResult = (data: unknown) => {
|
||||
setFormData(data as Partial<People>);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -24,7 +25,7 @@ const MainContent: React.FC<Props> = ({ inputOpen = false, onCloseInput, contain
|
||||
点击右上角可以直接输入描述或上传图片
|
||||
</Typography.Paragraph>
|
||||
|
||||
<PeopleForm initialData={formData} />
|
||||
<PeopleForm initialData={formData || undefined} />
|
||||
</div>
|
||||
|
||||
{/* 首页右侧输入抽屉,仅在顶栏点击后弹出;挂载到标题栏下方容器 */}
|
||||
|
||||
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,14 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Input, Select, InputNumber, Button, message, Row, Col } from 'antd';
|
||||
import { Form, Input, Select, InputNumber, Button, message, Row, Col, Modal } from 'antd';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { FormInstance } from 'antd';
|
||||
|
||||
import './PeopleForm.css';
|
||||
import KeyValueList from './KeyValueList.tsx'
|
||||
import { createPeople, type People } from '../apis';
|
||||
import ImagePreview from './ImagePreview.tsx';
|
||||
import { createPeople, type People, uploadPeopleImage, uploadImage } from '../apis';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface PeopleFormProps {
|
||||
initialData?: any;
|
||||
initialData?: Partial<People>;
|
||||
// 编辑模式下由父组件控制提交,隐藏内部提交按钮
|
||||
hideSubmitButton?: boolean;
|
||||
// 暴露 AntD Form 实例给父组件,用于在外部触发校验与取值
|
||||
@@ -18,15 +23,18 @@ interface PeopleFormProps {
|
||||
const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton = false, onFormReady }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [imgSrc, setImgSrc] = React.useState('')
|
||||
const [crop, setCrop] = React.useState<Crop>()
|
||||
const [completedCrop, setCompletedCrop] = React.useState<Crop>()
|
||||
const [modalVisible, setModalVisible] = React.useState(false)
|
||||
const imgRef = React.useRef<HTMLImageElement>(null)
|
||||
const previewCanvasRef = React.useRef<HTMLCanvasElement>(null)
|
||||
|
||||
// 当 initialData 变化时,自动填充表单
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
console.log('收到API返回数据,自动填充表单:', initialData);
|
||||
|
||||
// 处理返回的数据,将其转换为表单需要的格式
|
||||
const formData: any = {};
|
||||
|
||||
const formData: Partial<People> = {};
|
||||
if (initialData.name) formData.name = initialData.name;
|
||||
if (initialData.contact) formData.contact = initialData.contact;
|
||||
if (initialData.cover) formData.cover = initialData.cover;
|
||||
@@ -35,13 +43,8 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
|
||||
if (initialData.height) formData.height = initialData.height;
|
||||
if (initialData.marital_status) formData.marital_status = initialData.marital_status;
|
||||
if (initialData.match_requirement) formData.match_requirement = initialData.match_requirement;
|
||||
if (initialData.introduction) formData.introduction = initialData.introduction;
|
||||
|
||||
// 设置表单字段值
|
||||
if (initialData.introduction) formData.introduction = initialData.introduction as Record<string, string>;
|
||||
form.setFieldsValue(formData);
|
||||
|
||||
// 显示成功消息
|
||||
// message.success('已自动填充表单,请检查并确认信息');
|
||||
}
|
||||
}, [initialData, form]);
|
||||
|
||||
@@ -52,7 +55,18 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
type FormValues = {
|
||||
name: string;
|
||||
contact?: string;
|
||||
gender: string;
|
||||
age: number;
|
||||
height?: number;
|
||||
marital_status?: string;
|
||||
introduction?: Record<string, string>;
|
||||
match_requirement?: string;
|
||||
cover?: string;
|
||||
};
|
||||
const onFinish = async (values: FormValues) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -81,22 +95,130 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
|
||||
message.error(response.error_info || '提交失败,请重试');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('提交失败:', error);
|
||||
|
||||
// 根据错误类型显示不同的错误信息
|
||||
if (error.status === 422) {
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; message?: string };
|
||||
if (err.status === 422) {
|
||||
message.error('表单数据格式有误,请检查输入内容');
|
||||
} else if (error.status >= 500) {
|
||||
} else if ((err.status ?? 0) >= 500) {
|
||||
message.error('服务器错误,请稍后重试');
|
||||
} else {
|
||||
message.error(error.message || '提交失败,请重试');
|
||||
message.error(err.message || '提交失败,请重试');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectFile = (file: File) => {
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => {
|
||||
setImgSrc(reader.result?.toString() || '')
|
||||
setModalVisible(true)
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const { width, height } = e.currentTarget
|
||||
const crop = centerCrop(
|
||||
makeAspectCrop(
|
||||
{
|
||||
unit: '%',
|
||||
width: 100,
|
||||
},
|
||||
1,
|
||||
width,
|
||||
height,
|
||||
),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
setCrop(crop)
|
||||
}
|
||||
|
||||
function canvasPreview(
|
||||
image: HTMLImageElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
crop: Crop,
|
||||
) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('No 2d context')
|
||||
}
|
||||
|
||||
const scaleX = image.naturalWidth / image.width
|
||||
const scaleY = image.naturalHeight / image.height
|
||||
const pixelRatio = window.devicePixelRatio
|
||||
canvas.width = Math.floor(crop.width * scaleX * pixelRatio)
|
||||
canvas.height = Math.floor(crop.height * scaleY * pixelRatio)
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio)
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
|
||||
const cropX = crop.x * scaleX
|
||||
const cropY = crop.y * scaleY
|
||||
|
||||
const centerX = image.naturalWidth / 2
|
||||
const centerY = image.naturalHeight / 2
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(-cropX, -cropY)
|
||||
ctx.translate(centerX, centerY)
|
||||
ctx.translate(-centerX, -centerY)
|
||||
ctx.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
)
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const onOk = async () => {
|
||||
if (completedCrop && previewCanvasRef.current && imgRef.current) {
|
||||
canvasPreview(imgRef.current, previewCanvasRef.current, completedCrop)
|
||||
previewCanvasRef.current.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
setUploading(true)
|
||||
try {
|
||||
const response = initialData?.id
|
||||
? await uploadPeopleImage(initialData.id, blob as File)
|
||||
: await uploadImage(blob as File);
|
||||
|
||||
if (response.data) {
|
||||
form.setFieldsValue({ cover: response.data })
|
||||
}
|
||||
} catch {
|
||||
message.error('图片上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setModalVisible(false)
|
||||
}
|
||||
}
|
||||
}, 'image/png')
|
||||
}
|
||||
}
|
||||
|
||||
const coverUrl = Form.useWatch('cover', form);
|
||||
|
||||
const coverPreviewNode = (
|
||||
<ImagePreview
|
||||
url={coverUrl}
|
||||
minHeight="264px"
|
||||
maxHeight="264px"
|
||||
// backgroundColor="#0b0101ff"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="people-form">
|
||||
<Form
|
||||
@@ -105,10 +227,9 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
|
||||
size="large"
|
||||
onFinish={onFinish}
|
||||
>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input placeholder="如:张三" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
@@ -119,48 +240,83 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24}>
|
||||
<Form.Item name="cover" label="人物封面">
|
||||
<Input placeholder="请输入图片链接(可留空)" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* Left Side: Form Fields */}
|
||||
<Col xs={24} md={12}>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
|
||||
<Select
|
||||
placeholder="请选择性别"
|
||||
options={[
|
||||
{ label: '男', value: '男' },
|
||||
{ label: '女', value: '女' },
|
||||
{ label: '其他/保密', value: '其他/保密' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24}>
|
||||
<Form.Item name="cover" label="人物封面">
|
||||
<Input
|
||||
placeholder="请输入图片链接(可留空)"
|
||||
suffix={
|
||||
<Button icon={<UploadOutlined />} type="text" size="small" loading={uploading} onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files?.[0]) {
|
||||
onSelectFile(target.files[0]);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}} />
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{/* Mobile Only Preview */}
|
||||
<Col xs={24} md={0} className="ant-visible-xs">
|
||||
<Form.Item label="封面预览">
|
||||
{coverPreviewNode}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
|
||||
<Select
|
||||
placeholder="请选择性别"
|
||||
options={[
|
||||
{ label: '男', value: '男' },
|
||||
{ label: '女', value: '女' },
|
||||
{ label: '其他/保密', value: '其他/保密' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="age" label="年龄" rules={[{ required: true, message: '请输入年龄' }]}>
|
||||
<InputNumber min={0} max={120} style={{ width: '100%' }} placeholder="如:28" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="height" label="身高(cm)">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={250}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="如:175(可留空)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="marital_status" label="婚姻状况">
|
||||
<Input placeholder="可自定义输入,例如:未婚、已婚、离异等" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name="age" label="年龄" rules={[{ required: true, message: '请输入年龄' }]}>
|
||||
<InputNumber min={0} max={120} style={{ width: '100%' }} placeholder="如:28" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name="height" label="身高(cm)">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={250}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="如:175(可留空)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name="marital_status" label="婚姻状况">
|
||||
<Input placeholder="可自定义输入,例如:未婚、已婚、离异等" />
|
||||
{/* Right Side: Cover Preview (PC) */}
|
||||
<Col xs={0} md={12} className="ant-hidden-xs">
|
||||
<Form.Item label="封面预览">
|
||||
{coverPreviewNode}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -181,8 +337,41 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton =
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
<Modal
|
||||
title="裁剪图片"
|
||||
open={modalVisible}
|
||||
onOk={onOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
okText="上传"
|
||||
cancelText="取消"
|
||||
|
||||
>
|
||||
{imgSrc && (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
||||
onComplete={(c) => setCompletedCrop(c)}
|
||||
aspect={1}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt="Crop me"
|
||||
src={imgSrc}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
)}
|
||||
</Modal>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
width: Math.round(completedCrop?.width ?? 0),
|
||||
height: Math.round(completedCrop?.height ?? 0),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PeopleForm;
|
||||
export default PeopleForm;
|
||||
|
||||
112
src/components/RegisterModal.tsx
Normal file
112
src/components/RegisterModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, Button, message } from 'antd';
|
||||
import { useAuth } from '../contexts/useAuth';
|
||||
import type { RegisterRequest } from '../apis/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const RegisterModal: React.FC<Props> = ({ open, onCancel }) => {
|
||||
const [form] = Form.useForm();
|
||||
const { register, sendCode } = useAuth();
|
||||
const [step, setStep] = useState('register'); // 'register' | 'verify'
|
||||
const [registerPayload, setRegisterPayload] = useState<Partial<RegisterRequest> | null>(null);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const values = await form.validateFields(['phone', 'email', 'nickname', 'password']);
|
||||
if (!values.phone && !values.email) {
|
||||
message.error('手机号和邮箱至少填写一个');
|
||||
return;
|
||||
}
|
||||
setRegisterPayload(values);
|
||||
|
||||
const target_type = values.phone ? 'phone' : 'email';
|
||||
const target = values.phone || values.email;
|
||||
|
||||
await sendCode({ target_type, target, scene: 'register' });
|
||||
message.success('验证码已发送');
|
||||
setStep('verify');
|
||||
} catch {
|
||||
message.error('发送验证码失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await register({ ...registerPayload, ...values } as RegisterRequest);
|
||||
message.success('注册成功');
|
||||
onCancel();
|
||||
} catch {
|
||||
message.error('注册失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="注册"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{step === 'register' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="nickname"
|
||||
label="昵称"
|
||||
rules={[{ required: true, message: '请输入昵称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSendCode} block>
|
||||
发送验证码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleRegister} block>
|
||||
注册
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterModal;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag, message, Modal, Dropdown, Input, Select } from 'antd';
|
||||
import { Layout, Typography, Table, Grid, Button, Space, Tag, message, Modal, Dropdown, Input, Select } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||
import type { FilterDropdownProps } from 'antd/es/table/interface';
|
||||
@@ -9,6 +9,7 @@ import './MainContent.css';
|
||||
import InputDrawer from './InputDrawer.tsx';
|
||||
import ImageModal from './ImageModal.tsx';
|
||||
import PeopleForm from './PeopleForm.tsx';
|
||||
import NumberRangeFilterDropdown from './NumberRangeFilterDropdown';
|
||||
import { getPeoples } from '../apis';
|
||||
import type { People } from '../apis';
|
||||
import { addOrUpdateRemark, deletePeople, deleteRemark, updatePeople } from '../apis/people';
|
||||
@@ -22,7 +23,7 @@ export type Resource = Omit<People, 'id'> & { id: string };
|
||||
|
||||
// 统一转换 API 返回的人员列表为表格需要的结构
|
||||
function transformPeoples(list: People[] = []): Resource[] {
|
||||
return (list || []).map((person: any) => ({
|
||||
return (list || []).map((person: Partial<People>) => ({
|
||||
id: person.id || `person-${Date.now()}-${Math.random()}`,
|
||||
name: person.name || '未知',
|
||||
gender: person.gender || '其他/保密',
|
||||
@@ -34,6 +35,7 @@ function transformPeoples(list: People[] = []): Resource[] {
|
||||
contact: person.contact || '',
|
||||
cover: person.cover || '',
|
||||
created_at: person.created_at,
|
||||
match_requirement: person.match_requirement,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -71,7 +73,7 @@ async function fetchResources(): Promise<Resource[]> {
|
||||
|
||||
return transformed;
|
||||
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('获取人员列表失败:', error);
|
||||
message.error('获取人员列表失败,使用模拟数据');
|
||||
|
||||
@@ -462,124 +464,64 @@ async function fetchResources(): Promise<Resource[]> {
|
||||
}
|
||||
|
||||
// 数字范围筛选下拉
|
||||
// 已替换为引入的 NumberRangeFilterDropdown 组件
|
||||
|
||||
function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): ColumnType<Resource> {
|
||||
return {
|
||||
title: label,
|
||||
dataIndex,
|
||||
sorter: (a: Resource, b: Resource) => Number((a as any)[dataIndex] ?? 0) - Number((b as any)[dataIndex] ?? 0),
|
||||
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) => {
|
||||
const [min, max] = String(selectedKeys?.[0] ?? ':').split(':');
|
||||
const [localMin, setLocalMin] = React.useState<number | undefined>(min ? Number(min) : undefined);
|
||||
const [localMax, setLocalMax] = React.useState<number | undefined>(max ? Number(max) : undefined);
|
||||
return (
|
||||
<div style={{ padding: 8 }}>
|
||||
<Space direction="vertical" style={{ width: 200 }}>
|
||||
<InputNumber
|
||||
placeholder="最小值"
|
||||
value={localMin}
|
||||
onChange={(v) => setLocalMin(v ?? undefined)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="最大值"
|
||||
value={localMax}
|
||||
onChange={(v) => setLocalMax(v ?? undefined)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => {
|
||||
const key = `${localMin ?? ''}:${localMax ?? ''}`;
|
||||
setSelectedKeys?.([key]);
|
||||
confirm?.({ closeDropdown: true });
|
||||
}}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setLocalMin(undefined);
|
||||
setLocalMax(undefined);
|
||||
setSelectedKeys?.([]);
|
||||
clearFilters?.();
|
||||
confirm?.({ closeDropdown: true });
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
sorter: (a: Resource, b: Resource) => {
|
||||
const av = a[dataIndex] as number | undefined;
|
||||
const bv = b[dataIndex] as number | undefined;
|
||||
return Number(av ?? 0) - Number(bv ?? 0);
|
||||
},
|
||||
filterDropdown: (props) => (
|
||||
<NumberRangeFilterDropdown
|
||||
{...props}
|
||||
clearFilters={() => props.clearFilters?.()}
|
||||
/>
|
||||
),
|
||||
onFilter: (filterValue: React.Key | boolean, record: Resource) => {
|
||||
const [minStr, maxStr] = String(filterValue).split(':');
|
||||
// 适配新组件的逗号分隔格式
|
||||
const [minStr, maxStr] = String(filterValue).split(',');
|
||||
const min = minStr ? Number(minStr) : undefined;
|
||||
const max = maxStr ? Number(maxStr) : undefined;
|
||||
const val = Number((record as any)[dataIndex] ?? NaN);
|
||||
if (Number.isNaN(val)) return false;
|
||||
if (min !== undefined && val < min) return false;
|
||||
if (max !== undefined && val > max) return false;
|
||||
const val = record[dataIndex] as number | undefined;
|
||||
|
||||
// 如果值无效,视为不匹配(或者根据需求调整)
|
||||
if (val === undefined || val === null || Number.isNaN(Number(val))) return false;
|
||||
|
||||
const numVal = Number(val);
|
||||
if (min !== undefined && !isNaN(min) && numVal < min) return false;
|
||||
if (max !== undefined && !isNaN(max) && numVal > max) return false;
|
||||
return true;
|
||||
},
|
||||
} as ColumnType<Resource>;
|
||||
}
|
||||
|
||||
// 枚举筛选下拉(用于性别等枚举类列)
|
||||
function EnumFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters, options, label }: FilterDropdownProps & { options: Array<{ label: string; value: string }>; label: string }) {
|
||||
const [val, setVal] = React.useState<string | undefined>(selectedKeys && selectedKeys[0] ? String(selectedKeys[0]) : undefined);
|
||||
return (
|
||||
<div className="byte-table-custom-filter" style={{ padding: 8 }}>
|
||||
<Space direction="vertical" style={{ width: 200 }}>
|
||||
<Select allowClear placeholder={`请选择${label}`} value={val} onChange={(v) => setVal(v)} options={options} style={{ width: '100%' }} />
|
||||
<Space>
|
||||
<Button type="primary" size="small" icon={<SearchOutlined />} onClick={() => { setSelectedKeys?.(val ? [val] : []); confirm?.({ closeDropdown: true }); }}>筛选</Button>
|
||||
<Button size="small" onClick={() => { setVal(undefined); setSelectedKeys?.([]); clearFilters?.(); confirm?.({ closeDropdown: true }); }}>重置</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildEnumFilter(dataIndex: keyof Resource, label: string, options: Array<{ label: string; value: string }>): ColumnType<Resource> {
|
||||
return {
|
||||
title: label,
|
||||
dataIndex,
|
||||
key: String(dataIndex),
|
||||
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) => {
|
||||
const [val, setVal] = React.useState<string | undefined>(
|
||||
(selectedKeys && selectedKeys[0] ? String(selectedKeys[0]) : undefined)
|
||||
);
|
||||
return (
|
||||
<div className="byte-table-custom-filter" style={{ padding: 8 }}>
|
||||
<Space direction="vertical" style={{ width: 200 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={`请选择${label}`}
|
||||
value={val}
|
||||
onChange={(v) => setVal(v)}
|
||||
options={options}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedKeys?.(val ? [val] : []);
|
||||
confirm?.({ closeDropdown: true });
|
||||
}}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setVal(undefined);
|
||||
setSelectedKeys?.([]);
|
||||
clearFilters?.();
|
||||
confirm?.({ closeDropdown: true });
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
onFilter: (filterValue: React.Key | boolean, record: Resource) =>
|
||||
String((record as any)[dataIndex]) === String(filterValue),
|
||||
filterDropdown: (props) => <EnumFilterDropdown {...props} options={options} label={label} />,
|
||||
onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record[dataIndex] ?? '') === String(filterValue),
|
||||
render: (g: string) => {
|
||||
const color = g === '男' ? 'blue' : g === '女' ? 'magenta' : 'default';
|
||||
return <Tag color={color}>{g}</Tag>;
|
||||
@@ -653,7 +595,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
} else {
|
||||
message.error(res.error_info || '删除失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
} finally {
|
||||
await reloadResources();
|
||||
@@ -687,7 +629,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record.name).includes(String(filterValue)),
|
||||
render: (text: string, record: Resource) => {
|
||||
// 图片图标逻辑
|
||||
const hasCover = record.cover && record.cover.trim() !== '';
|
||||
const hasCover = typeof record.cover === 'string' && record.cover.trim() !== '';
|
||||
const pictureIcon = (
|
||||
<PictureOutlined
|
||||
style={{
|
||||
@@ -695,7 +637,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
cursor: hasCover ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={hasCover ? () => {
|
||||
setCurrentImageUrl(record.cover);
|
||||
setCurrentImageUrl(record.cover as string);
|
||||
setImageModalVisible(true);
|
||||
} : undefined}
|
||||
/>
|
||||
@@ -833,7 +775,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_: any, record: Resource) => (
|
||||
render: (_: unknown, record: Resource) => (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
@@ -951,8 +893,9 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
} else {
|
||||
message.error(res.error_info || '更新失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) {
|
||||
} catch (err) {
|
||||
const hasErrorFields = typeof err === 'object' && err !== null && 'errorFields' in err;
|
||||
if (hasErrorFields) {
|
||||
message.error('请完善表单后再保存');
|
||||
} else {
|
||||
message.error('更新失败');
|
||||
@@ -1002,7 +945,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
}
|
||||
},
|
||||
}
|
||||
: ({} as any)
|
||||
: {}
|
||||
}
|
||||
pagination={{
|
||||
...pagination,
|
||||
@@ -1054,10 +997,21 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
)}
|
||||
{record.created_at && (
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '12px' }}>
|
||||
{record.created_at ? '录入于: ' + formatDate(record.created_at) : ''}
|
||||
{record.created_at ? '录入于: ' + formatDate(Number(record.created_at)) : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<Typography.Title level={5} style={{ color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>择偶要求</span>
|
||||
</Typography.Title>
|
||||
{record.match_requirement ? (
|
||||
<div>{String(record.match_requirement)}</div>
|
||||
) : (
|
||||
<div style={{ color: '#9ca3af' }}>暂无匹配要求</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid #f0f0f0', margin: '12px 0' }} />
|
||||
|
||||
<div>
|
||||
@@ -1066,7 +1020,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
{record.comments && record.comments.remark ? (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => {
|
||||
setEditingRemark({ recordId: record.id, content: record.comments.remark.content });
|
||||
setEditingRemark({ recordId: record.id, content: record.comments?.remark?.content || '' });
|
||||
setRemarkModalVisible(true);
|
||||
}}>修改</Button>
|
||||
<Button size="small" danger onClick={async () => {
|
||||
@@ -1078,7 +1032,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
} else {
|
||||
message.error(res.error_info || '清空失败');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('清空失败');
|
||||
}
|
||||
}}>清空</Button>
|
||||
@@ -1109,7 +1063,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
<InputDrawer
|
||||
open={inputOpen}
|
||||
onClose={onCloseInput || (() => {})}
|
||||
onResult={(list: any) => {
|
||||
onResult={(list: unknown) => {
|
||||
// setInputResult(list);
|
||||
const mapped = transformPeoples(Array.isArray(list) ? list : []);
|
||||
setData(mapped);
|
||||
@@ -1124,7 +1078,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
{/* 图片预览弹窗 */}
|
||||
<ImageModal
|
||||
visible={imageModalVisible}
|
||||
imageUrl={currentImageUrl}
|
||||
images={currentImageUrl ? [currentImageUrl] : []}
|
||||
onClose={() => {
|
||||
setImageModalVisible(false);
|
||||
setCurrentImageUrl('');
|
||||
@@ -1174,13 +1128,14 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
} else {
|
||||
message.error(res.error_info || '更新失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) {
|
||||
message.error('请完善表单后再确认');
|
||||
} else {
|
||||
message.error('更新失败');
|
||||
} catch (err) {
|
||||
const hasErrorFields = typeof err === 'object' && err !== null && 'errorFields' in err;
|
||||
if (hasErrorFields) {
|
||||
message.error('请完善表单后再确认');
|
||||
} else {
|
||||
message.error('更新失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
destroyOnHidden
|
||||
okText="确认"
|
||||
@@ -1213,7 +1168,7 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
||||
} else {
|
||||
message.error(res.error_info || '操作失败');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* 侧边菜单组件局部样式 */
|
||||
.sider-header { border-bottom: 1px solid rgba(255,255,255,0.08); color: var(--text-invert); }
|
||||
.sider-header { border-bottom: 1px solid rgba(255,255,255,0.08); color: var(--text-invert); display: flex; align-items: center; justify-content: space-between; padding: 12px; }
|
||||
|
||||
/* 收起时图标水平居中(仅 PC 端 Sider 使用) */
|
||||
.sider-header.collapsed { justify-content: center; }
|
||||
|
||||
.sider-header.collapsed .sider-title,
|
||||
.sider-header.collapsed .sider-settings-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端汉堡触发按钮位置 */
|
||||
.mobile-menu-trigger {
|
||||
position: fixed;
|
||||
@@ -52,4 +57,12 @@
|
||||
}
|
||||
.mobile-menu-trigger:hover {
|
||||
background: rgba(16,185,129,0.35);
|
||||
}
|
||||
}
|
||||
.sider-user { display: inline-flex; align-items: center; gap: 12px; }
|
||||
.sider-avatar-container { display: inline-flex; align-items: center; justify-content: center; }
|
||||
.sider-avatar-frame { width: 40px; height: 40px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.6); box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; background: #ffffff; }
|
||||
.sider-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; }
|
||||
.sider-avatar-icon { font-size: 20px; color: #64748b; }
|
||||
.sider-title { font-weight: 600; color: #e5e7eb; }
|
||||
.sider-settings-btn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; color: #e5e7eb; background: transparent; border: none; cursor: pointer; }
|
||||
.sider-settings-btn:hover { background: rgba(255,255,255,0.08); }
|
||||
@@ -1,11 +1,14 @@
|
||||
import LoginModal from './LoginModal';
|
||||
import RegisterModal from './RegisterModal';
|
||||
import { useAuth } from '../contexts/useAuth';
|
||||
import React from 'react';
|
||||
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
|
||||
import { HeartOutlined, FormOutlined, UnorderedListOutlined, MenuOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { FormOutlined, UnorderedListOutlined, MenuOutlined, CopyOutlined, UserOutlined, SettingOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import './SiderMenu.css';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
// 新增:支持外部导航回调 + 受控选中态
|
||||
type Props = {
|
||||
onNavigate?: (key: string) => void;
|
||||
selectedKey?: string;
|
||||
@@ -14,6 +17,10 @@ type Props = {
|
||||
};
|
||||
|
||||
const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMobileToggle }) => {
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = React.useState(false);
|
||||
const [isRegisterModalOpen, setIsRegisterModalOpen] = React.useState(false);
|
||||
const { isAuthenticated, user, login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
@@ -36,7 +43,6 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
setCollapsed(isMobile);
|
||||
}, [isMobile]);
|
||||
|
||||
// 根据外部 selectedKey 同步选中态
|
||||
React.useEffect(() => {
|
||||
if (selectedKey) {
|
||||
setSelectedKeys([selectedKey]);
|
||||
@@ -44,16 +50,46 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
}, [selectedKey]);
|
||||
|
||||
const items = [
|
||||
{ key: 'custom-list', label: '客户列表', icon: <TeamOutlined /> },
|
||||
{ key: 'custom', label: '客户录入', icon: <UserOutlined /> },
|
||||
{ key: 'home', label: '录入资源', icon: <FormOutlined /> },
|
||||
{ key: 'batch', label: '批量录入', icon: <CopyOutlined /> },
|
||||
{ key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },
|
||||
];
|
||||
|
||||
// 移动端:使用 Drawer 覆盖主内容
|
||||
const renderSiderHeader = (options?: { setOpen?: (v: boolean) => void; collapsed?: boolean }) => (
|
||||
<div className={`sider-header ${options?.collapsed ? 'collapsed' : ''}`}>
|
||||
{isAuthenticated && user ? (
|
||||
<>
|
||||
<div className="sider-user">
|
||||
<div className="sider-avatar-container">
|
||||
<div className="sider-avatar-frame">
|
||||
{user.avatar_link ? (
|
||||
<img src={user.avatar_link} alt="avatar" className="sider-avatar" />
|
||||
) : (
|
||||
<UserOutlined className="sider-avatar-icon" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sider-title">{user.nickname}</div>
|
||||
</div>
|
||||
<button type="button" className="sider-settings-btn" aria-label="设置" onClick={() => { navigate('/user'); options?.setOpen?.(false); }}>
|
||||
<SettingOutlined />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="primary" style={{ marginRight: 8 }} onClick={() => setIsLoginModalOpen(true)}>登录</Button>
|
||||
<Button onClick={() => setIsRegisterModalOpen(true)}>注册</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
const open = mobileOpen ?? internalMobileOpen;
|
||||
const setOpen = (v: boolean) => (onMobileToggle ? onMobileToggle(v) : setInternalMobileOpen(v));
|
||||
const showInternalTrigger = !onMobileToggle; // 若无外部控制,则显示内部按钮
|
||||
const showInternalTrigger = !onMobileToggle;
|
||||
return (
|
||||
<>
|
||||
{showInternalTrigger && (
|
||||
@@ -73,13 +109,7 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
|
||||
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
|
||||
>
|
||||
<div className="sider-header">
|
||||
<HeartOutlined style={{ fontSize: 22 }} />
|
||||
<div>
|
||||
<div className="sider-title">单身管理</div>
|
||||
<div className="sider-desc">录入、展示与搜索你的单身资源</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderSiderHeader({ setOpen })}
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
@@ -87,36 +117,36 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
onClick={({ key }) => {
|
||||
const k = String(key);
|
||||
setSelectedKeys([k]);
|
||||
setOpen(false); // 选择后自动收起
|
||||
setOpen(false);
|
||||
onNavigate?.(k);
|
||||
}}
|
||||
items={items}
|
||||
/>
|
||||
</Drawer>
|
||||
<LoginModal
|
||||
open={isLoginModalOpen}
|
||||
onCancel={() => setIsLoginModalOpen(false)}
|
||||
onOk={async (values) => {
|
||||
await login(values);
|
||||
setIsLoginModalOpen(false);
|
||||
}}
|
||||
title="登录"
|
||||
/>
|
||||
<RegisterModal open={isRegisterModalOpen} onCancel={() => setIsRegisterModalOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// PC 端:保持 Sider 行为不变
|
||||
return (
|
||||
<Sider
|
||||
width={260}
|
||||
theme="dark"
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={(c) => setCollapsed(c)}
|
||||
breakpoint="md"
|
||||
collapsedWidth={64}
|
||||
theme="dark"
|
||||
onCollapse={(value) => setCollapsed(value)}
|
||||
className="sider-menu"
|
||||
width={240}
|
||||
>
|
||||
<div className={`sider-header ${collapsed ? 'collapsed' : ''}`}>
|
||||
<HeartOutlined style={{ fontSize: 22 }} />
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div className="sider-title">单身管理</div>
|
||||
<div className="sider-desc">录入、展示与搜索你的单身资源</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSiderHeader({ collapsed })}
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
@@ -128,6 +158,16 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
}}
|
||||
items={items}
|
||||
/>
|
||||
<LoginModal
|
||||
open={isLoginModalOpen}
|
||||
onCancel={() => setIsLoginModalOpen(false)}
|
||||
onOk={async (values) => {
|
||||
await login(values);
|
||||
setIsLoginModalOpen(false);
|
||||
}}
|
||||
title="登录"
|
||||
/>
|
||||
<RegisterModal open={isRegisterModalOpen} onCancel={() => setIsRegisterModalOpen(false)} />
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,6 +41,4 @@
|
||||
}
|
||||
.icon-btn:hover { background: rgba(16,185,129,0.35); }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.topbar { grid-template-columns: 56px 1fr 56px; }
|
||||
}
|
||||
.layout-desktop .topbar { grid-template-columns: 56px 1fr 56px; }
|
||||
418
src/components/UserProfile.tsx
Normal file
418
src/components/UserProfile.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import React from 'react'
|
||||
import { Form, Input, Button, message, Card, Space, Upload, Modal } from 'antd'
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop'
|
||||
import { useAuth } from '../contexts/useAuth'
|
||||
import { updateMe, deleteUser, uploadAvatar, updatePhone, updateEmail } from '../apis'
|
||||
import { UserOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import LoginModal from './LoginModal';
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
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 UserProfile: React.FC = () => {
|
||||
const { user, logout, refreshUser, sendCode, login, clearLocalSession } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [imgSrc, setImgSrc] = React.useState('')
|
||||
const [crop, setCrop] = React.useState<Crop>()
|
||||
const [completedCrop, setCompletedCrop] = React.useState<Crop>()
|
||||
const [modalVisible, setModalVisible] = React.useState(false)
|
||||
const [uploading, setUploading] = React.useState(false)
|
||||
const imgRef = React.useRef<HTMLImageElement>(null)
|
||||
const previewCanvasRef = React.useRef<HTMLCanvasElement>(null)
|
||||
const [editVisible, setEditVisible] = React.useState(false)
|
||||
const [editType, setEditType] = React.useState<'phone' | 'email' | null>(null)
|
||||
const [editStep, setEditStep] = React.useState<'input' | 'verify'>('input')
|
||||
const [editLoading, setEditLoading] = React.useState(false)
|
||||
const [editForm] = Form.useForm()
|
||||
const [reauthVisible, setReauthVisible] = React.useState(false)
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
body: { display: 'flex', flexDirection: 'column', gap: 16 },
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
nickname: user?.nickname || '',
|
||||
avatar_link: user?.avatar_link || '',
|
||||
phone: user?.phone || '',
|
||||
email: user?.email || '',
|
||||
})
|
||||
}, [user, form])
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (values.nickname !== user?.nickname) payload.nickname = values.nickname
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
message.info('没有需要保存的变更')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const res = await updateMe(payload)
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('已保存')
|
||||
} else {
|
||||
message.error(res.error_info || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
navigate('/')
|
||||
} catch {
|
||||
message.error('登出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async () => {
|
||||
Modal.confirm({
|
||||
title: '确定注销吗?',
|
||||
content: '注销后所有数据均会被清理,且无法找回',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
setReauthVisible(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onSelectFile = (file: File) => {
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => {
|
||||
setImgSrc(reader.result?.toString() || '')
|
||||
setModalVisible(true)
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
const { width, height } = e.currentTarget
|
||||
const crop = centerCrop(
|
||||
makeAspectCrop(
|
||||
{
|
||||
unit: '%',
|
||||
width: 90,
|
||||
},
|
||||
1,
|
||||
width,
|
||||
height,
|
||||
),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
setCrop(crop)
|
||||
}
|
||||
|
||||
const uploadBlob = async (blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
message.error('图片处理失败')
|
||||
setUploading(false)
|
||||
setModalVisible(false)
|
||||
return
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', new File([blob], 'avatar.png', { type: 'image/png' }))
|
||||
try {
|
||||
const res = await uploadAvatar(formData)
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('头像更新成功')
|
||||
} else {
|
||||
message.error(res.error_info || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
message.error('上传失败')
|
||||
} finally {
|
||||
refreshUser()
|
||||
setUploading(false)
|
||||
setModalVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onOk = async () => {
|
||||
setUploading(true)
|
||||
if (completedCrop && previewCanvasRef.current && imgRef.current) {
|
||||
canvasPreview(imgRef.current, previewCanvasRef.current, completedCrop)
|
||||
const canvas = previewCanvasRef.current
|
||||
const MAX_SIZE = 128
|
||||
if (canvas.width > MAX_SIZE) {
|
||||
const resizeCanvas = document.createElement('canvas')
|
||||
const ctx = resizeCanvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
message.error('无法处理图片')
|
||||
setModalVisible(false)
|
||||
return
|
||||
}
|
||||
resizeCanvas.width = MAX_SIZE
|
||||
resizeCanvas.height = MAX_SIZE
|
||||
ctx.drawImage(canvas, 0, 0, MAX_SIZE, MAX_SIZE)
|
||||
resizeCanvas.toBlob((blob) => {
|
||||
uploadBlob(blob)
|
||||
}, 'image/png')
|
||||
} else {
|
||||
canvas.toBlob((blob) => {
|
||||
uploadBlob(blob)
|
||||
}, 'image/png')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onEditClick = (type: 'phone' | 'email') => {
|
||||
setEditType(type)
|
||||
setEditStep('input')
|
||||
setEditVisible(true)
|
||||
editForm.resetFields()
|
||||
}
|
||||
|
||||
const handleSendEditCode = async () => {
|
||||
try {
|
||||
const field = editType === 'phone' ? 'phone' : 'email'
|
||||
const values = await editForm.validateFields([field])
|
||||
const target = values[field]
|
||||
await sendCode({ target_type: field === 'phone' ? 'phone' : 'email', target, scene: 'update' })
|
||||
message.success('验证码已发送')
|
||||
setEditStep('verify')
|
||||
} catch {
|
||||
message.error('发送验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitEdit = async () => {
|
||||
try {
|
||||
setEditLoading(true)
|
||||
const phoneOrEmail = editType === 'phone' ? await editForm.validateFields(['phone', 'code']) : await editForm.validateFields(['email', 'code'])
|
||||
if (editType === 'phone') {
|
||||
const res = await updatePhone({ phone: phoneOrEmail.phone, code: phoneOrEmail.code })
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('手机号已更新')
|
||||
setEditVisible(false)
|
||||
} else {
|
||||
message.error(res.error_info || '更新失败')
|
||||
}
|
||||
} else if (editType === 'email') {
|
||||
const res = await updateEmail({ email: phoneOrEmail.email, code: phoneOrEmail.code })
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('邮箱已更新')
|
||||
setEditVisible(false)
|
||||
} else {
|
||||
message.error(res.error_info || '更新失败')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('更新失败')
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card style={{ width: '100%' }} styles={styles}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Upload
|
||||
beforeUpload={onSelectFile}
|
||||
showUploadList={false}
|
||||
accept='image/*'
|
||||
>
|
||||
{user?.avatar_link ? (
|
||||
<img src={user.avatar_link} alt="avatar" style={{ width: 96, height: 96, borderRadius: '50%', objectFit: 'cover', cursor: 'pointer' }} />
|
||||
) : (
|
||||
<div style={{ width: 96, height: 96, borderRadius: '50%', backgroundColor: '#f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||
<UserOutlined style={{ fontSize: 48, color: '#999' }} />
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
<p style={{ fontSize: 12, color: '#999', textAlign: 'center', marginTop: 8 }}>点击更换头像</p>
|
||||
</div>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="nickname" label="昵称" rules={[{ required: true, message: '请输入昵称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input
|
||||
readOnly
|
||||
style={{ width: '100%', backgroundColor: '#f5f5f5' }}
|
||||
suffix={<Button type="text" icon={<EditOutlined />} onClick={() => onEditClick('phone')}>编辑</Button>}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input
|
||||
readOnly
|
||||
style={{ width: '100%', backgroundColor: '#f5f5f5' }}
|
||||
suffix={<Button type="text" icon={<EditOutlined />} onClick={() => onEditClick('email')}>编辑</Button>}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Button type="primary" onClick={onSave} loading={saving}>保存</Button>
|
||||
<Button onClick={onLogout}>登出</Button>
|
||||
<Button danger onClick={onDelete}>注销</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Modal
|
||||
title="裁剪头像"
|
||||
open={modalVisible}
|
||||
onOk={onOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={uploading}
|
||||
maskClosable={!uploading}
|
||||
closable={!uploading}
|
||||
>
|
||||
{imgSrc && (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={c => setCrop(c)}
|
||||
onComplete={c => setCompletedCrop(c)}
|
||||
aspect={1}
|
||||
circularCrop
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt="Crop me"
|
||||
src={imgSrc}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title={editType === 'phone' ? '修改手机号' : editType === 'email' ? '修改邮箱' : ''}
|
||||
open={editVisible}
|
||||
onCancel={() => setEditVisible(false)}
|
||||
footer={null}
|
||||
confirmLoading={editLoading}
|
||||
maskClosable={!editLoading}
|
||||
closable={!editLoading}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
{editStep === 'input' ? (
|
||||
<>
|
||||
{editType === 'phone' ? (
|
||||
<Form.Item name="phone" label="新手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="email" label="新邮箱" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '请输入有效的邮箱地址' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSendEditCode} block>
|
||||
发送验证码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSubmitEdit} block loading={editLoading}>
|
||||
提交
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
objectFit: 'contain',
|
||||
width: completedCrop?.width,
|
||||
height: completedCrop?.height,
|
||||
}}
|
||||
/>
|
||||
<LoginModal
|
||||
open={reauthVisible}
|
||||
onCancel={() => setReauthVisible(false)}
|
||||
title="请输入密码以确认注销"
|
||||
okText="注销"
|
||||
username={user?.phone || user?.email}
|
||||
usernameReadOnly
|
||||
hideSuccessMessage
|
||||
onOk={async (values) => {
|
||||
try {
|
||||
await login(values);
|
||||
const res = await deleteUser();
|
||||
if (res.error_code === 0) {
|
||||
clearLocalSession();
|
||||
message.success('账户已注销');
|
||||
navigate('/');
|
||||
} else {
|
||||
message.error(res.error_info || '注销失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('登录或注销失败');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfile
|
||||
105
src/contexts/AuthContext.tsx
Normal file
105
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { login as apiLogin, register as apiRegister, sendCode as apiSendCode, getMe as apiGetMe, logout as apiLogout } from '../apis';
|
||||
import type { LoginRequest, RegisterRequest, User, SendCodeRequest } from '../apis/types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (data: LoginRequest) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
clearLocalSession: () => void;
|
||||
register: (data: RegisterRequest) => Promise<void>;
|
||||
sendCode: (data: SendCodeRequest) => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
|
||||
useEffect(() => {
|
||||
const validateSession = async () => {
|
||||
try {
|
||||
const response = await apiGetMe();
|
||||
if (response.data) {
|
||||
setUser(response.data);
|
||||
}
|
||||
} catch {
|
||||
setToken(null);
|
||||
localStorage.removeItem('token');
|
||||
void 0;
|
||||
}
|
||||
};
|
||||
validateSession();
|
||||
}, [token]);
|
||||
|
||||
const login = async (data: LoginRequest) => {
|
||||
const response = await apiLogin(data);
|
||||
if (response.data?.token) {
|
||||
const newToken = response.data.token;
|
||||
setToken(newToken);
|
||||
localStorage.setItem('token', newToken);
|
||||
}
|
||||
try {
|
||||
const me = await apiGetMe();
|
||||
if (me.data) {
|
||||
setUser(me.data);
|
||||
}
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await apiLogout();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
const clearLocalSession = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
const register = async (data: RegisterRequest) => {
|
||||
await apiRegister(data);
|
||||
// 注册后可以根据业务需求选择是否自动登录
|
||||
};
|
||||
|
||||
const sendCode = async (data: SendCodeRequest) => {
|
||||
await apiSendCode(data);
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
const me = await apiGetMe();
|
||||
if (me.data) {
|
||||
setUser(me.data);
|
||||
}
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: !!token || !!user,
|
||||
login,
|
||||
logout,
|
||||
clearLocalSession,
|
||||
register,
|
||||
sendCode,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export default AuthContext;
|
||||
12
src/contexts/useAuth.ts
Normal file
12
src/contexts/useAuth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useContext } from 'react';
|
||||
import AuthContext from './AuthContext';
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default useAuth;
|
||||
@@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import 'antd/dist/reset.css'
|
||||
import './styles/base.css'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './contexts/AuthContext.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
@@ -14,7 +15,9 @@ createRoot(document.getElementById('root')!).render(
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -63,7 +63,5 @@
|
||||
}
|
||||
|
||||
/* 小屏优化:输入区域内边距更紧凑 */
|
||||
@media (max-width: 768px) {
|
||||
.content-body { padding: 16px; border-radius: 0; }
|
||||
.input-panel-wrapper { padding: 12px 12px 16px 12px; }
|
||||
}
|
||||
.layout-mobile .content-body { padding: 16px; border-radius: 0; }
|
||||
.layout-mobile .input-panel-wrapper { padding: 12px 12px 16px 12px; }
|
||||
304
src/utils/shareImageGenerator.ts
Normal file
304
src/utils/shareImageGenerator.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
|
||||
export interface ShareData {
|
||||
// 1. 照片
|
||||
imageBlob: Blob;
|
||||
|
||||
// 2. 标签区 (左下)
|
||||
tags: {
|
||||
assets: string; // 资产等级 (A6, A7...)
|
||||
liquidAssets: string; // 流动资产 (C6, C7...)
|
||||
income: string; // 收入 (50万)
|
||||
};
|
||||
|
||||
// 3. 信息区 (右上)
|
||||
basicInfo: {
|
||||
age: string; // 30岁
|
||||
height: string; // 175cm
|
||||
degree: string; // 本科
|
||||
};
|
||||
details: {
|
||||
city: string; // 常住城市
|
||||
isSingleChild: string; // 是/否/-
|
||||
houseCar: string; // 有房有贷; 有车无贷
|
||||
};
|
||||
introduction: string; // 详细介绍文本
|
||||
|
||||
// 4. 红娘区 (右下)
|
||||
matchmaker: {
|
||||
orgName: string; // IF.U
|
||||
name: string; // 红娘昵称
|
||||
};
|
||||
}
|
||||
|
||||
export const generateShareImage = (data: ShareData): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(data.imageBlob);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('Canvas context not supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 设置画布尺寸 960x540
|
||||
const totalWidth = 960;
|
||||
const totalHeight = 540;
|
||||
|
||||
canvas.width = totalWidth;
|
||||
canvas.height = totalHeight;
|
||||
|
||||
// 填充白色背景
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// ==========================================
|
||||
// 1. 左上:照片区域 (0, 0, 480, 480)
|
||||
// ==========================================
|
||||
const photoSize = 480;
|
||||
// 绘制照片,缩放至 480x480
|
||||
ctx.save();
|
||||
// 绘制圆角遮罩(如果需要圆角,用户示意图左上角似乎是直角,只有整个卡片可能有圆角,这里先直角)
|
||||
ctx.drawImage(img, 0, 0, photoSize, photoSize);
|
||||
|
||||
// 添加一个内阴影或边框让照片更清晰(可选,参考示意图有浅蓝边框)
|
||||
ctx.strokeStyle = '#d9e8ff'; // 浅蓝色边框
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeRect(0, 0, photoSize, photoSize);
|
||||
|
||||
// 左上角 "照片" 字样 (已移除)
|
||||
ctx.restore();
|
||||
|
||||
// ==========================================
|
||||
// 2. 左下:标签区域 (0, 480, 480, 60)
|
||||
// ==========================================
|
||||
const tagAreaY = 480;
|
||||
const tagAreaHeight = 60;
|
||||
|
||||
// 区域宽 480,平均分给3个标签
|
||||
// 左右留白 20px -> 可用 440
|
||||
// 间距 20px * 2 = 40
|
||||
// 标签宽 (440 - 40) / 3 = 133.33 -> 取 130
|
||||
const tagWidth = 130;
|
||||
const tagHeight = 40; // 略微加高
|
||||
const tagY = tagAreaY + (tagAreaHeight - tagHeight) / 2;
|
||||
|
||||
// 标签配置
|
||||
const tags = [
|
||||
{ label: data.tags.assets || '-', bg: '#E6E6FA' }, // 资产 - 淡紫
|
||||
{ label: data.tags.liquidAssets || '-', bg: '#E6E6FA' }, // 流动 - 淡紫
|
||||
{ label: data.tags.income || '-', bg: '#E6E6FA' } // 收入 - 淡紫
|
||||
];
|
||||
|
||||
// 计算起始 X,居中排列
|
||||
// 总宽 480, 内容宽 3 * 130 + 2 * 20 = 390 + 40 = 430
|
||||
// 剩余 50, padding-left 25
|
||||
const startX = 25;
|
||||
const gap = 20;
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// 字体适当调大
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
|
||||
tags.forEach((tag, index) => {
|
||||
const x = startX + index * (tagWidth + gap);
|
||||
|
||||
// 绘制胶囊背景
|
||||
ctx.fillStyle = tag.bg;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, tagY, tagWidth, tagHeight, tagHeight / 2);
|
||||
ctx.fill();
|
||||
|
||||
// 绘制黑色边框
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制文字
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillText(tag.label, x + tagWidth / 2, tagY + tagHeight / 2 + 1);
|
||||
});
|
||||
ctx.restore();
|
||||
|
||||
// ==========================================
|
||||
// 3. 右上:信息区域 (480, 0, 480, 480)
|
||||
// ==========================================
|
||||
const infoX = 480;
|
||||
|
||||
// 3.1 顶部三个便签 (年龄, 身高, 学历)
|
||||
// y: 30
|
||||
const noteY = 30;
|
||||
// 宽度增加
|
||||
const noteWidth = 130;
|
||||
const noteHeight = 50;
|
||||
const noteGap = 20;
|
||||
// 480 - (130*3 + 20*2) = 480 - 430 = 50, padding 25
|
||||
const noteStartX = infoX + 25;
|
||||
|
||||
const notes = [
|
||||
{ label: data.basicInfo.age || '-', title: '年龄' },
|
||||
{ label: data.basicInfo.height || '-', title: '身高' },
|
||||
{ label: data.basicInfo.degree || '-', title: '学历' }
|
||||
];
|
||||
|
||||
ctx.save();
|
||||
notes.forEach((note, index) => {
|
||||
const x = noteStartX + index * (noteWidth + noteGap);
|
||||
|
||||
// 模拟便签纸样式 (淡黄色背景 + 阴影)
|
||||
ctx.fillStyle = '#FFF8DC'; // Cornsilk
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.shadowOffsetX = 2;
|
||||
ctx.shadowOffsetY = 2;
|
||||
|
||||
ctx.beginPath();
|
||||
// 简单矩形
|
||||
ctx.fillRect(x, noteY, noteWidth, noteHeight);
|
||||
|
||||
// 清除阴影绘制边框
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.strokeStyle = '#DAA520'; // GoldenRod
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, noteY, noteWidth, noteHeight);
|
||||
|
||||
// 绘制文字
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// 字体不变或微调,需求说信息显示区文字大小可以不变,但便签比较大,稍微大一点点好看
|
||||
ctx.font = 'bold 20px sans-serif';
|
||||
ctx.fillText(note.label, x + noteWidth / 2, noteY + noteHeight / 2);
|
||||
});
|
||||
ctx.restore();
|
||||
|
||||
// 3.2 列表信息 (城市, 独生, 房车)
|
||||
// y 从 120 开始 (noteY + noteHeight + gap)
|
||||
let currentY = 130;
|
||||
const labelX = infoX + 40;
|
||||
const valueX = infoX + 160;
|
||||
const lineHeight = 50; // 行高增加
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const listItems = [
|
||||
{ label: '【城市】', value: data.details.city || '常住城市' },
|
||||
{ label: '【独生子女】', value: data.details.isSingleChild || '-' },
|
||||
{ label: '【房车情况】', value: data.details.houseCar || '-' },
|
||||
];
|
||||
|
||||
listItems.forEach(item => {
|
||||
// 标签 (加粗) - 保持大小或微调
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.fillStyle = '#333333';
|
||||
ctx.fillText(item.label, labelX, currentY);
|
||||
|
||||
// 值
|
||||
ctx.font = '18px sans-serif';
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillText(item.value, valueX, currentY);
|
||||
|
||||
currentY += lineHeight;
|
||||
});
|
||||
|
||||
// 3.3 详细介绍
|
||||
// currentY 约为 130 + 50*3 = 280
|
||||
currentY += 10; // 增加一点间距
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.fillStyle = '#333333';
|
||||
ctx.fillText('【详细介绍】', labelX, currentY);
|
||||
|
||||
currentY += 35;
|
||||
ctx.font = '18px sans-serif';
|
||||
ctx.fillStyle = '#555555';
|
||||
|
||||
// 文本换行与截断处理
|
||||
const maxWidth = 400; // 480 - 80 padding
|
||||
// 空间计算: 480(底线) - 280(当前Y) = 200px 剩余高度
|
||||
// 行高 30px -> 约 6 行
|
||||
const maxLines = 6;
|
||||
const textX = labelX; // 对齐
|
||||
|
||||
const words = (data.introduction || '').split('');
|
||||
let line = '';
|
||||
let linesDrawn = 0;
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (linesDrawn >= maxLines) break;
|
||||
|
||||
const testLine = line + words[i];
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && i > 0) {
|
||||
// 这一行满了
|
||||
if (linesDrawn === maxLines - 1) {
|
||||
// 最后一行,需要截断加...
|
||||
line = line.substring(0, line.length - 1) + '...';
|
||||
ctx.fillText(line, textX, currentY);
|
||||
linesDrawn++;
|
||||
break;
|
||||
} else {
|
||||
ctx.fillText(line, textX, currentY);
|
||||
line = words[i];
|
||||
currentY += 30; // 行高
|
||||
linesDrawn++;
|
||||
}
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
// 绘制最后一行 (如果没有超限)
|
||||
if (linesDrawn < maxLines && line.length > 0) {
|
||||
ctx.fillText(line, textX, currentY);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// ==========================================
|
||||
// 4. 右下:红娘区域 (480, 480, 480, 60)
|
||||
// ==========================================
|
||||
const footerX = 480;
|
||||
const footerY = 480;
|
||||
|
||||
ctx.save();
|
||||
// 背景
|
||||
ctx.fillStyle = '#E6E6FA'; // Lavender (淡紫色)
|
||||
// 绘制带边框的圆角矩形
|
||||
const footerRectX = footerX + 20;
|
||||
const footerRectY = footerY + 10;
|
||||
const footerRectW = 440;
|
||||
const footerRectH = 40;
|
||||
|
||||
ctx.roundRect(footerRectX, footerRectY, footerRectW, footerRectH, 20);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// 文字
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.font = '18px sans-serif'; // 调大字体
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const footerText = `${data.matchmaker.orgName} - ${data.matchmaker.name}`;
|
||||
ctx.fillText(footerText, footerRectX + footerRectW / 2, footerRectY + footerRectH / 2);
|
||||
ctx.restore();
|
||||
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load base image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,33 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import fs from 'node:fs'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// Vite 默认支持环境变量,无需额外配置
|
||||
// 环境变量加载优先级:
|
||||
// .env.local > .env.[mode] > .env
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const keyPath = env.VITE_HTTPS_KEY_PATH
|
||||
const certPath = env.VITE_HTTPS_CERT_PATH
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
// Vite 默认支持环境变量,无需额外配置
|
||||
// 环境变量加载优先级:
|
||||
// .env.local > .env.[mode] > .env
|
||||
server: {
|
||||
https: keyPath && certPath
|
||||
? {
|
||||
key: fs.readFileSync(keyPath),
|
||||
cert: fs.readFileSync(certPath),
|
||||
}
|
||||
: undefined,
|
||||
host: 'localhost',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8099',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user