Release v0.1
This commit is contained in:
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443
|
||||
@@ -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
49
publish.py
Normal 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)
|
||||
34
src/App.tsx
34
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 (
|
||||
<>
|
||||
<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
215
src/apis/README.md
Normal 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
20
src/apis/config.ts
Normal 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
29
src/apis/index.ts
Normal 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
26
src/apis/input.ts
Normal 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
180
src/apis/people.ts
Normal 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
162
src/apis/request.ts
Normal 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
63
src/apis/types.ts
Normal 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
109
src/apis/upload.ts
Normal 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 };
|
||||
}
|
||||
2
src/components/HintText.css
Normal file
2
src/components/HintText.css
Normal file
@@ -0,0 +1,2 @@
|
||||
/* 提示信息组件样式 */
|
||||
/* 文字样式在 layout.css 的 .hint-text 中定义,此处预留扩展 */
|
||||
13
src/components/HintText.tsx
Normal file
13
src/components/HintText.tsx
Normal 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;
|
||||
65
src/components/ImageModal.css
Normal file
65
src/components/ImageModal.css
Normal 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;
|
||||
}
|
||||
190
src/components/ImageModal.tsx
Normal file
190
src/components/ImageModal.tsx
Normal 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;
|
||||
43
src/components/InputDrawer.css
Normal file
43
src/components/InputDrawer.css
Normal 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; /* 移动端更紧凑 */
|
||||
}
|
||||
}
|
||||
77
src/components/InputDrawer.tsx
Normal file
77
src/components/InputDrawer.tsx
Normal 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;
|
||||
56
src/components/InputPanel.css
Normal file
56
src/components/InputPanel.css
Normal 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 中对齐 */
|
||||
250
src/components/InputPanel.tsx
Normal file
250
src/components/InputPanel.tsx
Normal 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;
|
||||
24
src/components/KeyValueList.css
Normal file
24
src/components/KeyValueList.css
Normal 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);
|
||||
}
|
||||
113
src/components/KeyValueList.tsx
Normal file
113
src/components/KeyValueList.tsx
Normal 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;
|
||||
98
src/components/LayoutWrapper.tsx
Normal file
98
src/components/LayoutWrapper.tsx
Normal 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;
|
||||
2
src/components/MainContent.css
Normal file
2
src/components/MainContent.css
Normal file
@@ -0,0 +1,2 @@
|
||||
/* 主内容区组件样式(如需) */
|
||||
/* 主要样式已在 layout.css 中定义,此处预留以便后续扩展 */
|
||||
41
src/components/MainContent.tsx
Normal file
41
src/components/MainContent.tsx
Normal 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;
|
||||
40
src/components/PeopleForm.css
Normal file
40
src/components/PeopleForm.css
Normal 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);
|
||||
}
|
||||
188
src/components/PeopleForm.tsx
Normal file
188
src/components/PeopleForm.tsx
Normal 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;
|
||||
1132
src/components/ResourceList.tsx
Normal file
1132
src/components/ResourceList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
55
src/components/SiderMenu.css
Normal file
55
src/components/SiderMenu.css
Normal 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);
|
||||
}
|
||||
134
src/components/SiderMenu.tsx
Normal file
134
src/components/SiderMenu.tsx
Normal 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
46
src/components/TopBar.css
Normal 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
41
src/components/TopBar.tsx
Normal 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;
|
||||
@@ -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; }
|
||||
|
||||
14
src/main.tsx
14
src/main.tsx
@@ -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
78
src/styles/base.css
Normal 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
69
src/styles/layout.css
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user