diff --git a/.env b/.env new file mode 100644 index 0000000..26c53db --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +# 开发环境配置 +VITE_API_BASE_URL=http://127.0.0.1:8099 \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..5cfb9c3 --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +# 生产环境配置 +VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443 \ No newline at end of file diff --git a/package.json b/package.json index 002ab2b..2e73cd3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/publish.py b/publish.py new file mode 100644 index 0000000..7f72c52 --- /dev/null +++ b/publish.py @@ -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) diff --git a/src/App.tsx b/src/App.tsx index 3d7ded3..3617b8d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) + return ; } -export default App +export default App; diff --git a/src/apis/README.md b/src/apis/README.md new file mode 100644 index 0000000..d9469b9 --- /dev/null +++ b/src/apis/README.md @@ -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', + }, +}; +``` \ No newline at end of file diff --git a/src/apis/config.ts b/src/apis/config.ts new file mode 100644 index 0000000..d16a397 --- /dev/null +++ b/src/apis/config.ts @@ -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; \ No newline at end of file diff --git a/src/apis/index.ts b/src/apis/index.ts new file mode 100644 index 0000000..b04df0c --- /dev/null +++ b/src/apis/index.ts @@ -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; \ No newline at end of file diff --git a/src/apis/input.ts b/src/apis/input.ts new file mode 100644 index 0000000..fd433a0 --- /dev/null +++ b/src/apis/input.ts @@ -0,0 +1,26 @@ +// 文本输入相关 API + +import { post } from './request'; +import { API_ENDPOINTS } from './config'; +import type { PostInputRequest, ApiResponse } from './types'; + +/** + * 提交文本输入 + * @param text 输入的文本内容 + * @returns Promise + */ +export async function postInput(text: string): Promise { + const requestData: PostInputRequest = { text }; + // 为 postInput 设置 30 秒超时时间 + return post(API_ENDPOINTS.INPUT, requestData, { timeout: 120000 }); +} + +/** + * 提交文本输入(使用对象参数) + * @param data 包含文本的请求对象 + * @returns Promise + */ +export async function postInputData(data: PostInputRequest): Promise { + // 为 postInputData 设置 30 秒超时时间 + return post(API_ENDPOINTS.INPUT, data, { timeout: 120000 }); +} \ No newline at end of file diff --git a/src/apis/people.ts b/src/apis/people.ts new file mode 100644 index 0000000..1155989 --- /dev/null +++ b/src/apis/people.ts @@ -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 + */ +export async function createPeople(people: People): Promise { + const requestData: PostPeopleRequest = { people }; + console.log('创建人员请求数据:', requestData); + // 创建接口改为 /people + return post(API_ENDPOINTS.PEOPLE, requestData); +} + +/** + * 查询人员列表 + * @param params 查询参数 + * @returns Promise> + */ +export async function getPeoples(params?: GetPeoplesParams): Promise> { + return get>(API_ENDPOINTS.PEOPLES, params); +} + +/** + * 搜索人员 + * @param searchText 搜索关键词 + * @param topK 返回结果数量,默认5 + * @returns Promise> + */ +export async function searchPeoples( + searchText: string, + topK = 5 +): Promise> { + const params: GetPeoplesParams = { + search: searchText, + top_k: topK, + }; + return get>(API_ENDPOINTS.PEOPLES, params); +} + +/** + * 按条件筛选人员 + * @param filters 筛选条件 + * @returns Promise> + */ +export async function filterPeoples(filters: { + name?: string; + gender?: string; + age?: number; + height?: number; + marital_status?: string; +}): Promise> { + const params: GetPeoplesParams = { + ...filters, + limit: 50, // 默认返回50条 + }; + return get>(API_ENDPOINTS.PEOPLES, params); +} + +/** + * 分页获取人员列表 + * @param page 页码(从1开始) + * @param pageSize 每页数量,默认10 + * @param filters 可选的筛选条件 + * @returns Promise>> + */ +export async function getPeoplesPaginated( + page = 1, + pageSize = 10, + filters?: Partial +): Promise>> { + const params: GetPeoplesParams = { + ...filters, + limit: pageSize, + offset: (page - 1) * pageSize, + }; + + const response = await get>(API_ENDPOINTS.PEOPLES, params); + + // 将响应转换为分页格式 + const paginatedResponse: PaginatedResponse = { + items: response.data || [], + total: response.data?.length || 0, // 注意:实际项目中应该从后端获取总数 + limit: pageSize, + offset: (page - 1) * pageSize, + }; + + return { + ...response, + data: paginatedResponse, + }; +} + +/** + * 删除人员信息 + * @param peopleId 人员ID + * @returns Promise + */ +export async function deletePeople(peopleId: string): Promise { + return del(API_ENDPOINTS.PEOPLE_BY_ID(peopleId)); +} + +/** + * 更新人员信息 + * @param peopleId 人员ID + * @param people 人员信息对象 + * @returns Promise + */ +export async function updatePeople(peopleId: string, people: People): Promise { + const requestData: PostPeopleRequest = { people }; + return put(API_ENDPOINTS.PEOPLE_BY_ID(peopleId), requestData); +} + +/** + * 批量创建人员信息 + * @param peopleList 人员信息数组 + * @returns Promise + */ +export async function createPeoplesBatch( + peopleList: People[] +): Promise { + const promises = peopleList.map(people => createPeople(people)); + return Promise.all(promises); +} + +/** + * 高级搜索人员 + * @param options 搜索选项 + * @returns Promise> + */ +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> { + 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>(API_ENDPOINTS.PEOPLES, params); +} \ No newline at end of file diff --git a/src/apis/request.ts b/src/apis/request.ts new file mode 100644 index 0000000..141f41c --- /dev/null +++ b/src/apis/request.ts @@ -0,0 +1,162 @@ +// 基础请求工具函数 + +import { API_CONFIG } from './config'; + +// 请求选项接口 +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + headers?: Record; + 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( + url: string, + options: RequestOptions = {} +): Promise { + 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 = { + ...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(url: string, params?: Record): Promise { + 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(fullUrl, { method: 'GET' }); +} + +// POST 请求 +export function post(url: string, data?: any, options?: Partial): Promise { + return request(url, { + method: 'POST', + body: data, + ...options, + }); +} + +// PUT 请求 +export function put(url: string, data?: any): Promise { + return request(url, { + method: 'PUT', + body: data, + }); +} + +// DELETE 请求 +export function del(url: string): Promise { + return request(url, { method: 'DELETE' }); +} + +// 文件上传请求 +export function upload(url: string, file: File, fieldName = 'file', options?: Partial): Promise { + const formData = new FormData(); + formData.append(fieldName, file); + + return request(url, { + method: 'POST', + body: formData, + ...options, + }); +} \ No newline at end of file diff --git a/src/apis/types.ts b/src/apis/types.ts new file mode 100644 index 0000000..1b581c2 --- /dev/null +++ b/src/apis/types.ts @@ -0,0 +1,63 @@ +// API 请求和响应类型定义 + +// 基础响应类型 +export interface ApiResponse { + 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; +} + +// 人员查询参数类型 +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 { + items: T[]; + total: number; + limit: number; + offset: number; +} \ No newline at end of file diff --git a/src/apis/upload.ts b/src/apis/upload.ts new file mode 100644 index 0000000..2d61f49 --- /dev/null +++ b/src/apis/upload.ts @@ -0,0 +1,109 @@ +// 图片上传相关 API + +import { upload } from './request'; +import { API_ENDPOINTS } from './config'; +import type { ApiResponse } from './types'; + +/** + * 上传图片文件 + * @param file 要上传的图片文件 + * @returns Promise + */ +export async function postInputImage(file: File): Promise { + // 验证文件类型 + if (!file.type.startsWith('image/')) { + throw new Error('只能上传图片文件'); + } + + return upload(API_ENDPOINTS.INPUT_IMAGE, file, 'image', { timeout: 120000 }); +} + +/** + * 上传图片文件(带进度回调) + * @param file 要上传的图片文件 + * @param onProgress 上传进度回调函数 + * @returns Promise + */ +export async function postInputImageWithProgress( + file: File, + onProgress?: (progress: number) => void +): Promise { + // 验证文件类型 + 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 }; +} \ No newline at end of file diff --git a/src/components/HintText.css b/src/components/HintText.css new file mode 100644 index 0000000..cf451de --- /dev/null +++ b/src/components/HintText.css @@ -0,0 +1,2 @@ +/* 提示信息组件样式 */ +/* 文字样式在 layout.css 的 .hint-text 中定义,此处预留扩展 */ \ No newline at end of file diff --git a/src/components/HintText.tsx b/src/components/HintText.tsx new file mode 100644 index 0000000..bf74f44 --- /dev/null +++ b/src/components/HintText.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import './HintText.css'; + +type Props = { showUpload?: boolean }; + +const HintText: React.FC = ({ showUpload = true }) => { + const text = showUpload + ? '提示:支持输入多行文本、上传图片或粘贴剪贴板图片。按 Enter 发送,Shift+Enter 换行。' + : '提示:支持输入多行文本。按 Enter 发送,Shift+Enter 换行。'; + return
{text}
; +}; + +export default HintText; \ No newline at end of file diff --git a/src/components/ImageModal.css b/src/components/ImageModal.css new file mode 100644 index 0000000..127c712 --- /dev/null +++ b/src/components/ImageModal.css @@ -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; +} \ No newline at end of file diff --git a/src/components/ImageModal.tsx b/src/components/ImageModal.tsx new file mode 100644 index 0000000..99814ae --- /dev/null +++ b/src/components/ImageModal.tsx @@ -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(); + +const ImageModal: React.FC = ({ 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 ( + + {/* 自定义关闭按钮 */} +
{ + e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; + }} + > + +
+ + {/* 图片内容 */} +
+ {loading && ( + + )} + + {imageError && ( +
+
📷
+
图片加载失败
+
+ )} + + {imageLoaded && !loading && !imageError && ( + 预览图片 + )} +
+
+ ); +}; + +export default ImageModal; \ No newline at end of file diff --git a/src/components/InputDrawer.css b/src/components/InputDrawer.css new file mode 100644 index 0000000..c156f36 --- /dev/null +++ b/src/components/InputDrawer.css @@ -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; /* 移动端更紧凑 */ + } +} \ No newline at end of file diff --git a/src/components/InputDrawer.tsx b/src/components/InputDrawer.tsx new file mode 100644 index 0000000..4863fd1 --- /dev/null +++ b/src/components/InputDrawer.tsx @@ -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 = ({ open, onClose, onResult, containerEl, showUpload = true, mode = 'input' }) => { + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + const [topbarHeight, setTopbarHeight] = React.useState(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 ( + 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)' }, + }} + > +
+
AI FIND U
+
+ + +
+
+
+ ); +}; + +export default InputDrawer; \ No newline at end of file diff --git a/src/components/InputPanel.css b/src/components/InputPanel.css new file mode 100644 index 0000000..5ff0b91 --- /dev/null +++ b/src/components/InputPanel.css @@ -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 中对齐 */ \ No newline at end of file diff --git a/src/components/InputPanel.tsx b/src/components/InputPanel.tsx new file mode 100644 index 0000000..d63b4ae --- /dev/null +++ b/src/components/InputPanel.tsx @@ -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 = ({ onResult, showUpload = true, mode = 'input' }) => { + const [value, setValue] = React.useState(''); + const [fileList, setFileList] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [savedText, setSavedText] = React.useState(''); + + // 统一显示短文件名: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) => { + if (loading) return; // 加载中时禁用快捷键 + + if (e.key === 'Enter') { + if (e.shiftKey) { + // Shift+Enter 换行(保持默认行为) + return; + } + // Enter 发送 + e.preventDefault(); + send(); + } + }; + + // 处理剪贴板粘贴图片:将图片加入上传列表,复用现有上传流程 + const onPaste = (e: React.ClipboardEvent) => { + 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 ( +
+ } + > + {/** 根据禁用状态动态占位符文案 */} + {(() => { + return null; + })()} +