Release v0.1

This commit is contained in:
2025-11-13 00:06:48 +08:00
38 changed files with 3640 additions and 102 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
# 开发环境配置
VITE_API_BASE_URL=http://127.0.0.1:8099

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
# 生产环境配置
VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443

View File

@@ -10,8 +10,12 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"antd": "^5.27.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

49
publish.py Normal file
View File

@@ -0,0 +1,49 @@
import os
import qiniu
# 七牛云的配置信息
access_key = 'IpeHQ-vdzi2t1YD53NDupyE8e9kxNZha2n5-m_3J'
secret_key = '7wF4JM0cnKFwBfrGVZrS12Wq4VWbphm0DpHRfK6O'
bucket_name = 'ifindu'
# 可以指定一个七牛云空间的前缀,方便管理文件
prefix = ''
# 初始化七牛云的认证信息
q = qiniu.Auth(access_key, secret_key)
# 初始化七牛云的存储桶对象
bucket = qiniu.BucketManager(q)
def upload_file(local_file_path, remote_file_path):
"""
上传单个文件到七牛云
:param local_file_path: 本地文件的路径
:param remote_file_path: 七牛云空间中的文件路径
"""
# 生成上传凭证
token = q.upload_token(bucket_name, remote_file_path)
# 初始化七牛云的上传对象
ret, info = qiniu.put_file(token, remote_file_path, local_file_path)
if info.status_code == 200:
print(f"文件 {local_file_path} 上传成功,七牛云路径: {remote_file_path}")
else:
print(f"文件 {local_file_path} 上传失败,错误信息: {info.text_body}")
def upload_folder(folder_path):
"""
上传文件夹到七牛云
:param folder_path: 本地文件夹的路径
"""
for root, dirs, files in os.walk(folder_path):
for file in files:
# 构建本地文件的完整路径
local_file_path = os.path.join(root, file)
# 构建七牛云空间中的文件路径
relative_path = os.path.relpath(local_file_path, folder_path)
remote_file_path = os.path.join(prefix, relative_path).replace("\\", "/")
# 调用上传单个文件的函数
upload_file(local_file_path, remote_file_path)
if __name__ == "__main__":
# 要上传的本地文件夹路径
local_folder_path = 'dist/'
upload_folder(local_folder_path)

View File

@@ -1,35 +1,7 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import LayoutWrapper from './components/LayoutWrapper';
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
return <LayoutWrapper />;
}
export default App
export default App;

215
src/apis/README.md Normal file
View File

@@ -0,0 +1,215 @@
# API 接口封装
本目录包含了对 FastAPI 后端接口的完整封装,提供了类型安全的 TypeScript 接口。
## 文件结构
```
src/apis/
├── index.ts # 统一导出文件
├── config.ts # API 配置
├── request.ts # 基础请求工具
├── types.ts # TypeScript 类型定义
├── input.ts # 文本输入接口
├── upload.ts # 图片上传接口
├── people.ts # 人员管理接口
└── README.md # 使用说明
```
## 使用方法
### 1. 导入方式
```typescript
// 方式一导入所有API
import api from '@/apis';
// 方式二:按需导入
import { postInput, getPeoples, postInputImage } from '@/apis';
// 方式三:分模块导入
import { api } from '@/apis';
const { input, people, upload } = api;
```
### 2. 文本输入接口
```typescript
import { postInput } from '@/apis';
// 提交文本
try {
const response = await postInput('这是一段文本');
console.log('提交成功:', response);
} catch (error) {
console.error('提交失败:', error);
}
```
### 3. 图片上传接口
```typescript
import { postInputImage, validateImageFile } from '@/apis';
// 上传图片
const handleFileUpload = async (file: File) => {
// 验证文件
const validation = validateImageFile(file);
if (!validation.valid) {
alert(validation.error);
return;
}
try {
const response = await postInputImage(file);
console.log('上传成功:', response);
} catch (error) {
console.error('上传失败:', error);
}
};
// 带进度的上传
import { postInputImageWithProgress } from '@/apis';
const handleFileUploadWithProgress = async (file: File) => {
try {
const response = await postInputImageWithProgress(file, (progress) => {
console.log(`上传进度: ${progress}%`);
});
console.log('上传成功:', response);
} catch (error) {
console.error('上传失败:', error);
}
};
```
### 4. 人员管理接口
```typescript
import {
createPeople,
getPeoples,
searchPeoples,
deletePeople,
updatePeople,
getPeoplesPaginated
} from '@/apis';
// 创建人员
const createNewPeople = async () => {
const peopleData = {
name: '张三',
gender: '男',
age: 25,
height: 175,
marital_status: '未婚'
};
try {
const response = await createPeople(peopleData);
console.log('创建成功:', response);
} catch (error) {
console.error('创建失败:', error);
}
};
// 查询人员列表
const fetchPeoples = async () => {
try {
const response = await getPeoples({
limit: 20,
offset: 0
});
console.log('查询结果:', response.data);
} catch (error) {
console.error('查询失败:', error);
}
};
// 搜索人员
const searchForPeople = async (keyword: string) => {
try {
const response = await searchPeoples(keyword, 10);
console.log('搜索结果:', response.data);
} catch (error) {
console.error('搜索失败:', error);
}
};
// 分页查询
const fetchPeoplesPaginated = async (page: number) => {
try {
const response = await getPeoplesPaginated(page, 10);
console.log('分页结果:', response.data);
} catch (error) {
console.error('查询失败:', error);
}
};
// 删除人员
const removePeople = async (peopleId: string) => {
try {
const response = await deletePeople(peopleId);
console.log('删除成功:', response);
} catch (error) {
console.error('删除失败:', error);
}
};
// 更新人员
const updateOnePeople = async (peopleId: string) => {
const peopleData = {
name: '李四',
age: 28,
};
try {
const response = await updatePeople(peopleId, peopleData);
console.log('更新成功:', response);
} catch (error) {
console.error('更新失败:', error);
}
};
```
## 错误处理
所有接口都会抛出 `ApiError` 类型的错误,包含以下信息:
```typescript
try {
const response = await postInput('test');
} catch (error) {
if (error instanceof ApiError) {
console.log('错误状态码:', error.status);
console.log('错误信息:', error.message);
console.log('错误详情:', error.data);
}
}
```
## 类型定义
所有接口都提供了完整的 TypeScript 类型支持:
```typescript
import type {
People,
GetPeoplesParams,
PostInputRequest,
ApiResponse
} from '@/apis';
```
## 配置
可以通过修改 `config.ts` 文件来调整 API 配置:
```typescript
export const API_CONFIG = {
BASE_URL: 'http://127.0.0.1:8099', // API 基础地址
TIMEOUT: 10000, // 请求超时时间
HEADERS: {
'Content-Type': 'application/json',
},
};
```

20
src/apis/config.ts Normal file
View File

@@ -0,0 +1,20 @@
// API 配置
export const API_CONFIG = {
BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8099',
TIMEOUT: 10000,
HEADERS: {
'Content-Type': 'application/json',
},
};
// API 端点
export const API_ENDPOINTS = {
INPUT: '/recognition/input',
INPUT_IMAGE: '/recognition/image',
// 人员列表查询仍为 /peoples
PEOPLES: '/peoples',
// 新增单个资源路径 /people
PEOPLE: '/people',
PEOPLE_BY_ID: (id: string) => `/people/${id}`,
} as const;

29
src/apis/index.ts Normal file
View File

@@ -0,0 +1,29 @@
// API 模块统一导出
// 配置和工具
export * from './config';
export * from './request';
export * from './types';
// 具体接口
export * from './input';
export * from './upload';
export * from './people';
// 默认导出所有API函数
import * as inputApi from './input';
import * as uploadApi from './upload';
import * as peopleApi from './people';
export const api = {
// 文本输入相关
input: inputApi,
// 图片上传相关
upload: uploadApi,
// 人员管理相关
people: peopleApi,
};
export default api;

26
src/apis/input.ts Normal file
View File

@@ -0,0 +1,26 @@
// 文本输入相关 API
import { post } from './request';
import { API_ENDPOINTS } from './config';
import type { PostInputRequest, ApiResponse } from './types';
/**
* 提交文本输入
* @param text 输入的文本内容
* @returns Promise<ApiResponse>
*/
export async function postInput(text: string): Promise<ApiResponse> {
const requestData: PostInputRequest = { text };
// 为 postInput 设置 30 秒超时时间
return post<ApiResponse>(API_ENDPOINTS.INPUT, requestData, { timeout: 120000 });
}
/**
* 提交文本输入(使用对象参数)
* @param data 包含文本的请求对象
* @returns Promise<ApiResponse>
*/
export async function postInputData(data: PostInputRequest): Promise<ApiResponse> {
// 为 postInputData 设置 30 秒超时时间
return post<ApiResponse>(API_ENDPOINTS.INPUT, data, { timeout: 120000 });
}

180
src/apis/people.ts Normal file
View File

@@ -0,0 +1,180 @@
// 人员管理相关 API
import { get, post, del, put } from './request';
import { API_ENDPOINTS } from './config';
import type {
PostPeopleRequest,
GetPeoplesParams,
People,
ApiResponse,
PaginatedResponse
} from './types';
/**
* 创建人员信息
* @param people 人员信息对象
* @returns Promise<ApiResponse>
*/
export async function createPeople(people: People): Promise<ApiResponse> {
const requestData: PostPeopleRequest = { people };
console.log('创建人员请求数据:', requestData);
// 创建接口改为 /people
return post<ApiResponse>(API_ENDPOINTS.PEOPLE, requestData);
}
/**
* 查询人员列表
* @param params 查询参数
* @returns Promise<ApiResponse<People[]>>
*/
export async function getPeoples(params?: GetPeoplesParams): Promise<ApiResponse<People[]>> {
return get<ApiResponse<People[]>>(API_ENDPOINTS.PEOPLES, params);
}
/**
* 搜索人员
* @param searchText 搜索关键词
* @param topK 返回结果数量默认5
* @returns Promise<ApiResponse<People[]>>
*/
export async function searchPeoples(
searchText: string,
topK = 5
): Promise<ApiResponse<People[]>> {
const params: GetPeoplesParams = {
search: searchText,
top_k: topK,
};
return get<ApiResponse<People[]>>(API_ENDPOINTS.PEOPLES, params);
}
/**
* 按条件筛选人员
* @param filters 筛选条件
* @returns Promise<ApiResponse<People[]>>
*/
export async function filterPeoples(filters: {
name?: string;
gender?: string;
age?: number;
height?: number;
marital_status?: string;
}): Promise<ApiResponse<People[]>> {
const params: GetPeoplesParams = {
...filters,
limit: 50, // 默认返回50条
};
return get<ApiResponse<People[]>>(API_ENDPOINTS.PEOPLES, params);
}
/**
* 分页获取人员列表
* @param page 页码从1开始
* @param pageSize 每页数量默认10
* @param filters 可选的筛选条件
* @returns Promise<ApiResponse<PaginatedResponse<People>>>
*/
export async function getPeoplesPaginated(
page = 1,
pageSize = 10,
filters?: Partial<GetPeoplesParams>
): Promise<ApiResponse<PaginatedResponse<People>>> {
const params: GetPeoplesParams = {
...filters,
limit: pageSize,
offset: (page - 1) * pageSize,
};
const response = await get<ApiResponse<People[]>>(API_ENDPOINTS.PEOPLES, params);
// 将响应转换为分页格式
const paginatedResponse: PaginatedResponse<People> = {
items: response.data || [],
total: response.data?.length || 0, // 注意:实际项目中应该从后端获取总数
limit: pageSize,
offset: (page - 1) * pageSize,
};
return {
...response,
data: paginatedResponse,
};
}
/**
* 删除人员信息
* @param peopleId 人员ID
* @returns Promise<ApiResponse>
*/
export async function deletePeople(peopleId: string): Promise<ApiResponse> {
return del<ApiResponse>(API_ENDPOINTS.PEOPLE_BY_ID(peopleId));
}
/**
* 更新人员信息
* @param peopleId 人员ID
* @param people 人员信息对象
* @returns Promise<ApiResponse>
*/
export async function updatePeople(peopleId: string, people: People): Promise<ApiResponse> {
const requestData: PostPeopleRequest = { people };
return put<ApiResponse>(API_ENDPOINTS.PEOPLE_BY_ID(peopleId), requestData);
}
/**
* 批量创建人员信息
* @param peopleList 人员信息数组
* @returns Promise<ApiResponse[]>
*/
export async function createPeoplesBatch(
peopleList: People[]
): Promise<ApiResponse[]> {
const promises = peopleList.map(people => createPeople(people));
return Promise.all(promises);
}
/**
* 高级搜索人员
* @param options 搜索选项
* @returns Promise<ApiResponse<People[]>>
*/
export async function advancedSearchPeoples(options: {
searchText?: string;
filters?: {
name?: string;
gender?: string;
ageRange?: { min?: number; max?: number };
heightRange?: { min?: number; max?: number };
marital_status?: string;
};
pagination?: {
page?: number;
pageSize?: number;
};
topK?: number;
}): Promise<ApiResponse<People[]>> {
const { searchText, filters = {}, pagination = {}, topK = 10 } = options;
const { page = 1, pageSize = 10 } = pagination;
const params: GetPeoplesParams = {
search: searchText,
name: filters.name,
gender: filters.gender,
marital_status: filters.marital_status,
limit: pageSize,
offset: (page - 1) * pageSize,
top_k: topK,
};
// 处理年龄范围(这里简化处理,实际可能需要后端支持范围查询)
if (filters.ageRange?.min !== undefined) {
params.age = filters.ageRange.min;
}
// 处理身高范围(这里简化处理,实际可能需要后端支持范围查询)
if (filters.heightRange?.min !== undefined) {
params.height = filters.heightRange.min;
}
return get<ApiResponse<People[]>>(API_ENDPOINTS.PEOPLES, params);
}

162
src/apis/request.ts Normal file
View File

@@ -0,0 +1,162 @@
// 基础请求工具函数
import { API_CONFIG } from './config';
// 请求选项接口
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: any;
timeout?: number;
}
// 自定义错误类
export class ApiError extends Error {
status?: number;
data?: any;
constructor(message: string, status?: number, data?: any) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
// 基础请求函数
export async function request<T = any>(
url: string,
options: RequestOptions = {}
): Promise<T> {
const {
method = 'GET',
headers = {},
body,
timeout = API_CONFIG.TIMEOUT,
} = options;
const fullUrl = url.startsWith('http') ? url : `${API_CONFIG.BASE_URL}${url}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const requestHeaders: Record<string, string> = {
...API_CONFIG.HEADERS,
...headers,
};
let requestBody: string | FormData | undefined;
if (body instanceof FormData) {
// 对于 FormData不设置 Content-Type让浏览器自动设置
delete requestHeaders['Content-Type'];
requestBody = body;
} else if (body) {
requestBody = JSON.stringify(body);
}
const response = await fetch(fullUrl, {
method,
headers: requestHeaders,
body: requestBody,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
let errorData: any;
try {
errorData = await response.json();
} catch {
errorData = { message: response.statusText };
}
throw new ApiError(
errorData.message || `HTTP ${response.status}: ${response.statusText}`,
response.status,
errorData
);
}
// 检查响应是否有内容
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
// 如果没有 JSON 内容,返回空对象
return {} as T;
}
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof ApiError) {
throw error;
}
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new ApiError('请求超时', 408);
}
throw new ApiError(error.message);
}
throw new ApiError('未知错误');
}
}
// GET 请求
export function get<T = any>(url: string, params?: Record<string, any>): Promise<T> {
let fullUrl = url;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
if (queryString) {
fullUrl += (url.includes('?') ? '&' : '?') + queryString;
}
}
return request<T>(fullUrl, { method: 'GET' });
}
// POST 请求
export function post<T = any>(url: string, data?: any, options?: Partial<RequestOptions>): Promise<T> {
return request<T>(url, {
method: 'POST',
body: data,
...options,
});
}
// PUT 请求
export function put<T = any>(url: string, data?: any): Promise<T> {
return request<T>(url, {
method: 'PUT',
body: data,
});
}
// DELETE 请求
export function del<T = any>(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> {
const formData = new FormData();
formData.append(fieldName, file);
return request<T>(url, {
method: 'POST',
body: formData,
...options,
});
}

63
src/apis/types.ts Normal file
View File

@@ -0,0 +1,63 @@
// API 请求和响应类型定义
// 基础响应类型
export interface ApiResponse<T = any> {
data?: T;
error_code: number;
error_info?: string;
}
// 验证错误类型
export interface ValidationError {
loc: (string | number)[];
msg: string;
type: string;
}
export interface HTTPValidationError {
detail: ValidationError[];
}
// 文本输入请求类型
export interface PostInputRequest {
text: string;
}
// 人员信息请求类型
export interface PostPeopleRequest {
people: Record<string, any>;
}
// 人员查询参数类型
export interface GetPeoplesParams {
name?: string;
gender?: string;
age?: number;
height?: number;
marital_status?: string;
limit?: number;
offset?: number;
search?: string;
top_k?: number;
}
// 人员信息类型
export interface People {
id?: string;
name?: string;
contact?: string;
gender?: string;
age?: number;
height?: number;
marital_status?: string;
[key: string]: any;
cover?: string;
}
// 分页响应类型
export interface PaginatedResponse<T> {
items: T[];
total: number;
limit: number;
offset: number;
}

109
src/apis/upload.ts Normal file
View File

@@ -0,0 +1,109 @@
// 图片上传相关 API
import { upload } from './request';
import { API_ENDPOINTS } from './config';
import type { ApiResponse } from './types';
/**
* 上传图片文件
* @param file 要上传的图片文件
* @returns Promise<ApiResponse>
*/
export async function postInputImage(file: File): Promise<ApiResponse> {
// 验证文件类型
if (!file.type.startsWith('image/')) {
throw new Error('只能上传图片文件');
}
return upload<ApiResponse>(API_ENDPOINTS.INPUT_IMAGE, file, 'image', { timeout: 120000 });
}
/**
* 上传图片文件(带进度回调)
* @param file 要上传的图片文件
* @param onProgress 上传进度回调函数
* @returns Promise<ApiResponse>
*/
export async function postInputImageWithProgress(
file: File,
onProgress?: (progress: number) => void
): Promise<ApiResponse> {
// 验证文件类型
if (!file.type.startsWith('image/')) {
throw new Error('只能上传图片文件');
}
const formData = new FormData();
// 后端要求字段名为 image
formData.append('image', file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 监听上传进度
if (onProgress) {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
onProgress(progress);
}
});
}
// 监听请求完成
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = xhr.responseText ? JSON.parse(xhr.responseText) : {};
resolve(response);
} catch (error) {
resolve({error_code: 1, error_info: '解析响应失败'});
}
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
});
// 监听请求错误
xhr.addEventListener('error', () => {
reject(new Error('网络错误'));
});
// 监听请求超时
xhr.addEventListener('timeout', () => {
reject(new Error('请求超时'));
});
// 发送请求
xhr.open('POST', `http://127.0.0.1:8099${API_ENDPOINTS.INPUT_IMAGE}`);
xhr.timeout = 120000; // 30秒超时
xhr.send(formData);
});
}
/**
* 验证图片文件
* @param file 文件对象
* @param maxSize 最大文件大小(字节),默认 10MB
* @returns 验证结果
*/
export function validateImageFile(file: File, maxSize = 10 * 1024 * 1024): { valid: boolean; error?: string } {
// 检查文件类型
if (!file.type.startsWith('image/')) {
return { valid: false, error: '只能上传图片文件' };
}
// 检查文件大小
if (file.size > maxSize) {
const maxSizeMB = Math.round(maxSize / (1024 * 1024));
return { valid: false, error: `文件大小不能超过 ${maxSizeMB}MB` };
}
// 检查支持的图片格式
const supportedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!supportedTypes.includes(file.type)) {
return { valid: false, error: '支持的图片格式JPEG、PNG、GIF、WebP' };
}
return { valid: true };
}

View File

@@ -0,0 +1,2 @@
/* 提示信息组件样式 */
/* 文字样式在 layout.css 的 .hint-text 中定义,此处预留扩展 */

View File

@@ -0,0 +1,13 @@
import React from 'react';
import './HintText.css';
type Props = { showUpload?: boolean };
const HintText: React.FC<Props> = ({ showUpload = true }) => {
const text = showUpload
? '提示:支持输入多行文本、上传图片或粘贴剪贴板图片。按 Enter 发送Shift+Enter 换行。'
: '提示:支持输入多行文本。按 Enter 发送Shift+Enter 换行。';
return <div className="hint-text">{text}</div>;
};
export default HintText;

View File

@@ -0,0 +1,65 @@
/* PC端图片弹窗样式 */
.desktop-image-modal .ant-modal-wrap {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.desktop-image-modal .ant-modal {
top: auto !important;
left: auto !important;
transform: none !important;
margin: 0 !important;
padding-bottom: 0 !important;
position: relative !important;
}
.desktop-image-modal .ant-modal-content {
border-radius: 8px;
overflow: hidden;
}
/* 移动端图片弹窗样式 */
.mobile-image-modal .ant-modal-wrap {
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 64px 0 0 0 !important; /* 顶部留出标题栏空间 */
}
.mobile-image-modal .ant-modal {
top: auto !important;
left: auto !important;
transform: none !important;
margin: 0 !important;
padding-bottom: 0 !important;
position: relative !important;
width: 100vw !important;
}
.mobile-image-modal .ant-modal-content {
border-radius: 0;
width: 100% !important;
max-height: calc(100vh - 64px);
}
.mobile-image-modal .ant-modal-body {
padding: 0 !important;
}
/* 确保图片容器不会溢出 */
.image-modal-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image-modal-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}

View File

@@ -0,0 +1,190 @@
import React, { useState, useEffect } from 'react';
import { Modal, Spin } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import './ImageModal.css';
interface ImageModalProps {
visible: boolean;
imageUrl: string;
onClose: () => void;
}
// 图片缓存
const imageCache = new Set<string>();
const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) => {
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);
// 检测是否为移动端
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// 预加载图片
useEffect(() => {
if (visible && imageUrl) {
// 如果图片已缓存,直接显示
if (imageCache.has(imageUrl)) {
setImageLoaded(true);
setLoading(false);
return;
}
setLoading(true);
setImageLoaded(false);
setImageError(false);
const img = new Image();
img.onload = () => {
imageCache.add(imageUrl);
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
setImageLoaded(true);
setLoading(false);
};
img.onerror = () => {
setImageError(true);
setLoading(false);
};
img.src = imageUrl;
}
}, [visible, imageUrl]);
// 重置状态当弹窗关闭时
useEffect(() => {
if (!visible) {
setLoading(false);
setImageLoaded(false);
setImageError(false);
setImageDimensions(null);
}
}, [visible]);
// 计算移动端弹窗高度
const getMobileModalHeight = () => {
if (imageLoaded && imageDimensions) {
// 如果图片已加载,根据图片比例自适应高度
const availableHeight = window.innerHeight - 64; // 减去标题栏高度
const availableWidth = window.innerWidth;
// 计算图片按宽度100%显示时的高度
const aspectRatio = imageDimensions.height / imageDimensions.width;
const calculatedHeight = availableWidth * aspectRatio;
// 确保高度不超过可用空间的90%
const maxHeight = availableHeight * 0.9;
const finalHeight = Math.min(calculatedHeight, maxHeight);
return `${finalHeight}px`;
}
// 图片未加载时使用默认高度除标题栏外的33%
return 'calc((100vh - 64px) * 0.33)';
};
const modalStyle = isMobile ? {
// 移动端居中显示不设置top
paddingBottom: 0,
margin: 0,
} : {
// PC端不设置top让centered属性处理居中
};
const modalBodyStyle = isMobile ? {
padding: 0,
height: getMobileModalHeight(),
minHeight: 'calc((100vh - 64px) * 0.33)', // 最小高度为33%
maxHeight: 'calc(100vh - 64px)', // 最大高度不超过可视区域
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
} : {
padding: 0,
height: '66vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
};
return (
<Modal
open={visible}
onCancel={onClose}
footer={null}
closable={false}
width={isMobile ? '100vw' : '66vw'}
style={modalStyle}
styles={{
body: modalBodyStyle,
mask: { backgroundColor: 'rgba(0, 0, 0, 0.8)' },
}}
centered={true} // 移动端和PC端都居中显示
destroyOnHidden
wrapClassName={isMobile ? 'mobile-image-modal' : 'desktop-image-modal'}
>
{/* 自定义关闭按钮 */}
<div
style={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 1000,
cursor: 'pointer',
color: '#fff',
fontSize: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: '50%',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.3s',
}}
onClick={onClose}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
}}
>
<CloseOutlined />
</div>
{/* 图片内容 */}
<div className="image-modal-container">
{loading && (
<Spin size="large" style={{ color: '#fff' }} />
)}
{imageError && (
<div style={{ color: '#fff', textAlign: 'center' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>📷</div>
<div></div>
</div>
)}
{imageLoaded && !loading && !imageError && (
<img
src={imageUrl}
alt="预览图片"
/>
)}
</div>
</Modal>
);
};
export default ImageModal;

View File

@@ -0,0 +1,43 @@
/* 右侧输入抽屉样式(薄荷之春配色) */
.input-drawer .ant-drawer-body {
background: var(--color-primary);
}
.input-drawer-inner {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center; /* 居中内部内容 */
width: 100%;
}
.input-drawer-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.5px;
text-align: center;
}
.input-drawer-box {
background: var(--bg-card);
border-radius: 12px;
padding: 16px 18px; /* 增大内边距 */
box-shadow: 0 1px 6px rgba(0,0,0,0.08);
width: 100%;
max-width: 680px; /* 桌面居中显示更宽 */
margin: 0 auto; /* 水平居中 */
box-sizing: border-box;
}
/* 抽屉底部按钮区域与页面底栏保持间距(如有) */
.input-drawer .ant-drawer-footer { border-top: none; }
/* 抽屉与遮罩不再额外向下偏移,依赖 getContainer 挂载到标题栏下方的容器 */
@media (max-width: 768px) {
.input-drawer-box {
max-width: 100%;
padding: 14px; /* 移动端更紧凑 */
}
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { Drawer, Grid } from 'antd';
import InputPanel from './InputPanel.tsx';
import HintText from './HintText.tsx';
import './InputDrawer.css';
type Props = {
open: boolean;
onClose: () => void;
onResult?: (data: any) => void;
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
showUpload?: boolean; // 透传到输入面板,控制图片上传按钮
mode?: 'input' | 'search'; // 透传到输入面板,控制工作模式
};
const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, showUpload = true, mode = 'input' }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [topbarHeight, setTopbarHeight] = React.useState<number>(56);
React.useEffect(() => {
const update = () => {
const el = document.querySelector('.topbar') as HTMLElement | null;
const h = el?.clientHeight || 56;
setTopbarHeight(h);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
// 在输入处理成功onResult 被调用)后,自动关闭抽屉
const handleResult = React.useCallback(
(data: any) => {
onResult?.(data);
onClose();
},
[onResult, onClose]
);
return (
<Drawer
className="input-drawer"
placement="right"
width={isMobile ? '100%' : '33%'}
open={open}
onClose={onClose}
mask
getContainer={containerEl ? () => containerEl : undefined}
closable={false}
zIndex={1500}
rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
styles={{
header: { display: 'none' },
body: {
padding: 16,
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
// mask: { top: topbarHeight, height: `calc(100% - ${topbarHeight}px)`, backgroundColor: 'var(--mask)' },
}}
>
<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} />
<HintText showUpload={showUpload} />
</div>
</div>
</Drawer>
);
};
export default InputDrawer;

View File

@@ -0,0 +1,56 @@
/* 输入面板组件样式 */
.input-panel {
display: flex;
flex-direction: column;
gap: 12px; /* 增大间距 */
}
.input-panel .ant-input-outlined,
.input-panel .ant-input {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
min-height: 180px; /* 提升基础高度,配合 autoSize 更宽裕 */
}
.input-panel .ant-input::placeholder { color: var(--placeholder); }
.input-panel .ant-input:focus,
.input-panel .ant-input-focused {
border-color: var(--color-primary-600);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}
/* 禁用态:浅灰背景与文字,明确不可编辑 */
.input-panel .ant-input[disabled],
.input-panel .ant-input-disabled,
.input-panel .ant-input-outlined.ant-input-disabled {
background: #f3f4f6; /* gray-100 */
color: #9ca3af; /* gray-400 */
border-color: #e5e7eb; /* gray-200 */
cursor: not-allowed;
-webkit-text-fill-color: #9ca3af; /* Safari 禁用态颜色 */
}
.input-panel .ant-input[disabled]::placeholder {
color: #cbd5e1; /* gray-300 */
}
.input-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.input-actions .ant-btn-text { color: var(--text-secondary); }
.input-actions .ant-btn-text:hover { color: var(--color-primary-600); }
/* 左侧文件标签样式,保持短名及紧凑展示 */
.selected-image-tag {
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: auto; /* 保持标签在左侧,按钮在右侧 */
}
/* 移除右侧容器样式,按钮直接在 input-actions 中对齐 */

View File

@@ -0,0 +1,250 @@
import React from 'react';
import { Input, Upload, message, Button, Spin, Tag } from 'antd';
import { PictureOutlined, SendOutlined, LoadingOutlined, SearchOutlined } from '@ant-design/icons';
import { postInput, postInputImage, getPeoples } from '../apis';
import './InputPanel.css';
const { TextArea } = Input;
interface InputPanelProps {
onResult?: (data: any) => void;
showUpload?: boolean; // 是否显示图片上传按钮,默认显示
mode?: 'input' | 'search'; // 输入面板工作模式默认为表单填写input
}
const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mode = 'input' }) => {
const [value, setValue] = React.useState('');
const [fileList, setFileList] = React.useState<any[]>([]);
const [loading, setLoading] = React.useState(false);
const [savedText, setSavedText] = React.useState<string>('');
// 统一显示短文件名image.{ext}
const getImageExt = (file: any): string => {
const type = file?.type || '';
if (typeof type === 'string' && type.startsWith('image/')) {
const sub = type.split('/')[1] || 'png';
return sub.toLowerCase();
}
const name = file?.name || '';
const dot = name.lastIndexOf('.');
const ext = dot >= 0 ? name.slice(dot + 1) : '';
return (ext || 'png').toLowerCase();
};
const send = async () => {
const trimmed = value.trim();
const hasText = trimmed.length > 0;
const hasImage = showUpload && fileList.length > 0;
// 搜索模式:仅以文本触发检索,忽略图片
if (mode === 'search') {
if (!hasText) {
message.info('请输入内容');
return;
}
setLoading(true);
try {
console.log('检索文本:', trimmed);
const response = await getPeoples({ search: trimmed, top_k: 10 });
console.log('检索响应:', response);
if (response.error_code === 0) {
message.success('已获取检索结果');
onResult?.(response.data || []);
// 清空输入
setValue('');
setFileList([]);
} else {
message.error(response.error_info || '检索失败,请重试');
}
} catch (error) {
console.error('检索调用失败:', error);
message.error('网络错误,请检查连接后重试');
} finally {
setLoading(false);
}
return;
}
setLoading(true);
try {
let response;
// 如果有图片,优先处理图片上传
if (hasImage) {
const file = fileList[0].originFileObj || fileList[0];
if (!file) {
message.error('图片文件无效,请重新选择');
return;
}
console.log('上传图片:', file.name);
response = await postInputImage(file);
} else {
// 只有文本时,调用文本处理 API
console.log('处理文本:', trimmed);
response = await postInput(trimmed);
}
console.log('API响应:', response);
if (response.error_code === 0 && response.data) {
message.success('处理完成!已自动填充表单');
// 将结果传递给父组件
onResult?.(response.data);
message.info('输入已清空');
// 清空输入
setValue('');
setFileList([]);
setSavedText('');
} else {
message.error(response.error_info || '处理失败,请重试');
}
} catch (error) {
console.error('API调用失败:', error);
message.error('网络错误,请检查连接后重试');
} finally {
setLoading(false);
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (loading) return; // 加载中时禁用快捷键
if (e.key === 'Enter') {
if (e.shiftKey) {
// Shift+Enter 换行(保持默认行为)
return;
}
// Enter 发送
e.preventDefault();
send();
}
};
// 处理剪贴板粘贴图片:将图片加入上传列表,复用现有上传流程
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!showUpload || loading) return;
const items = e.clipboardData?.items;
if (!items || items.length === 0) return;
let pastedImage: File | null = null;
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/')) {
pastedImage = file;
break; // 只取第一张
}
}
}
if (pastedImage) {
// 避免图片内容以文本方式粘贴进输入框
e.preventDefault();
const ext = getImageExt(pastedImage);
const name = `image.${ext}`;
const entry = {
uid: `${Date.now()}-${Math.random()}`,
name,
status: 'done',
originFileObj: pastedImage,
} as any;
// 仅保留一张:新图直接替换旧图
if (fileList.length === 0) {
setSavedText(value);
}
setValue('');
setFileList([entry]);
message.success('已添加剪贴板图片');
}
};
return (
<div className="input-panel">
<Spin
spinning={loading}
tip="正在处理中,请稍候..."
indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />}
>
{/** 根据禁用状态动态占位符文案 */}
{(() => {
return null;
})()}
<TextArea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={
showUpload && fileList.length > 0
? '不可在添加图片时输入信息...'
: (showUpload ? '请输入个人信息描述,或上传图片…' : '请输入个人信息描述…')
}
autoSize={{ minRows: 6, maxRows: 12 }}
style={{ fontSize: 16 }}
onKeyDown={onKeyDown}
onPaste={onPaste}
disabled={loading || (showUpload && fileList.length > 0)}
/>
</Spin>
<div className="input-actions">
{/* 左侧文件标签显示 */}
{showUpload && fileList.length > 0 && (
<Tag
className="selected-image-tag"
color="processing"
closable
onClose={() => { setFileList([]); setValue(savedText); setSavedText(''); }}
bordered={false}
>
{`image.${new Date().getSeconds()}.${getImageExt(fileList[0]?.originFileObj || fileList[0])}`}
</Tag>
)}
{showUpload && (
<Upload
accept="image/*"
multiple={false}
beforeUpload={() => false}
fileList={fileList}
onChange={({ file, fileList: nextFileList }) => {
// 只保留最新一个,并重命名为 image.{ext}
if (nextFileList.length === 0) {
setFileList([]);
return;
}
const latest = nextFileList[nextFileList.length - 1] as any;
const raw = latest.originFileObj || file; // UploadFile 或原始 File
const ext = getImageExt(raw);
const renamed = { ...latest, name: `image.${ext}` };
if (fileList.length === 0) {
setSavedText(value);
}
setValue('');
setFileList([renamed]);
}}
onRemove={() => { setFileList([]); setValue(savedText); setSavedText(''); return true; }}
maxCount={1}
showUploadList={false}
disabled={loading}
>
<Button type="text" icon={<PictureOutlined />} disabled={loading} />
</Upload>
)}
<Button
type="primary"
icon={loading ? <LoadingOutlined /> : (mode === 'search' ? <SearchOutlined /> : <SendOutlined />)}
onClick={send}
loading={loading}
disabled={loading}
aria-label={mode === 'search' ? '搜索' : '发送'}
/>
</div>
</div>
);
};
export default InputPanel;

View File

@@ -0,0 +1,24 @@
/* 键值对动态输入组件样式 */
.kv-list {
margin-top: 8px;
}
.kv-row {
margin-bottom: 8px;
}
.kv-remove {
width: 100%;
}
.kv-list .ant-input {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
}
.kv-list .ant-input::placeholder { color: var(--placeholder); }
.kv-list .ant-input:focus {
border-color: var(--color-primary-600);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}

View File

@@ -0,0 +1,113 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Input, Button } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import './KeyValueList.css';
export type DictValue = Record<string, string>;
type KeyValuePair = { id: string; k: string; v: string };
type Props = {
value?: DictValue;
onChange?: (value: DictValue) => void;
};
const KeyValueList: React.FC<Props> = ({ value, onChange }) => {
const [rows, setRows] = useState<KeyValuePair[]>([]);
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
? Object.keys(value).map((key) => ({
id: existingIdByKey.get(key) || `${key}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
k: key,
v: value[key] ?? '',
}))
: [];
const blankRows = prev.filter((r) => !r.k);
let merged = [...valuePairs, ...blankRows];
if (!initializedRef.current && merged.length === 0) {
merged = [{ id: `row-${Date.now()}-${Math.random().toString(36).slice(2)}`, k: '', v: '' }];
}
initializedRef.current = true;
return merged;
});
}, [value]);
const emitChange = (nextRows: KeyValuePair[]) => {
const dict: DictValue = {};
nextRows.forEach((r) => {
if (r.k && r.k.trim() !== '') {
dict[r.k] = r.v ?? '';
}
});
onChange?.(dict);
};
const updateRow = (id: string, field: 'k' | 'v', val: string) => {
const next = rows.map((r) => (r.id === id ? { ...r, [field]: val } : r));
setRows(next);
emitChange(next);
};
const addRow = () => {
const next = [...rows, { id: `row-${Date.now()}-${Math.random().toString(36).slice(2)}`, k: '', v: '' }];
setRows(next);
// 不触发 onChange因为字典未变化空行不入字典
};
const removeRow = (id: string) => {
const removed = rows.find((r) => r.id === id);
const next = rows.filter((r) => r.id !== id);
setRows(next);
if (removed?.k && removed.k.trim() !== '') {
emitChange(next);
}
};
return (
<div className="kv-list">
{rows.map((r) => (
<div className="kv-row" key={r.id}>
<Row gutter={[12, 12]} align="middle">
<Col xs={24} md={10}>
<Input
size="large"
placeholder="键(例如:籍贯、职业)"
value={r.k}
onChange={(e) => updateRow(r.id, 'k', e.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<Input
size="large"
placeholder="值(例如:北京、产品经理)"
value={r.v}
onChange={(e) => updateRow(r.id, 'v', e.target.value)}
/>
</Col>
<Col xs={24} md={2}>
<Button
className="kv-remove"
aria-label="删除"
icon={<DeleteOutlined />}
onClick={() => removeRow(r.id)}
/>
</Col>
</Row>
</div>
))}
<Button type="dashed" block icon={<PlusOutlined />} onClick={addRow}>
</Button>
</div>
);
};
export default KeyValueList;

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { Layout } from 'antd';
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import SiderMenu from './SiderMenu.tsx';
import MainContent from './MainContent.tsx';
import ResourceList from './ResourceList.tsx';
import TopBar from './TopBar.tsx';
import '../styles/base.css';
import '../styles/layout.css';
const LayoutWrapper: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
const [inputOpen, setInputOpen] = React.useState(false);
const isHome = location.pathname === '/';
const isList = location.pathname === '/resources';
const layoutShellRef = React.useRef<HTMLDivElement>(null);
const pathToKey = (path: string) => {
switch (path) {
case '/resources':
return 'menu1';
case '/menu2':
return 'menu2';
default:
return 'home';
}
};
const selectedKey = pathToKey(location.pathname);
const handleNavigate = (key: string) => {
switch (key) {
case 'home':
navigate('/');
break;
case 'menu1':
navigate('/resources');
break;
case 'menu2':
navigate('/menu2');
break;
default:
navigate('/');
break;
}
// 切换页面时收起输入抽屉
setInputOpen(false);
};
return (
<Layout className="layout-wrapper app-root">
{/* 顶部标题栏,位于左侧菜单栏之上 */}
<TopBar
onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
onToggleInput={() => {if (isHome || isList) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
showInput={isHome || isList}
/>
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
<Layout ref={layoutShellRef as any} className="layout-shell">
<SiderMenu
onNavigate={handleNavigate}
selectedKey={selectedKey}
mobileOpen={mobileMenuOpen}
onMobileToggle={(open) => setMobileMenuOpen(open)}
/>
<Layout>
<Routes>
<Route
path="/"
element={
<MainContent
inputOpen={inputOpen}
onCloseInput={() => setInputOpen(false)}
containerEl={layoutShellRef.current}
/>
}
/>
<Route
path="/resources"
element={
<ResourceList
inputOpen={inputOpen}
onCloseInput={() => setInputOpen(false)}
containerEl={layoutShellRef.current}
/>
}
/>
<Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>2</div>} />
</Routes>
</Layout>
</Layout>
</Layout>
);
};
export default LayoutWrapper;

View File

@@ -0,0 +1,2 @@
/* 主内容区组件样式(如需) */
/* 主要样式已在 layout.css 中定义,此处预留以便后续扩展 */

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Layout, Typography } from 'antd';
import PeopleForm from './PeopleForm.tsx';
import InputDrawer from './InputDrawer.tsx';
import './MainContent.css';
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 handleInputResult = (data: any) => {
setFormData(data);
};
return (
<Content className="main-content">
<div className="content-body">
<Typography.Title level={3} style={{ color: 'var(--text-primary)', marginBottom: 12 }}>
?
</Typography.Title>
<Typography.Paragraph style={{ color: 'var(--muted)', marginBottom: 0 }}>
TA的信息
</Typography.Paragraph>
<PeopleForm initialData={formData} />
</div>
{/* 首页右侧输入抽屉,仅在顶栏点击后弹出;挂载到标题栏下方容器 */}
<InputDrawer
open={inputOpen}
onClose={onCloseInput || (() => {})}
onResult={handleInputResult}
containerEl={containerEl}
/>
</Content>
);
};
export default MainContent;

View File

@@ -0,0 +1,40 @@
/* 人物信息录入表单样式 */
.people-form {
margin-top: 16px;
padding: 20px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-card);
color: var(--text-primary);
}
.people-form .ant-form-item-label > label {
color: var(--text-secondary);
}
.people-form .ant-input,
.people-form .ant-input-affix-wrapper,
.people-form .ant-select-selector,
.people-form .ant-input-number,
.people-form .ant-input-number-input {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
}
.people-form .ant-select-selection-item,
.people-form .ant-select-selection-placeholder {
color: var(--placeholder);
}
.people-form .ant-select-selection-item { color: var(--text-primary); }
/* 输入占位与聚焦态 */
.people-form .ant-input::placeholder { color: var(--placeholder); }
.people-form .ant-input-number-input::placeholder { color: var(--placeholder); }
.people-form .ant-input:focus,
.people-form .ant-input-affix-wrapper-focused,
.people-form .ant-select-focused .ant-select-selector,
.people-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,188 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Select, InputNumber, Button, message, Row, Col } from 'antd';
import type { FormInstance } from 'antd';
import './PeopleForm.css';
import KeyValueList from './KeyValueList.tsx'
import { createPeople, type People } from '../apis';
const { TextArea } = Input;
interface PeopleFormProps {
initialData?: any;
// 编辑模式下由父组件控制提交,隐藏内部提交按钮
hideSubmitButton?: boolean;
// 暴露 AntD Form 实例给父组件,用于在外部触发校验与取值
onFormReady?: (form: FormInstance) => void;
}
const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton = false, onFormReady }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// 当 initialData 变化时,自动填充表单
useEffect(() => {
if (initialData) {
console.log('收到API返回数据自动填充表单:', initialData);
// 处理返回的数据,将其转换为表单需要的格式
const formData: any = {};
if (initialData.name) formData.name = initialData.name;
if (initialData.contact) formData.contact = initialData.contact;
if (initialData.cover) formData.cover = initialData.cover;
if (initialData.gender) formData.gender = initialData.gender;
if (initialData.age) formData.age = initialData.age;
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;
// 设置表单字段值
form.setFieldsValue(formData);
// 显示成功消息
// message.success('已自动填充表单,请检查并确认信息');
}
}, [initialData, form]);
// 将表单实例暴露给父组件
useEffect(() => {
onFormReady?.(form);
// 仅在首次挂载时调用一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onFinish = async (values: any) => {
setLoading(true);
try {
const peopleData: People = {
name: values.name,
contact: values.contact || undefined,
gender: values.gender,
age: values.age,
height: values.height || undefined,
marital_status: values.marital_status || undefined,
introduction: values.introduction || {},
match_requirement: values.match_requirement || undefined,
cover: values.cover || undefined,
};
console.log('提交人员数据:', peopleData);
const response = await createPeople(peopleData);
console.log('API响应:', response);
if (response.error_code === 0) {
message.success('人员信息已成功提交到后端!');
form.resetFields();
} else {
message.error(response.error_info || '提交失败,请重试');
}
} catch (error: any) {
console.error('提交失败:', error);
// 根据错误类型显示不同的错误信息
if (error.status === 422) {
message.error('表单数据格式有误,请检查输入内容');
} else if (error.status >= 500) {
message.error('服务器错误,请稍后重试');
} else {
message.error(error.message || '提交失败,请重试');
}
} finally {
setLoading(false);
}
};
return (
<div className="people-form">
<Form
form={form}
layout="vertical"
size="large"
onFinish={onFinish}
>
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="如:张三" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="contact" label="联系人">
<Input placeholder="如:李四(可留空)" />
</Form.Item>
</Col>
</Row>
<Row gutter={[12, 12]}>
<Col xs={24}>
<Form.Item name="cover" label="人物封面">
<Input placeholder="请输入图片链接(可留空)" />
</Form.Item>
</Col>
</Row>
<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>
</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="可自定义输入,例如:未婚、已婚、离异等" />
</Form.Item>
</Col>
</Row>
<Form.Item name="introduction" label="个人介绍(键值对)">
<KeyValueList />
</Form.Item>
<Form.Item name="match_requirement" label="择偶要求">
<TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="例如:性格开朗、三观一致等" />
</Form.Item>
{!hideSubmitButton && (
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
{loading ? '提交中...' : '提交'}
</Button>
</Form.Item>
)}
</Form>
</div>
);
};
export default PeopleForm;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
/* 侧边菜单组件局部样式 */
.sider-header { border-bottom: 1px solid rgba(255,255,255,0.08); color: var(--text-invert); }
/* 收起时图标水平居中(仅 PC 端 Sider 使用) */
.sider-header.collapsed { justify-content: center; }
/* 移动端汉堡触发按钮位置 */
.mobile-menu-trigger {
position: fixed;
left: 16px;
top: 16px;
z-index: 1100;
}
/* 移动端 Drawer 背景与滚动 */
.mobile-menu-drawer .ant-drawer-body {
background: linear-gradient(180deg, rgba(16,185,129,0.12) 0%, rgba(16,185,129,0.22) 100%), var(--bg-sider);
padding: 0;
height: 100%;
}
/* Dark 菜单项:选中与悬停采用薄荷主色的柔和高亮 */
.ant-menu-dark .ant-menu-item-selected {
background-color: rgba(167, 243, 208, 0.16) !important;
color: var(--text-invert) !important;
}
.ant-menu-dark .ant-menu-item:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
/* 移动端菜单背景透明以露出侧栏渐变 */
.mobile-menu-drawer .ant-menu { background: transparent !important; }
/* 统一 Sider 背景为薄荷风格的深色渐变 */
.ant-layout-sider {
background: linear-gradient(180deg, rgba(16,185,129,0.12) 0%, rgba(16,185,129,0.22) 100%), var(--bg-sider) !important;
}
.ant-layout-sider .ant-menu { background: transparent !important; }
/* Sider 触发按钮(折叠/展开)样式 */
.ant-layout-sider-trigger {
background: linear-gradient(180deg, rgba(16,185,129,0.18) 0%, rgba(16,185,129,0.28) 100%) !important;
color: var(--text-invert) !important;
border-top: 1px solid rgba(167, 243, 208, 0.25);
}
/* 移动端汉堡按钮风格 */
.mobile-menu-trigger {
border: 1px solid rgba(255,255,255,0.16);
background: linear-gradient(180deg, rgba(16,185,129,0.12) 0%, rgba(16,185,129,0.24) 100%);
color: var(--text-invert);
}
.mobile-menu-trigger:hover {
background: rgba(16,185,129,0.35);
}

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
import { HeartOutlined, FormOutlined, UnorderedListOutlined, MenuOutlined } from '@ant-design/icons';
import './SiderMenu.css';
const { Sider } = Layout;
// 新增:支持外部导航回调 + 受控选中态
type Props = {
onNavigate?: (key: string) => void;
selectedKey?: string;
mobileOpen?: boolean; // 外部控制移动端抽屉开关
onMobileToggle?: (open: boolean) => void; // 顶栏触发开关
};
const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMobileToggle }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [collapsed, setCollapsed] = React.useState(false);
const [selectedKeys, setSelectedKeys] = React.useState<string[]>(['home']);
const [internalMobileOpen, setInternalMobileOpen] = React.useState(false);
const [topbarHeight, setTopbarHeight] = React.useState<number>(56);
React.useEffect(() => {
const update = () => {
const el = document.querySelector('.topbar') as HTMLElement | null;
const h = el?.clientHeight || 56;
setTopbarHeight(h);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
React.useEffect(() => {
setCollapsed(isMobile);
}, [isMobile]);
// 根据外部 selectedKey 同步选中态
React.useEffect(() => {
if (selectedKey) {
setSelectedKeys([selectedKey]);
}
}, [selectedKey]);
const items = [
{ key: 'home', label: '注册', icon: <FormOutlined /> },
{ key: 'menu1', label: '列表', icon: <UnorderedListOutlined /> },
];
// 移动端:使用 Drawer 覆盖主内容
if (isMobile) {
const open = mobileOpen ?? internalMobileOpen;
const setOpen = (v: boolean) => (onMobileToggle ? onMobileToggle(v) : setInternalMobileOpen(v));
const showInternalTrigger = !onMobileToggle; // 若无外部控制,则显示内部按钮
return (
<>
{showInternalTrigger && (
<Button
className="mobile-menu-trigger"
type="default"
icon={<MenuOutlined />}
onClick={() => setInternalMobileOpen((o) => !o)}
/>
)}
<Drawer
className="mobile-menu-drawer"
placement="left"
width="100%"
open={open}
onClose={() => setOpen(false)}
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>
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
onClick={({ key }) => {
const k = String(key);
setSelectedKeys([k]);
setOpen(false); // 选择后自动收起
onNavigate?.(k);
}}
items={items}
/>
</Drawer>
</>
);
}
// PC 端:保持 Sider 行为不变
return (
<Sider
width={260}
collapsible
collapsed={collapsed}
onCollapse={(c) => setCollapsed(c)}
breakpoint="md"
collapsedWidth={64}
theme="dark"
>
<div className={`sider-header ${collapsed ? 'collapsed' : ''}`}>
<HeartOutlined style={{ fontSize: 22 }} />
{!collapsed && (
<div>
<div className="sider-title"></div>
<div className="sider-desc"></div>
</div>
)}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
onClick={({ key }) => {
const k = String(key);
setSelectedKeys([k]);
onNavigate?.(k);
}}
items={items}
/>
</Sider>
);
};
export default SiderMenu;

46
src/components/TopBar.css Normal file
View File

@@ -0,0 +1,46 @@
/* 顶部网站标题栏样式 */
.topbar {
position: sticky;
top: 0;
z-index: 2000; /* 保证位于抽屉与遮罩之上 */
height: 56px;
display: grid;
grid-template-columns: 56px 1fr 56px;
align-items: center;
background: linear-gradient(180deg, rgba(6, 78, 59, 0.55) 0%, rgba(6, 78, 59, 0.88) 100%);
border-bottom: 1px solid rgba(167, 243, 208, 0.25);
backdrop-filter: blur(4px);
}
.topbar-title {
text-align: center;
font-size: 18px;
font-weight: 600;
font-style: italic;
letter-spacing: 1px;
color: var(--text-invert);
}
.topbar-left,
.topbar-right {
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.16);
background: linear-gradient(180deg, rgba(16,185,129,0.15) 0%, rgba(16,185,129,0.25) 100%);
color: var(--color-primary-600);
}
.icon-btn:hover { background: rgba(16,185,129,0.35); }
@media (min-width: 768px) {
.topbar { grid-template-columns: 56px 1fr 56px; }
}

41
src/components/TopBar.tsx Normal file
View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Grid } from 'antd';
import { MenuOutlined, RobotOutlined } from '@ant-design/icons';
import './TopBar.css';
type Props = {
onToggleMenu?: () => void;
onToggleInput?: () => void;
showInput?: boolean;
};
const TopBar: React.FC<Props> = ({ onToggleMenu, onToggleInput, showInput }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
return (
<div className="topbar">
<div className="topbar-left">
{isMobile && (
<button className="icon-btn" onClick={onToggleMenu} aria-label="打开/收起菜单">
<MenuOutlined />
</button>
)}
</div>
<div className="topbar-title" role="heading" aria-level={1}>
I FIND U
</div>
<div className="topbar-right">
{showInput && (
<button className="icon-btn" onClick={onToggleInput} aria-label="打开/收起输入">
<RobotOutlined />
</button>
)}
</div>
</div>
);
};
export default TopBar;

View File

@@ -1,68 +1,3 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
/* 保留最小化基础样式,具体视觉由 base.css 控制 */
html, body, #root { height: 100%; }
body { margin: 0; }

View File

@@ -1,10 +1,20 @@
import '@ant-design/v5-patch-for-react-19'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { BrowserRouter } from 'react-router-dom'
import 'antd/dist/reset.css'
import './styles/base.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter
future={{
v7_relativeSplatPath: true,
v7_startTransition: true,
}}
>
<App />
</BrowserRouter>
</StrictMode>,
)

78
src/styles/base.css Normal file
View File

@@ -0,0 +1,78 @@
/* 全局基础样式 */
:root {
/* Minty Spring 主题变量 */
--color-primary: #A7F3D0; /* 薄荷绿 */
--color-primary-600: #10B981; /* 深薄荷绿,用于文本/边框强调 */
--color-accent: #F97A7A; /* 珊瑚红CTA */
--bg-app: #F9F9F9; /* 主内容背景 */
--bg-sider: #262626; /* 侧栏与深色顶栏 */
--bg-card: #FFFFFF; /* 卡片与输入框背景 */
--text-primary: #334155; /* 深石板灰 */
--text-secondary: #475569; /* 次要文本 */
--text-invert: #E5E7EB; /* 深背景上的浅文本 */
--border: #E2E8F0; /* 通用边框 */
--muted: #94A3B8; /* 提示文字 */
--placeholder: hsla(215, 12%, 35%, 0.15); /* 更淡的占位符 */
--focus: #A7F3D0; /* 聚焦外光圈 */
--mask: rgba(17, 24, 39, 0.35); /* 遮罩 */
}
@supports (font-variation-settings: normal) {
:root { font-synthesis-weight: none; }
}
html, body, #root {
height: 100%;
}
body {
margin: 0;
/* 保留暗色基底,但主内容将使用浅色背景 */
background: radial-gradient(1200px 600px at 70% -100px, rgba(92,124,255,0.25) 0%, rgba(92,124,255,0.06) 45%, transparent 60%),
radial-gradient(800px 400px at -200px 20%, rgba(120,220,255,0.15) 0%, rgba(120,220,255,0.06) 50%, transparent 70%),
#0f172a; /* slate-900 */
color: var(--text-invert);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 让 Ant Design 的 Layout 充满视口 */
.app-root {
min-height: 100vh;
}
/* 统一滚动条样式(轻度) */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.2) transparent;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.2);
border-radius: 6px;
}
/* 全局主按钮样式Ant Design */
.ant-btn-primary {
background: var(--color-accent);
border-color: var(--color-accent);
}
.ant-btn-primary:hover,
.ant-btn-primary:focus {
background: #F06363;
border-color: #F06363;
}
.ant-btn-primary:disabled {
background: #FBC2C2;
border-color: #FBC2C2;
color: rgba(255,255,255,0.7);
}
/* 全局更淡的占位符颜色 */
.ant-input::placeholder,
.ant-input-affix-wrapper input::placeholder,
.ant-input-number-input::placeholder,
textarea::placeholder {
color: var(--placeholder) !important;
}

69
src/styles/layout.css Normal file
View File

@@ -0,0 +1,69 @@
/* 布局相关样式 */
.layout-wrapper {
min-height: 100vh;
}
/* 消除 Ant Layout 默认白底,避免顶栏下方看到白边 */
.layout-wrapper .ant-layout { background: transparent; }
/* 顶栏下的主布局容器:固定视口高度,隐藏外层滚动 */
.layout-shell {
height: calc(100vh - 56px);
overflow: hidden;
}
.layout-shell .ant-layout { height: 100%; }
/* 侧栏头部区域 */
.sider-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 12px 16px;
color: #e5e7eb;
}
.sider-title {
font-weight: 600;
font-size: 16px;
}
.sider-desc {
font-size: 12px;
color: #a3a3a3;
}
/* 主内容区 */
.main-content {
display: flex;
flex-direction: column;
height: 100%;
overflow: auto; /* 仅主内容区滚动 */
background: var(--bg-app);
}
.content-body {
flex: 1;
padding: 32px;
background: transparent;
color: var(--text-primary);
border-radius: 12px;
}
/* 输入面板固定底部 */
.input-panel-wrapper {
position: sticky;
bottom: 0;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.5) 0%, rgba(15, 23, 42, 0.9) 40%, rgba(15, 23, 42, 1) 100%);
backdrop-filter: blur(4px);
border-top: 1px solid rgba(255,255,255,0.08);
padding: 16px 24px 24px 24px;
}
.hint-text {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
/* 小屏优化:输入区域内边距更紧凑 */
@media (max-width: 768px) {
.content-body { padding: 16px; border-radius: 0; }
.input-panel-wrapper { padding: 12px 12px 16px 12px; }
}

View File

@@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
// Vite 默认支持环境变量,无需额外配置
// 环境变量加载优先级:
// .env.local > .env.[mode] > .env
})