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 + 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)' },
+ }}
+ >
+
+
+ );
+};
+
+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;
+ })()}
+
+ );
+};
+
+export default InputPanel;
\ No newline at end of file
diff --git a/src/components/KeyValueList.css b/src/components/KeyValueList.css
new file mode 100644
index 0000000..a856aee
--- /dev/null
+++ b/src/components/KeyValueList.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/components/KeyValueList.tsx b/src/components/KeyValueList.tsx
new file mode 100644
index 0000000..976746b
--- /dev/null
+++ b/src/components/KeyValueList.tsx
@@ -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;
+
+type KeyValuePair = { id: string; k: string; v: string };
+
+type Props = {
+ value?: DictValue;
+ onChange?: (value: DictValue) => void;
+};
+
+const KeyValueList: React.FC = ({ value, onChange }) => {
+ const [rows, setRows] = useState([]);
+
+ 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 (
+
+ );
+};
+
+export default KeyValueList;
\ No newline at end of file
diff --git a/src/components/LayoutWrapper.tsx b/src/components/LayoutWrapper.tsx
new file mode 100644
index 0000000..fb35b51
--- /dev/null
+++ b/src/components/LayoutWrapper.tsx
@@ -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(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 (
+
+ {/* 顶部标题栏,位于左侧菜单栏之上 */}
+ {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
+ onToggleInput={() => {if (isHome || isList) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
+ showInput={isHome || isList}
+ />
+ {/* 下方为主布局:左侧菜单 + 右侧内容 */}
+
+ setMobileMenuOpen(open)}
+ />
+
+
+ setInputOpen(false)}
+ containerEl={layoutShellRef.current}
+ />
+ }
+ />
+ setInputOpen(false)}
+ containerEl={layoutShellRef.current}
+ />
+ }
+ />
+ 菜单2的内容暂未实现} />
+
+
+
+
+ );
+};
+
+export default LayoutWrapper;
\ No newline at end of file
diff --git a/src/components/MainContent.css b/src/components/MainContent.css
new file mode 100644
index 0000000..052c744
--- /dev/null
+++ b/src/components/MainContent.css
@@ -0,0 +1,2 @@
+/* 主内容区组件样式(如需) */
+/* 主要样式已在 layout.css 中定义,此处预留以便后续扩展 */
\ No newline at end of file
diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx
new file mode 100644
index 0000000..2f5b35e
--- /dev/null
+++ b/src/components/MainContent.tsx
@@ -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 = ({ inputOpen = false, onCloseInput, containerEl }) => {
+ const [formData, setFormData] = React.useState(null);
+
+ const handleInputResult = (data: any) => {
+ setFormData(data);
+ };
+
+ return (
+
+
+
+ ✨ 有新资源了吗?
+
+
+ 点击右上角可以直接输入个人信息描述或上传图片,我将自动整理TA的信息
+
+
+
+
+
+ {/* 首页右侧输入抽屉,仅在顶栏点击后弹出;挂载到标题栏下方容器 */}
+ {})}
+ onResult={handleInputResult}
+ containerEl={containerEl}
+ />
+
+ );
+};
+
+export default MainContent;
\ No newline at end of file
diff --git a/src/components/PeopleForm.css b/src/components/PeopleForm.css
new file mode 100644
index 0000000..d868e27
--- /dev/null
+++ b/src/components/PeopleForm.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/components/PeopleForm.tsx b/src/components/PeopleForm.tsx
new file mode 100644
index 0000000..383d645
--- /dev/null
+++ b/src/components/PeopleForm.tsx
@@ -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 = ({ 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 (
+
+
+
+
+
+
+
+
+
+ {!hideSubmitButton && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default PeopleForm;
\ No newline at end of file
diff --git a/src/components/ResourceList.tsx b/src/components/ResourceList.tsx
new file mode 100644
index 0000000..0d77c87
--- /dev/null
+++ b/src/components/ResourceList.tsx
@@ -0,0 +1,1081 @@
+import React from 'react';
+import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag, message, Modal, Dropdown, Input } from 'antd';
+import type { FormInstance } from 'antd';
+import type { ColumnsType, ColumnType } from 'antd/es/table';
+import type { FilterDropdownProps } from 'antd/es/table/interface';
+import type { TableProps } from 'antd';
+import { SearchOutlined, EllipsisOutlined, DeleteOutlined, ManOutlined, WomanOutlined, ExclamationCircleOutlined, PictureOutlined, EditOutlined } from '@ant-design/icons';
+import './MainContent.css';
+import InputDrawer from './InputDrawer.tsx';
+import ImageModal from './ImageModal.tsx';
+import PeopleForm from './PeopleForm.tsx';
+import { getPeoples } from '../apis';
+import type { People } from '../apis';
+import { deletePeople, updatePeople } from '../apis/people';
+
+const { Content } = Layout;
+
+// 数据类型定义 - 使用 API 中的 People 类型
+export type DictValue = Record;
+// 资源行类型:确保 id 一定存在且为 string,避免在使用处出现 "string | undefined" 类型问题
+export type Resource = Omit & { id: string };
+
+// 统一转换 API 返回的人员列表为表格需要的结构
+function transformPeoples(list: People[] = []): Resource[] {
+ return (list || []).map((person: any) => ({
+ id: person.id || `person-${Date.now()}-${Math.random()}`,
+ name: person.name || '未知',
+ gender: person.gender || '其他/保密',
+ age: person.age || 0,
+ height: person.height,
+ marital_status: person.marital_status,
+ introduction: person.introduction || {},
+ contact: person.contact || '',
+ cover: person.cover || '',
+ }));
+}
+
+// 获取人员列表数据
+async function fetchResources(): Promise {
+ try {
+ const response = await getPeoples({
+ limit: 1000, // 获取大量数据用于前端分页和筛选
+ offset: 0
+ });
+
+ // 检查响应是否成功
+ if (response.error_code !== 0) {
+ console.error('API错误:', response.error_info);
+ message.error(response.error_info || '获取数据失败');
+ // 返回空数组或使用 mock 数据作为后备
+ return [];
+ }
+
+ // 转换数据格式以匹配组件期望的结构
+ return transformPeoples(response.data || []);
+
+ } catch (error: any) {
+ console.error('获取人员列表失败:', error);
+ message.error('获取人员列表失败,使用模拟数据');
+
+ // 回退到 mock 数据,便于本地开发
+ return [
+ {
+ id: '1',
+ name: '张三',
+ gender: '男',
+ age: 28,
+ height: 175,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '北京',
+ 职业: '产品经理',
+ 教育: '本科',
+ 爱好: '跑步、旅行',
+ 技能: '数据分析、原型设计',
+ 座右铭: '保持好奇心,持续迭代',
+ 自我评价: '目标感强,善于跨团队沟通与协调。',
+ },
+ },
+ {
+ id: '2',
+ name: '李四',
+ gender: '女',
+ age: 26,
+ height: 165,
+ marital_status: '已婚',
+ introduction: {
+ 籍贯: '上海',
+ 职业: 'UI 设计师',
+ 教育: '硕士',
+ 公司: '知名互联网公司',
+ 爱好: '阅读、咖啡、插画',
+ 技能: '视觉系统、组件库设计',
+ 自我评价: '注重细节,强调一致性与可访问性。',
+ },
+ },
+ {
+ id: '3',
+ name: '王五',
+ gender: '其他/保密',
+ age: 31,
+ height: 180,
+ marital_status: '保密',
+ introduction: {
+ 籍贯: '成都',
+ 职业: '摄影师',
+ 教育: '本科',
+ 爱好: '旅行拍摄、登山',
+ 技能: '人像、风光摄影',
+ 座右铭: '用镜头记录真实与美',
+ 自我评价: '对光线敏感,善于捕捉转瞬即逝的情绪。',
+ },
+ },
+ {
+ id: '4',
+ name: '赵六',
+ gender: '男',
+ age: 22,
+ height: 178,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '西安',
+ 职业: '前端开发',
+ 教育: '本科',
+ 技能: 'React、TypeScript、Vite',
+ 证书: '阿里云开发者认证',
+ 项目经验: '电商用户中心、数据可视化面板',
+ 自我评价: '爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。',
+ 项目经验2: '电商用户中心、数据可视化面板',
+ 项目经验3: '电商用户中心、数据可视化面板',
+ 项目经验4: '电商用户中心、数据可视化面板',
+ 项目经验5: '电商用户中心、数据可视化面板',
+ 项目经验6: '电商用户中心、数据可视化面板',
+ },
+ },
+ {
+ id: '5',
+ name: '周杰',
+ gender: '男',
+ age: 35,
+ height: 182,
+ marital_status: '已婚',
+ introduction: {
+ 籍贯: '杭州',
+ 职业: '后端开发',
+ 教育: '硕士',
+ 技能: 'Go、gRPC、K8s',
+ 部门: '平台基础设施',
+ 自我评价: '偏工程化,注重稳定性与可观测性。',
+ },
+ },
+ {
+ id: '6',
+ name: '吴敏',
+ gender: '女',
+ age: 29,
+ height: 168,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '南京',
+ 职业: '数据分析师',
+ 教育: '本科',
+ 技能: 'SQL、Python、Tableau',
+ 研究方向: '用户增长与留存',
+ 自我评价: '以数据驱动决策,关注可解释性。',
+ },
+ },
+ {
+ id: '7',
+ name: '郑辉',
+ gender: '男',
+ age: 41,
+ height: 170,
+ marital_status: '离异',
+ introduction: {
+ 籍贯: '长沙',
+ 职业: '产品运营',
+ 教育: '本科',
+ 爱好: '篮球、播客',
+ 技能: '活动策划、社区运营',
+ 自我评价: '擅长整合资源并解决复杂协调问题。',
+ },
+ },
+ {
+ id: '8',
+ name: '王芳',
+ gender: '女',
+ age: 33,
+ height: 160,
+ marital_status: '已婚',
+ introduction: {
+ 籍贯: '青岛',
+ 职业: '市场经理',
+ 教育: '本科',
+ 技能: '品牌策略、内容营销',
+ 社交媒体: '微博、抖音、知乎',
+ 自我评价: '善于讲故事并构建品牌资产。',
+ },
+ },
+ {
+ id: '9',
+ name: '刘洋',
+ gender: '男',
+ age: 24,
+ height: 172,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '合肥',
+ 职业: '测试工程师',
+ 教育: '本科',
+ 技能: '自动化测试、性能测试',
+ 自我评价: '对边界条件敏感,追求高覆盖与低误报。',
+ },
+ },
+ {
+ id: '10',
+ name: '陈晨',
+ gender: '女',
+ age: 27,
+ height: 163,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '武汉',
+ 职业: '人力资源',
+ 教育: '本科',
+ 技能: '招聘、绩效与组织发展',
+ 自我评价: '重视文化与组织氛围建设。',
+ },
+ },
+ {
+ id: '11',
+ name: '孙琪',
+ gender: '女',
+ age: 38,
+ height: 166,
+ marital_status: '已婚',
+ introduction: {
+ 籍贯: '重庆',
+ 职业: '项目经理',
+ 教育: '硕士',
+ 技能: '进度与风险管理',
+ 获奖: '优秀 PM 奖',
+ 自我评价: '以结果为导向,兼顾团队士气。',
+ },
+ },
+ {
+ id: '12',
+ name: '朱莉',
+ gender: '女',
+ age: 30,
+ height: 158,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '厦门',
+ 职业: '内容编辑',
+ 教育: '本科',
+ 技能: '选题、采访与写作',
+ 座右铭: '字斟句酌,以小见大',
+ 自我评价: '具叙事力,关注读者反馈。',
+ },
+ },
+ {
+ id: '13',
+ name: '黄磊',
+ gender: '男',
+ age: 45,
+ height: 176,
+ marital_status: '已婚',
+ introduction: {
+ 籍贯: '广州',
+ 职业: '架构师',
+ 教育: '硕士',
+ 技能: '分布式系统、架构治理',
+ 自我评价: '关注可扩展性与长期维护成本。',
+ },
+ },
+ {
+ id: '14',
+ name: '高远',
+ gender: '男',
+ age: 32,
+ height: 181,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '沈阳',
+ 职业: '算法工程师',
+ 教育: '博士',
+ 技能: '推荐系统、CTR 预估',
+ 研究方向: '多模态融合',
+ 自我评价: '注重泛化与鲁棒性。',
+ },
+ },
+ {
+ id: '15',
+ name: '曹宁',
+ gender: '男',
+ age: 28,
+ height: 169,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '苏州',
+ 职业: '运维工程师',
+ 教育: '本科',
+ 技能: 'Linux、CI/CD、监控',
+ 自我评价: '追求稳定与自动化。',
+ },
+ },
+ {
+ id: '16',
+ name: '韩梅',
+ gender: '女',
+ age: 34,
+ height: 162,
+ marital_status: '已婚',
+ introduction: {
+ 籍贯: '大连',
+ 职业: '销售总监',
+ 教育: '本科',
+ 技能: '大客户拓展、谈判',
+ 自我评价: '结果导向,善于建立信任。',
+ },
+ },
+ {
+ id: '17',
+ name: '秦川',
+ gender: '男',
+ age: 52,
+ height: 174,
+ marital_status: '已婚',
+ introduction: {
+ 籍贯: '洛阳',
+ 职业: '财务主管',
+ 教育: '本科',
+ 技能: '成本控制、风险合规',
+ 自我评价: '稳健务实,注重细节。',
+ },
+ },
+ {
+ id: '18',
+ name: '何静',
+ gender: '女',
+ age: 23,
+ height: 159,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '昆明',
+ 职业: '新媒体运营',
+ 教育: '本科',
+ 技能: '短视频策划、社群',
+ 自我评价: '创意活跃,执行力强。',
+ },
+ },
+ {
+ id: '19',
+ name: '吕博',
+ gender: '男',
+ age: 36,
+ height: 183,
+ marital_status: '离异',
+ introduction: {
+ 籍贯: '天津',
+ 职业: '产品专家',
+ 教育: '硕士',
+ 技能: '竞品分析、商业化',
+ 自我评价: '偏战略,长期主义者。',
+ },
+ },
+ {
+ id: '20',
+ name: '沈玉',
+ gender: '女',
+ age: 25,
+ height: 161,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '福州',
+ 职业: '交互设计师',
+ 教育: '本科',
+ 技能: '流程设计、可用性测试',
+ 自我评价: '以用户为中心,迭代驱动优化。',
+ },
+ },
+ {
+ id: '21',
+ name: '罗兰',
+ gender: '其他/保密',
+ age: 40,
+ height: 177,
+ marital_status: '保密',
+ introduction: {
+ 籍贯: '呼和浩特',
+ 职业: '翻译',
+ 教育: '硕士',
+ 语言: '中英法德',
+ 自我评价: '严谨细致,语感强。',
+ },
+ },
+ {
+ id: '22',
+ name: '尹峰',
+ gender: '男',
+ age: 29,
+ height: 168,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '石家庄',
+ 职业: '安全工程师',
+ 教育: '本科',
+ 技能: '渗透测试、代码审计',
+ 自我评价: '对攻击面敏感,防守与预警并重。',
+ },
+ },
+ {
+ id: '23',
+ name: '邓雅',
+ gender: '女',
+ age: 37,
+ height: 167,
+ marital_status: '已婚',
+ introduction: {
+ 籍贯: '宁波',
+ 职业: '供应链经理',
+ 教育: '硕士',
+ 技能: '精益管理、库存优化',
+ 自我评价: '注重流程化与跨部门协同。',
+ },
+ },
+ {
+ id: '24',
+ name: '侯哲',
+ gender: '男',
+ age: 21,
+ height: 171,
+ marital_status: '未婚',
+ introduction: {
+ 籍贯: '桂林',
+ 职业: '实习生',
+ 教育: '本科在读',
+ 技能: '前端基础、原型制作',
+ 自我评价: '好学上进,快速吸收新知识。',
+ },
+ },
+ ];
+ }
+}
+
+// 数字范围筛选下拉
+function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): ColumnType {
+ return {
+ title: label,
+ dataIndex,
+ sorter: (a: Resource, b: Resource) => Number((a as any)[dataIndex] ?? 0) - Number((b as any)[dataIndex] ?? 0),
+ filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) => {
+ const [min, max] = String(selectedKeys?.[0] ?? ':').split(':');
+ const [localMin, setLocalMin] = React.useState(min ? Number(min) : undefined);
+ const [localMax, setLocalMax] = React.useState(max ? Number(max) : undefined);
+ return (
+
+
+ setLocalMin(v ?? undefined)}
+ style={{ width: '100%' }}
+ />
+ setLocalMax(v ?? undefined)}
+ style={{ width: '100%' }}
+ />
+
+ }
+ onClick={() => {
+ const key = `${localMin ?? ''}:${localMax ?? ''}`;
+ setSelectedKeys?.([key]);
+ confirm?.();
+ }}
+ >
+ 筛选
+
+
+
+
+
+ );
+ },
+ onFilter: (filterValue: React.Key | boolean, record: Resource) => {
+ const [minStr, maxStr] = String(filterValue).split(':');
+ const min = minStr ? Number(minStr) : undefined;
+ const max = maxStr ? Number(maxStr) : undefined;
+ const val = Number((record as any)[dataIndex] ?? NaN);
+ if (Number.isNaN(val)) return false;
+ if (min !== undefined && val < min) return false;
+ if (max !== undefined && val > max) return false;
+ return true;
+ },
+ } as ColumnType;
+}
+
+type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null };
+
+const ResourceList: React.FC = ({ inputOpen = false, onCloseInput, containerEl }) => {
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
+ const [loading, setLoading] = React.useState(false);
+ const [data, setData] = React.useState([]);
+ const [pagination, setPagination] = React.useState<{ current: number; pageSize: number }>({ current: 1, pageSize: 10 });
+ // const [inputResult, setInputResult] = React.useState(null);
+ const [swipedRowId, setSwipedRowId] = React.useState(null);
+ const touchStartRef = React.useRef<{ x: number; y: number } | null>(null);
+
+ // 图片弹窗状态
+ const [imageModalVisible, setImageModalVisible] = React.useState(false);
+ const [currentImageUrl, setCurrentImageUrl] = React.useState('');
+ // 编辑弹窗状态(仅桌面端)
+ const [editModalVisible, setEditModalVisible] = React.useState(false);
+ const [editingRecord, setEditingRecord] = React.useState(null);
+ const editFormRef = React.useRef(null);
+
+ // 移动端编辑模式状态
+ const [mobileEditing, setMobileEditing] = React.useState(false);
+
+ const handleTableChange: TableProps['onChange'] = (pg) => {
+ setPagination({ current: pg?.current ?? 1, pageSize: pg?.pageSize ?? 10 });
+ };
+
+ React.useEffect(() => {
+ let mounted = true;
+ setLoading(true);
+ fetchResources().then((list) => {
+ if (!mounted) return;
+ setData(list);
+ setLoading(false);
+ });
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ const reloadResources = async () => {
+ setLoading(true);
+ const list = await fetchResources();
+ setData(list);
+ setLoading(false);
+ };
+
+ const confirmDelete = (id: string) => {
+ Modal.confirm({
+ title: '确认删除',
+ content: '删除后不可恢复,是否继续?',
+ okText: '确认',
+ cancelText: '取消',
+ okButtonProps: { danger: true },
+ onOk: async () => {
+ try {
+ const res = await deletePeople(id);
+ if (res.error_code === 0) {
+ message.success('删除成功');
+ } else {
+ message.error(res.error_info || '删除失败');
+ }
+ } catch (err: any) {
+ message.error('删除失败');
+ } finally {
+ await reloadResources();
+ }
+ },
+ });
+ };
+
+ const columns: ColumnsType = [
+ {
+ title: '姓名',
+ dataIndex: 'name',
+ key: 'name',
+ filterIcon: ,
+ filterDropdown: ({ selectedKeys, setSelectedKeys, confirm }) => {
+ return (
+
+ {
+ setSelectedKeys(e.target.value ? [e.target.value] : []);
+ }}
+ onSearch={() => {
+ confirm();
+ }}
+ />
+
+ );
+ },
+ onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record.name).includes(String(filterValue)),
+ render: (text: string, record: Resource) => {
+ // 图片图标逻辑
+ const hasCover = record.cover && record.cover.trim() !== '';
+ const pictureIcon = (
+ {
+ setCurrentImageUrl(record.cover);
+ setImageModalVisible(true);
+ } : undefined}
+ />
+ );
+
+ if (!isMobile) {
+ // 桌面端:姓名后面跟图片图标
+ return (
+
+ {text}
+ {pictureIcon}
+
+ );
+ }
+
+ // 移动端:姓名 + 性别图标 + 图片图标
+ const g = record.gender;
+ const genderIcon = g === '男'
+ ?
+ : g === '女'
+ ?
+ : ;
+
+ return (
+
+ {text}
+ {genderIcon}
+ {pictureIcon}
+
+ );
+ },
+ },
+ // 非移动端显示更多列
+ ...(!isMobile
+ ? [
+ {
+ title: '性别',
+ dataIndex: 'gender',
+ key: 'gender',
+ filters: [
+ { text: '男', value: '男' },
+ { text: '女', value: '女' },
+ { text: '其他/保密', value: '其他/保密' },
+ ],
+ onFilter: (value: React.Key | boolean, record: Resource) => String(record.gender) === String(value),
+ render: (g: string) => {
+ const color = g === '男' ? 'blue' : g === '女' ? 'magenta' : 'default';
+ return {g};
+ },
+ },
+ buildNumberRangeFilter('age', '年龄'),
+ buildNumberRangeFilter('height', '身高'),
+ {
+ title: '婚姻状况',
+ dataIndex: 'marital_status',
+ key: 'marital_status',
+ } as ColumnType,
+ ]
+ : []),
+ {
+ title: '联系人',
+ dataIndex: 'contact',
+ key: 'contact',
+ filterIcon: ,
+ filterDropdown: ({ selectedKeys, setSelectedKeys, confirm }) => {
+ return (
+
+ {
+ setSelectedKeys(e.target.value ? [e.target.value] : []);
+ }}
+ onSearch={() => {
+ confirm();
+ }}
+ />
+
+ );
+ },
+ onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record.contact).includes(String(filterValue)),
+ render: (v: string, record: Resource) => {
+ if (!isMobile) return v ? v : '-';
+ return (
+
+
{v ? v : '-'}
+ {swipedRowId === record.id && (
+
+
+ )}
+
+ );
+ },
+ } as ColumnType,
+ // 非移动端显示操作列
+ ...(!isMobile
+ ? ([{
+ title: '操作',
+ key: 'actions',
+ width: 80,
+ render: (_: any, record: Resource) => (
+
+
+
+ ),
+ },
+ ]
+ : []),
+ {
+ key: 'delete',
+ label: '删除',
+ icon: (
+
+
+
+ ),
+ },
+ ],
+ onClick: ({ key }) => {
+ if (key === 'delete') confirmDelete(record.id);
+ if (key === 'edit' && !isMobile) {
+ setEditingRecord(record);
+ setEditModalVisible(true);
+ }
+ },
+ }}
+ >
+ } />
+
+ ),
+ }] as ColumnsType)
+ : ([] as ColumnsType)),
+ ];
+
+ return (
+
+
+
+ 资源列表
+
+
+ {isMobile && mobileEditing && (
+
+
+ 编辑资源
+
+
(editFormRef.current = f)}
+ />
+
+ {
+ try {
+ const form = editFormRef.current;
+ if (!form) {
+ message.error('表单未就绪');
+ return;
+ }
+ const values = await form.validateFields();
+ 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,
+ };
+ if (!editingRecord) {
+ message.error('缺少当前编辑的人员信息');
+ return;
+ }
+ const res = await updatePeople(editingRecord.id, peopleData);
+ if (res.error_code === 0) {
+ message.success('更新成功');
+ setMobileEditing(false);
+ setEditingRecord(null);
+ await reloadResources();
+ } else {
+ message.error(res.error_info || '更新失败');
+ }
+ } catch (err: any) {
+ if (err?.errorFields) {
+ message.error('请完善表单后再保存');
+ } else {
+ message.error('更新失败');
+ }
+ }
+ }}
+ >
+ 保存
+
+ {
+ setMobileEditing(false);
+ setEditingRecord(null);
+ }}
+ >
+ 取消
+
+
+
+ )}
+
+
+ rowKey="id"
+ loading={loading}
+ columns={columns}
+ dataSource={data}
+ style={{ display: isMobile && mobileEditing ? 'none' : undefined }}
+ onRow={(record) =>
+ isMobile
+ ? {
+ onTouchStart: (e) => {
+ const t = e.touches?.[0];
+ if (t) touchStartRef.current = { x: t.clientX, y: t.clientY };
+ },
+ onTouchEnd: (e) => {
+ const s = touchStartRef.current;
+ const t = e.changedTouches?.[0];
+ touchStartRef.current = null;
+ if (!s || !t) return;
+ const dx = t.clientX - s.x;
+ const dy = t.clientY - s.y;
+ if (Math.abs(dy) > 30) return; // 垂直滑动忽略
+ if (dx < -24) {
+ setSwipedRowId(record.id);
+ } else if (dx > 24) {
+ setSwipedRowId(null);
+ }
+ },
+ }
+ : ({} as any)
+ }
+ pagination={{
+ ...pagination,
+ showSizeChanger: true,
+ pageSizeOptions: [10, 25, 50, 100],
+ position: ['bottomRight'],
+ total: data.length,
+ showTotal: (total) => `总计 ${total} 条`,
+ }}
+ onChange={handleTableChange}
+ expandable={{
+ expandedRowRender: (record: Resource) => {
+ const intro = record.introduction ? Object.entries(record.introduction) : [];
+ return (
+
+ {isMobile && (
+
+ {record.age !== undefined &&
年龄: {record.age}
}
+ {record.height !== undefined &&
身高: {record.height}
}
+ {record.marital_status &&
婚姻状况: {record.marital_status}
}
+
+ )}
+ {intro.length > 0 ? (
+
+ {intro.map(([k, v]) => (
+
+ {k}
+ {String(v)}
+
+ ))}
+
+ ) : (
+
暂无介绍
+ )}
+
+ );
+ },
+ }}
+ />
+
+ {/* 列表页右侧输入抽屉,挂载到标题栏下方容器 */}
+ {})}
+ onResult={(list: any) => {
+ // setInputResult(list);
+ const mapped = transformPeoples(Array.isArray(list) ? list : []);
+ setData(mapped);
+ // 回到第一页,保证用户看到最新结果
+ setPagination((pg) => ({ current: 1, pageSize: pg.pageSize }));
+ }}
+ containerEl={containerEl}
+ showUpload={false}
+ mode={'search'}
+ />
+
+ {/* 图片预览弹窗 */}
+ {
+ setImageModalVisible(false);
+ setCurrentImageUrl('');
+ }}
+ />
+
+ {/* 编辑弹窗,仅桌面端显示 */}
+ {!isMobile && (
+ {
+ setEditModalVisible(false);
+ setEditingRecord(null);
+ }}
+ onOk={async () => {
+ try {
+ const form = editFormRef.current;
+ if (!form) {
+ message.error('表单未就绪');
+ return;
+ }
+ const values = await form.validateFields();
+ 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,
+ };
+ if (!editingRecord) {
+ message.error('缺少当前编辑的人员信息');
+ return;
+ }
+ const res = await updatePeople(editingRecord.id, peopleData);
+ if (res.error_code === 0) {
+ message.success('更新成功');
+ setEditModalVisible(false);
+ setEditingRecord(null);
+ await reloadResources();
+ } else {
+ message.error(res.error_info || '更新失败');
+ }
+ } catch (err: any) {
+ if (err?.errorFields) {
+ message.error('请完善表单后再确认');
+ } else {
+ message.error('更新失败');
+ }
+ }
+ }}
+ destroyOnHidden
+ okText="确认"
+ cancelText="取消"
+ >
+ (editFormRef.current = f)}
+ />
+
+ )}
+
+ );
+};
+
+export default ResourceList;
\ No newline at end of file
diff --git a/src/components/SiderMenu.css b/src/components/SiderMenu.css
new file mode 100644
index 0000000..a304fec
--- /dev/null
+++ b/src/components/SiderMenu.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/components/SiderMenu.tsx b/src/components/SiderMenu.tsx
new file mode 100644
index 0000000..58717a0
--- /dev/null
+++ b/src/components/SiderMenu.tsx
@@ -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 = ({ onNavigate, selectedKey, mobileOpen, onMobileToggle }) => {
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
+ const [collapsed, setCollapsed] = React.useState(false);
+ const [selectedKeys, setSelectedKeys] = React.useState(['home']);
+ const [internalMobileOpen, setInternalMobileOpen] = React.useState(false);
+ 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);
+ }, []);
+
+ React.useEffect(() => {
+ setCollapsed(isMobile);
+ }, [isMobile]);
+
+ // 根据外部 selectedKey 同步选中态
+ React.useEffect(() => {
+ if (selectedKey) {
+ setSelectedKeys([selectedKey]);
+ }
+ }, [selectedKey]);
+
+ const items = [
+ { key: 'home', label: '注册', icon: },
+ { key: 'menu1', label: '列表', icon: },
+ ];
+
+ // 移动端:使用 Drawer 覆盖主内容
+ if (isMobile) {
+ const open = mobileOpen ?? internalMobileOpen;
+ const setOpen = (v: boolean) => (onMobileToggle ? onMobileToggle(v) : setInternalMobileOpen(v));
+ const showInternalTrigger = !onMobileToggle; // 若无外部控制,则显示内部按钮
+ return (
+ <>
+ {showInternalTrigger && (
+ }
+ onClick={() => setInternalMobileOpen((o) => !o)}
+ />
+ )}
+ setOpen(false)}
+ rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
+ styles={{ body: { padding: 0 }, header: { display: 'none' } }}
+ >
+
+
+
+
单身管理
+
录入、展示与搜索你的单身资源
+
+
+
+ >
+ );
+ }
+
+ // PC 端:保持 Sider 行为不变
+ return (
+ setCollapsed(c)}
+ breakpoint="md"
+ collapsedWidth={64}
+ theme="dark"
+ >
+
+
+ {!collapsed && (
+
+
单身管理
+
录入、展示与搜索你的单身资源
+
+ )}
+
+
+ );
+};
+
+export default SiderMenu;
\ No newline at end of file
diff --git a/src/components/TopBar.css b/src/components/TopBar.css
new file mode 100644
index 0000000..2b0e36b
--- /dev/null
+++ b/src/components/TopBar.css
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx
new file mode 100644
index 0000000..6d8deec
--- /dev/null
+++ b/src/components/TopBar.tsx
@@ -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 = ({ onToggleMenu, onToggleInput, showInput }) => {
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
+
+ return (
+
+
+ {isMobile && (
+
+
+
+ )}
+
+
+
+ I FIND U
+
+
+
+ {showInput && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default TopBar;
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
index 08a3ac9..ef78507 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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; }
diff --git a/src/main.tsx b/src/main.tsx
index bef5202..4ac1105 100644
--- a/src/main.tsx
+++ b/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(
-
+
+
+
,
)
diff --git a/src/styles/base.css b/src/styles/base.css
new file mode 100644
index 0000000..e175b4e
--- /dev/null
+++ b/src/styles/base.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/styles/layout.css b/src/styles/layout.css
new file mode 100644
index 0000000..d037e4b
--- /dev/null
+++ b/src/styles/layout.css
@@ -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; }
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index 8b0f57b..f1d41c6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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
})