5 Commits

Author SHA1 Message Date
fee01abb60 feat: support custom management
- add page to register a custom
- add page to show custom table
- custom can be deleted and updated
- custom can be shared by a generated image
- refactor recognization api for both people and custom
2025-12-18 23:54:38 +08:00
cb59b3693d feat: support upload cover for people
- wrappe the post and delete people image api, and upload image api
- add cover preview when register and edit
- support upload image when edit cover of people in edit and register
2025-11-25 20:46:12 +08:00
0256f0cf22 fix: npm lint errors 2025-11-24 09:44:02 +08:00
b1fb054714 feat: show match requirements of people in detail panel 2025-11-24 02:24:28 +08:00
c923244a68 feat: support user management
- sign up and delete account with email or phone
- sign in and sign out
- update nickname and avatar
- update account phone or email
2025-11-22 09:53:47 +08:00
45 changed files with 4017 additions and 347 deletions

5
.env
View File

@@ -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

View File

@@ -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

View File

@@ -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": {

View File

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

View File

@@ -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
View File

@@ -0,0 +1,76 @@
// 客户管理相关 API
import { get, post, put, del, upload } from './request';
import { API_ENDPOINTS } from './config';
import type {
PostCustomRequest,
Custom,
ApiResponse,
PaginatedResponse,
} from './types';
/**
* 获取客户列表
* @param params 查询参数
* @returns Promise<ApiResponse<PaginatedResponse<Custom>>>
*/
export async function getCustoms(params?: Record<string, string | number>): Promise<ApiResponse<PaginatedResponse<Custom>>> {
const response = await get<ApiResponse<Custom[] | PaginatedResponse<Custom>>>(API_ENDPOINTS.CUSTOMS, params);
// 兼容处理:如果后端返回的是数组,封装为分页结构
if (Array.isArray(response.data)) {
return {
...response,
data: {
items: response.data,
total: response.data.length,
limit: Number(params?.limit) || 1000,
offset: Number(params?.offset) || 0,
}
};
}
// 如果后端返回的就是分页结构,直接返回
return response as ApiResponse<PaginatedResponse<Custom>>;
}
/**
* 创建客户信息
* @param custom 客户信息对象
* @returns Promise<ApiResponse>
*/
export async function createCustom(custom: Custom): Promise<ApiResponse> {
const requestData: PostCustomRequest = { custom };
console.log('创建客户请求数据:', requestData);
return post<ApiResponse>(API_ENDPOINTS.CUSTOM, requestData);
}
/**
* 更新客户信息
* @param id 客户ID
* @param custom 客户信息对象
* @returns Promise<ApiResponse>
*/
export async function updateCustom(id: string, custom: Custom): Promise<ApiResponse> {
const requestData: PostCustomRequest = { custom };
return put<ApiResponse>(`${API_ENDPOINTS.CUSTOM}/${id}`, requestData);
}
/**
* 删除客户
* @param id 客户ID
* @returns Promise<ApiResponse>
*/
export async function deleteCustom(id: string): Promise<ApiResponse> {
return del<ApiResponse>(`${API_ENDPOINTS.CUSTOM}/${id}`);
}
/**
* 上传客户图片
* @param id 客户ID
* @param file 图片文件
* @returns Promise<ApiResponse<string>>
*/
export async function uploadCustomImage(id: string, file: File): Promise<ApiResponse<string>> {
return upload<ApiResponse<string>>(API_ENDPOINTS.CUSTOM_IMAGE_BY_ID(id), file, 'image');
}

View File

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

View File

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

View File

@@ -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 人员信息数组

View File

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

View 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;
}

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
/* 客户信息录入表单样式 */
.custom-form {
margin-top: 16px;
padding: 20px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-card);
color: var(--text-primary);
}
.custom-form .ant-form-item-label > label {
color: var(--text-secondary);
}
.custom-form .ant-input,
.custom-form .ant-input-affix-wrapper,
.custom-form .ant-select-selector,
.custom-form .ant-input-number,
.custom-form .ant-input-number-input {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
}
.custom-form .ant-select-selection-item,
.custom-form .ant-select-selection-placeholder {
color: var(--placeholder);
}
.custom-form .ant-select-selection-item { color: var(--text-primary); }
/* 输入占位与聚焦态 */
.custom-form .ant-input::placeholder { color: var(--placeholder); }
.custom-form .ant-input-number-input::placeholder { color: var(--placeholder); }
.custom-form .ant-input:focus,
.custom-form .ant-input-affix-wrapper-focused,
.custom-form .ant-select-focused .ant-select-selector,
.custom-form .ant-input-number-focused {
border-color: var(--color-primary-600) !important;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}

View File

@@ -0,0 +1,377 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Select, InputNumber, Button, message, Row, Col, Radio, Typography, Grid } from 'antd';
import 'react-image-crop/dist/ReactCrop.css';
import type { FormInstance } from 'antd';
import './CustomForm.css';
import KeyValueList from './KeyValueList.tsx';
import ImageInputGroup from './ImageInputGroup.tsx';
import { createCustom, updateCustom, type Custom } from '../apis';
const { TextArea } = Input;
const { useBreakpoint } = Grid;
interface CustomFormProps {
initialData?: Partial<Custom>;
hideSubmitButton?: boolean;
onFormReady?: (form: FormInstance) => void;
onSuccess?: () => void;
}
const CustomForm: React.FC<CustomFormProps> = ({ initialData, hideSubmitButton = false, onFormReady, onSuccess }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (initialData) {
// 需要处理一些数据转换,例如 birth -> age (如果后端给的是 birth year)
// 这里假设 initialData 里已经处理好了,或者先直接透传
const formData = { ...initialData };
// 如果有 birth转换为 age 显示
if (formData.birth) {
// 如果 birth 小于 100假设直接存的年龄直接显示
if (formData.birth < 100) {
// @ts-expect-error: 临时给 form 设置 age 字段
formData.age = formData.birth;
} else {
// 否则假设是年份,计算年龄
const currentYear = new Date().getFullYear();
// @ts-expect-error: 临时给 form 设置 age 字段
formData.age = currentYear - formData.birth;
}
}
// images 数组处理,确保是字符串数组
if (formData.images && Array.isArray(formData.images)) {
if (formData.images.length === 0) {
formData.images = [''];
}
} else {
formData.images = [''];
}
form.setFieldsValue(formData);
} else {
// 初始化空状态
form.setFieldsValue({
images: ['']
});
}
}, [initialData, form]);
useEffect(() => {
onFormReady?.(form);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onFinish = async (values: Custom & { age?: number }) => {
setLoading(true);
try {
const currentYear = new Date().getFullYear();
const birth = currentYear - (values.age || 0);
const customData: Custom = {
name: values.name,
gender: values.gender,
birth: birth,
phone: values.phone || undefined,
email: values.email || undefined,
height: values.height || undefined,
weight: values.weight || undefined,
images: values.images?.filter((url: string) => !!url) || [],
scores: values.scores || undefined,
degree: values.degree || undefined,
academy: values.academy || undefined,
occupation: values.occupation || undefined,
income: values.income || undefined,
assets: values.assets || undefined,
current_assets: values.current_assets || undefined,
house: values.house || undefined,
car: values.car || undefined,
registered_city: values.registered_city || undefined,
live_city: values.live_city || undefined,
native_place: values.native_place || undefined,
original_family: values.original_family || undefined,
is_single_child: values.is_single_child,
match_requirement: values.match_requirement || undefined,
introductions: values.introductions || {},
custom_level: values.custom_level || '普通',
comments: values.comments || {},
};
console.log('提交客户数据:', customData);
let response;
if (initialData?.id) {
response = await updateCustom(initialData.id, customData);
} else {
response = await createCustom(customData);
}
if (response.error_code === 0) {
message.success(initialData?.id ? '客户信息已更新!' : '客户信息已成功提交到后端!');
if (!initialData?.id) {
form.resetFields();
}
onSuccess?.();
} else {
message.error(response.error_info || '提交失败,请重试');
}
} catch (e) {
const err = e as { status?: number; message?: string };
if (err.status === 422) {
message.error('表单数据格式有误,请检查输入内容');
} else if ((err.status ?? 0) >= 500) {
message.error('服务器错误,请稍后重试');
} else {
message.error(err.message || '提交失败,请重试');
}
} finally {
setLoading(false);
}
};
const screens = useBreakpoint();
const isMobile = !screens.md;
const scoresNode = (
<Form.Item name="scores" label="外貌评分">
<InputNumber min={0} max={100} style={{ width: '100%' }} placeholder="分" />
</Form.Item>
);
const heightNode = (
<Form.Item name="height" label="身高(cm)">
<InputNumber min={0} max={250} style={{ width: '100%' }} placeholder="cm" />
</Form.Item>
);
const weightNode = (
<Form.Item name="weight" label="体重(kg)">
<InputNumber min={0} max={200} style={{ width: '100%' }} placeholder="kg" />
</Form.Item>
);
const degreeNode = (
<Form.Item name="degree" label="学历">
<Input placeholder="如:本科" />
</Form.Item>
);
const academyNode = (
<Form.Item name="academy" label="院校">
<Input placeholder="如:清华大学" />
</Form.Item>
);
return (
<div className="custom-form">
{!hideSubmitButton && (
<>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}></Typography.Text>
</>
)}
<Form
form={form}
layout="vertical"
size="large"
onFinish={onFinish}
>
{/* Row 1: 姓名、性别、年龄 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={8}>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
<Select placeholder="请选择性别" options={[{ label: '男', value: '男' }, { label: '女', value: '女' }]} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="age" label="年龄" rules={[{ required: true, message: '请输入年龄' }]}>
<InputNumber min={0} max={120} style={{ width: '100%' }} placeholder="请输入年龄" />
</Form.Item>
</Col>
</Row>
{/* Row 2: 电话、邮箱 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="phone" label="电话">
<Input placeholder="请输入电话" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="email" label="邮箱">
<Input placeholder="请输入邮箱" />
</Form.Item>
</Col>
</Row>
{/* Row 3: Image Group */}
<Form.Item name="images" label="照片图片">
<ImageInputGroup customId={initialData?.id} />
</Form.Item>
{/* Row 4: Physical Info */}
{isMobile ? (
// Mobile Layout
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* 1. Scores (Full width) */}
{scoresNode}
{/* 2. Height & Weight */}
<Row gutter={[12, 12]}>
<Col xs={12}>{heightNode}</Col>
<Col xs={12}>{weightNode}</Col>
</Row>
{/* 3. Degree & Academy */}
<Row gutter={[12, 12]}>
<Col xs={12}>{degreeNode}</Col>
<Col xs={12}>{academyNode}</Col>
</Row>
</div>
) : (
// PC Layout
<Row gutter={[24, 24]}>
<Col span={8}>{scoresNode}</Col>
<Col span={8}>{heightNode}</Col>
<Col span={8}>{weightNode}</Col>
</Row>
)}
{/* Row 5a: 学历、院校 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>{degreeNode}</Col>
<Col xs={24} md={12}>{academyNode}</Col>
</Row>
{/* Row 5b: 职业、收入 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="occupation" label="职业">
<Input placeholder="如:工程师" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="income" label="收入(万/年)">
<InputNumber style={{ width: '100%' }} placeholder="万" />
</Form.Item>
</Col>
</Row>
{/* Row 5c: 资产、流动资产 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="assets" label="资产(万)">
<InputNumber style={{ width: '100%' }} placeholder="万" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="current_assets" label="流动资产(万)">
<InputNumber style={{ width: '100%' }} placeholder="万" />
</Form.Item>
</Col>
</Row>
{/* Row 5d: 房产情况、汽车情况 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="house" label="房产情况">
<Select placeholder="请选择房产情况" options={['无房', '有房有贷', '有房无贷'].map(v => ({ label: v, value: v }))} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="car" label="汽车情况">
<Select placeholder="请选择汽车情况" options={['无车', '有车有贷', '有车无贷'].map(v => ({ label: v, value: v }))} />
</Form.Item>
</Col>
</Row>
{/* Row 6: 户口城市、常住城市、籍贯 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={8}>
<Form.Item name="registered_city" label="户口城市">
<Input placeholder="如:北京" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="live_city" label="常住城市">
<Input placeholder="如:上海" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="native_place" label="籍贯">
<Input placeholder="如:江苏" />
</Form.Item>
</Col>
</Row>
{/* Row 7: 是否独生子 */}
<Form.Item name="is_single_child" label="是否独生子">
<Radio.Group>
<Radio value={true}></Radio>
<Radio value={false}></Radio>
</Radio.Group>
</Form.Item>
{/* Row 8: 原生家庭 */}
<Form.Item name="original_family" label="原生家庭">
<TextArea autoSize={{ minRows: 3, maxRows: 5 }} placeholder="请输入原生家庭情况" />
</Form.Item>
{/* Row 9: 择偶要求 */}
<Form.Item name="match_requirement" label="择偶要求">
<TextArea autoSize={{ minRows: 3, maxRows: 5 }} placeholder="请输入择偶要求" />
</Form.Item>
{/* Row 10: 其他信息 (KeyValueList) */}
<Form.Item name="introductions" label="其他信息">
<KeyValueList />
</Form.Item>
{/* Row 11: 客户等级、是否公开 */}
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="custom_level" label="客户等级">
<Select options={['普通', 'VIP', '高级VIP'].map(v => ({ label: v, value: v }))} placeholder="请选择" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="is_public" label="是否公开">
<Radio.Group>
<Radio value={true}></Radio>
<Radio value={false}></Radio>
</Radio.Group>
</Form.Item>
</Col>
</Row>
{/* Row 13: 备注评论 (KeyValueList) */}
<Form.Item name="comments" label="备注评论">
<KeyValueList />
</Form.Item>
{!hideSubmitButton && (
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large">
{initialData?.id ? '保存修改' : '提交客户信息'}
</Button>
</Form.Item>
)}
</Form>
</div>
);
};
export default CustomForm;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Image } from 'antd';
interface ImagePreviewProps {
url?: string;
alt?: string;
minHeight?: string | number;
maxHeight?: string | number;
placeholder?: string;
backgroundColor?: string;
}
const ImagePreview: React.FC<ImagePreviewProps> = ({
url,
alt = '封面预览',
minHeight,
maxHeight,
placeholder = '封面预览',
backgroundColor = '#fafafa',
}) => {
return (
<div style={{
width: '100%',
height: '100%',
minHeight: minHeight,
maxHeight: maxHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed #d9d9d9',
borderRadius: '8px',
background: backgroundColor,
padding: '8px',
overflow: 'hidden'
}}>
{url ? (
<Image
src={url}
alt={alt}
style={{
height: '100%',
width: '100%',
maxHeight: typeof maxHeight === 'string' ? `calc(${maxHeight} - 16px)` : maxHeight ? (maxHeight as number) - 16 : undefined,
objectFit: 'contain'
}}
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2IChhEhKKTMoZ0hTwIQYUXOgjpAhLwzpDbQCBCwh_gswOQDz12JoLPj+7YM..."
preview={false}
/>
) : (
<div style={{ color: '#999' }}>{placeholder}</div>
)}
</div>
);
};
export default ImagePreview;

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -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

View File

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

View 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;

View File

@@ -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>
{/* 首页右侧输入抽屉,仅在顶栏点击后弹出;挂载到标题栏下方容器 */}

View File

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

View File

@@ -1,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;

View 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;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag, message, Modal, Dropdown, Input, Select } from 'antd';
import { Layout, Typography, Table, Grid, Button, Space, Tag, message, Modal, Dropdown, Input, Select } from 'antd';
import type { FormInstance } from 'antd';
import type { ColumnsType, ColumnType } from 'antd/es/table';
import type { FilterDropdownProps } from 'antd/es/table/interface';
@@ -9,6 +9,7 @@ import './MainContent.css';
import InputDrawer from './InputDrawer.tsx';
import ImageModal from './ImageModal.tsx';
import PeopleForm from './PeopleForm.tsx';
import NumberRangeFilterDropdown from './NumberRangeFilterDropdown';
import { getPeoples } from '../apis';
import type { People } from '../apis';
import { addOrUpdateRemark, deletePeople, deleteRemark, updatePeople } from '../apis/people';
@@ -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('操作失败');
}
}}

View File

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

View File

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

View File

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

View File

@@ -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

View 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
View 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;

View File

@@ -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>,
)

View File

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

View File

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

View File

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