Compare commits
20 Commits
a05bd23766
...
release_v0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c7dbada25 | |||
| d95ec615cd | |||
| ca55ad5512 | |||
| dda8e13587 | |||
| c61a106373 | |||
| 86ad54cccb | |||
| ae617114e0 | |||
| f00ec0588c | |||
| 1f138f5097 | |||
| 18ee8c1ac2 | |||
| c3800541d4 | |||
| 9c83ba38b7 | |||
| d9f4f643b9 | |||
| 9269d77ef7 | |||
| a8170f45be | |||
| 38676ec2d0 | |||
| 01855ba13d | |||
| 3de5d296a0 | |||
| 2cce67d350 | |||
| 5038249c1a |
@@ -1,2 +1,2 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=http://47.109.95.59:20080
|
||||
VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
getPeoples,
|
||||
searchPeoples,
|
||||
deletePeople,
|
||||
updatePeople,
|
||||
getPeoplesPaginated
|
||||
} from '@/apis';
|
||||
|
||||
@@ -154,6 +155,20 @@ const removePeople = async (peopleId: string) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
@@ -10,8 +10,11 @@ export const API_CONFIG = {
|
||||
|
||||
// API 端点
|
||||
export const API_ENDPOINTS = {
|
||||
INPUT: '/input',
|
||||
INPUT_IMAGE: '/input_image',
|
||||
INPUT: '/recognition/input',
|
||||
INPUT_IMAGE: '/recognition/image',
|
||||
// 人员列表查询仍为 /peoples
|
||||
PEOPLES: '/peoples',
|
||||
PEOPLE_BY_ID: (id: string) => `/peoples/${id}`,
|
||||
// 新增单个资源路径 /people
|
||||
PEOPLE: '/people',
|
||||
PEOPLE_BY_ID: (id: string) => `/people/${id}`,
|
||||
} as const;
|
||||
@@ -1,6 +1,6 @@
|
||||
// 人员管理相关 API
|
||||
|
||||
import { get, post, del } from './request';
|
||||
import { get, post, del, put } from './request';
|
||||
import { API_ENDPOINTS } from './config';
|
||||
import type {
|
||||
PostPeopleRequest,
|
||||
@@ -15,10 +15,11 @@ import type {
|
||||
* @param people 人员信息对象
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function createPeople(people: Record<string, any>): Promise<ApiResponse> {
|
||||
export async function createPeople(people: People): Promise<ApiResponse> {
|
||||
const requestData: PostPeopleRequest = { people };
|
||||
console.log('创建人员请求数据:', requestData);
|
||||
return post<ApiResponse>(API_ENDPOINTS.PEOPLES, requestData);
|
||||
// 创建接口改为 /people
|
||||
return post<ApiResponse>(API_ENDPOINTS.PEOPLE, requestData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,13 +110,24 @@ 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: Record<string, any>[]
|
||||
peopleList: People[]
|
||||
): Promise<ApiResponse[]> {
|
||||
const promises = peopleList.map(people => createPeople(people));
|
||||
return Promise.all(promises);
|
||||
|
||||
@@ -45,12 +45,13 @@ export interface GetPeoplesParams {
|
||||
export interface People {
|
||||
id?: string;
|
||||
name?: string;
|
||||
contact?: string;
|
||||
gender?: string;
|
||||
age?: number;
|
||||
height?: number;
|
||||
marital_status?: string;
|
||||
contact?: string;
|
||||
[key: string]: any;
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
|
||||
@@ -34,7 +34,8 @@ export async function postInputImageWithProgress(
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// 后端要求字段名为 image
|
||||
formData.append('image', file);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import './HintText.css';
|
||||
|
||||
const HintText: React.FC = () => {
|
||||
return (
|
||||
<div className="hint-text">
|
||||
提示:支持输入多行文本与上传图片。按 Enter 发送,Shift+Enter 换行。
|
||||
</div>
|
||||
);
|
||||
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;
|
||||
@@ -8,8 +8,11 @@
|
||||
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;
|
||||
|
||||
@@ -9,9 +9,11 @@ type Props = {
|
||||
onClose: () => void;
|
||||
onResult?: (data: any) => void;
|
||||
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
|
||||
showUpload?: boolean; // 透传到输入面板,控制图片上传按钮
|
||||
mode?: 'input' | 'search'; // 透传到输入面板,控制工作模式
|
||||
};
|
||||
|
||||
const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl }) => {
|
||||
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);
|
||||
@@ -64,8 +66,8 @@ const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl })
|
||||
<div className="input-drawer-inner">
|
||||
<div className="input-drawer-title">AI FIND U</div>
|
||||
<div className="input-drawer-box">
|
||||
<InputPanel onResult={handleResult} />
|
||||
<HintText />
|
||||
<InputPanel onResult={handleResult} showUpload={showUpload} mode={mode} />
|
||||
<HintText showUpload={showUpload} />
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -20,6 +20,20 @@
|
||||
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;
|
||||
@@ -29,3 +43,14 @@
|
||||
|
||||
.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 中对齐 */
|
||||
@@ -1,25 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Input, Upload, message, Button, Spin } from 'antd';
|
||||
import { PictureOutlined, SendOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { postInput, postInputImage } from '../apis';
|
||||
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 }) => {
|
||||
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 hasText = value.trim().length > 0;
|
||||
const hasImage = fileList.length > 0;
|
||||
if (!hasText && !hasImage) {
|
||||
message.info('请输入内容或上传图片');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -39,8 +82,8 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
|
||||
response = await postInputImage(file);
|
||||
} else {
|
||||
// 只有文本时,调用文本处理 API
|
||||
console.log('处理文本:', value.trim());
|
||||
response = await postInput(value.trim());
|
||||
console.log('处理文本:', trimmed);
|
||||
response = await postInput(trimmed);
|
||||
}
|
||||
|
||||
console.log('API响应:', response);
|
||||
@@ -53,6 +96,7 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
|
||||
// 清空输入
|
||||
setValue('');
|
||||
setFileList([]);
|
||||
setSavedText('');
|
||||
} else {
|
||||
message.error(response.error_info || '处理失败,请重试');
|
||||
}
|
||||
@@ -78,6 +122,49 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理剪贴板粘贴图片:将图片加入上传列表,复用现有上传流程
|
||||
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
|
||||
@@ -85,34 +172,75 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
|
||||
tip="正在处理中,请稍候..."
|
||||
indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />}
|
||||
>
|
||||
{/** 根据禁用状态动态占位符文案 */}
|
||||
{(() => {
|
||||
return null;
|
||||
})()}
|
||||
<TextArea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="请输入个人信息描述,或上传图片…"
|
||||
placeholder={
|
||||
showUpload && fileList.length > 0
|
||||
? '不可在添加图片时输入信息...'
|
||||
: (showUpload ? '请输入个人信息描述,或上传图片…' : '请输入个人信息描述…')
|
||||
}
|
||||
autoSize={{ minRows: 6, maxRows: 12 }}
|
||||
style={{ fontSize: 14 }}
|
||||
style={{ fontSize: 16 }}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={loading}
|
||||
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={({ fileList }) => setFileList(fileList as any)}
|
||||
maxCount={9}
|
||||
showUploadList={{ showPreviewIcon: false }}
|
||||
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 /> : <SendOutlined />}
|
||||
icon={loading ? <LoadingOutlined /> : (mode === 'search' ? <SearchOutlined /> : <SendOutlined />)}
|
||||
onClick={send}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
aria-label={mode === 'search' ? '搜索' : '发送'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ const LayoutWrapper: React.FC = () => {
|
||||
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) => {
|
||||
@@ -52,12 +53,12 @@ const LayoutWrapper: React.FC = () => {
|
||||
<Layout className="layout-wrapper app-root">
|
||||
{/* 顶部标题栏,位于左侧菜单栏之上 */}
|
||||
<TopBar
|
||||
onToggleMenu={() => setMobileMenuOpen((v) => !v)}
|
||||
onToggleInput={() => isHome && setInputOpen((v) => !v)}
|
||||
isHome={isHome}
|
||||
onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
|
||||
onToggleInput={() => {if (isHome || isList) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
|
||||
showInput={isHome || isList}
|
||||
/>
|
||||
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
|
||||
<Layout ref={layoutShellRef as any}>
|
||||
<Layout ref={layoutShellRef as any} className="layout-shell">
|
||||
<SiderMenu
|
||||
onNavigate={handleNavigate}
|
||||
selectedKey={selectedKey}
|
||||
@@ -76,7 +77,16 @@ const LayoutWrapper: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/resources" element={<ResourceList />} />
|
||||
<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>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
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 } from '../apis';
|
||||
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 }) => {
|
||||
const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton = false, onFormReady }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -24,6 +29,7 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData }) => {
|
||||
|
||||
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;
|
||||
@@ -35,23 +41,31 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData }) => {
|
||||
form.setFieldsValue(formData);
|
||||
|
||||
// 显示成功消息
|
||||
message.success('已自动填充表单,请检查并确认信息');
|
||||
// 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 = {
|
||||
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,
|
||||
contact: values.contact || undefined,
|
||||
cover: values.cover || undefined,
|
||||
};
|
||||
|
||||
console.log('提交人员数据:', peopleData);
|
||||
@@ -105,6 +119,14 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData }) => {
|
||||
</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: '请选择性别' }]}>
|
||||
@@ -151,11 +173,13 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData }) => {
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag, message } from 'antd';
|
||||
import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag, message, Modal, Dropdown, Input, Select } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||
import type { FilterDropdownProps } from 'antd/es/table/interface';
|
||||
import type { TableProps } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
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<string, string>;
|
||||
export type Resource = People;
|
||||
// 资源行类型:确保 id 一定存在且为 string,避免在使用处出现 "string | undefined" 类型问题
|
||||
export type Resource = Omit<People, 'id'> & { 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<Resource[]> {
|
||||
@@ -31,16 +52,7 @@ async function fetchResources(): Promise<Resource[]> {
|
||||
}
|
||||
|
||||
// 转换数据格式以匹配组件期望的结构
|
||||
return response.data?.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 || '',
|
||||
})) || [];
|
||||
return transformPeoples(response.data || []);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('获取人员列表失败:', error);
|
||||
@@ -465,7 +477,7 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
|
||||
onClick={() => {
|
||||
const key = `${localMin ?? ''}:${localMax ?? ''}`;
|
||||
setSelectedKeys?.([key]);
|
||||
confirm?.();
|
||||
confirm?.({ closeDropdown: true });
|
||||
}}
|
||||
>
|
||||
筛选
|
||||
@@ -473,8 +485,11 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setLocalMin(undefined);
|
||||
setLocalMax(undefined);
|
||||
setSelectedKeys?.([]);
|
||||
clearFilters?.();
|
||||
confirm?.({ closeDropdown: true });
|
||||
}}
|
||||
>
|
||||
重置
|
||||
@@ -497,12 +512,86 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
|
||||
} as ColumnType<Resource>;
|
||||
}
|
||||
|
||||
const ResourceList: React.FC = () => {
|
||||
// 枚举筛选下拉(用于性别等枚举类列)
|
||||
function buildEnumFilter(dataIndex: keyof Resource, label: string, options: Array<{ label: string; value: string }>): ColumnType<Resource> {
|
||||
return {
|
||||
title: label,
|
||||
dataIndex,
|
||||
key: String(dataIndex),
|
||||
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) => {
|
||||
const [val, setVal] = React.useState<string | undefined>(
|
||||
(selectedKeys && selectedKeys[0] ? String(selectedKeys[0]) : undefined)
|
||||
);
|
||||
return (
|
||||
<div className="byte-table-custom-filter" style={{ padding: 8 }}>
|
||||
<Space direction="vertical" style={{ width: 200 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={`请选择${label}`}
|
||||
value={val}
|
||||
onChange={(v) => setVal(v)}
|
||||
options={options}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedKeys?.(val ? [val] : []);
|
||||
confirm?.({ closeDropdown: true });
|
||||
}}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setVal(undefined);
|
||||
setSelectedKeys?.([]);
|
||||
clearFilters?.();
|
||||
confirm?.({ closeDropdown: true });
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
onFilter: (filterValue: React.Key | boolean, record: Resource) =>
|
||||
String((record as any)[dataIndex]) === String(filterValue),
|
||||
render: (g: string) => {
|
||||
const color = g === '男' ? 'blue' : g === '女' ? 'magenta' : 'default';
|
||||
return <Tag color={color}>{g}</Tag>;
|
||||
},
|
||||
} as ColumnType<Resource>;
|
||||
}
|
||||
|
||||
type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null };
|
||||
|
||||
const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState<Resource[]>([]);
|
||||
const [pagination, setPagination] = React.useState<{ current: number; pageSize: number }>({ current: 1, pageSize: 10 });
|
||||
// const [inputResult, setInputResult] = React.useState<any>(null);
|
||||
const [swipedRowId, setSwipedRowId] = React.useState<string | null>(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<Resource | null>(null);
|
||||
const editFormRef = React.useRef<FormInstance | null>(null);
|
||||
|
||||
// 移动端编辑模式状态
|
||||
const [mobileEditing, setMobileEditing] = React.useState(false);
|
||||
|
||||
const handleTableChange: TableProps<Resource>['onChange'] = (pg) => {
|
||||
setPagination({ current: pg?.current ?? 1, pageSize: pg?.pageSize ?? 10 });
|
||||
@@ -521,32 +610,112 @@ const ResourceList: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
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<Resource> = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string) => <span style={{ fontWeight: 600 }}>{text}</span>,
|
||||
filterIcon: <SearchOutlined />,
|
||||
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm }) => {
|
||||
return (
|
||||
<div className="byte-table-custom-filter">
|
||||
<Input.Search
|
||||
placeholder="搜索资源..."
|
||||
value={selectedKeys[0] || ""}
|
||||
onChange={(e) => {
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : []);
|
||||
}}
|
||||
onSearch={() => {
|
||||
confirm();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
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 <Tag color={color}>{g}</Tag>;
|
||||
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 = (
|
||||
<PictureOutlined
|
||||
style={{
|
||||
color: hasCover ? '#1677ff' : '#9ca3af',
|
||||
cursor: hasCover ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={hasCover ? () => {
|
||||
setCurrentImageUrl(record.cover);
|
||||
setImageModalVisible(true);
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
// 桌面端:姓名后面跟图片图标
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600 }}>{text}</span>
|
||||
{pictureIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端:姓名 + 性别图标 + 图片图标
|
||||
const g = record.gender;
|
||||
const genderIcon = g === '男'
|
||||
? <ManOutlined style={{ color: '#1677ff' }} />
|
||||
: g === '女'
|
||||
? <WomanOutlined style={{ color: '#eb2f96' }} />
|
||||
: <ExclamationCircleOutlined style={{ color: '#9ca3af' }} />;
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600 }}>{text}</span>
|
||||
{genderIcon}
|
||||
{pictureIcon}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
buildNumberRangeFilter('age', '年龄'),
|
||||
// 非移动端显示更多列
|
||||
...(!isMobile
|
||||
? [
|
||||
buildEnumFilter('gender', '性别', [
|
||||
{ label: '男', value: '男' },
|
||||
{ label: '女', value: '女' },
|
||||
{ label: '其他/保密', value: '其他/保密' },
|
||||
]),
|
||||
buildNumberRangeFilter('age', '年龄'),
|
||||
buildNumberRangeFilter('height', '身高'),
|
||||
{
|
||||
title: '婚姻状况',
|
||||
@@ -559,8 +728,154 @@ const ResourceList: React.FC = () => {
|
||||
title: '联系人',
|
||||
dataIndex: 'contact',
|
||||
key: 'contact',
|
||||
render: (v: string) => (v ? v : '-'),
|
||||
filterIcon: <SearchOutlined />,
|
||||
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm }) => {
|
||||
return (
|
||||
<div className="byte-table-custom-filter">
|
||||
<Input.Search
|
||||
placeholder="搜索联系人..."
|
||||
value={selectedKeys[0] || ""}
|
||||
onChange={(e) => {
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : []);
|
||||
}}
|
||||
onSearch={() => {
|
||||
confirm();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record.contact).includes(String(filterValue)),
|
||||
render: (v: string, record: Resource) => {
|
||||
if (!isMobile) return v ? v : '-';
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1 }}>{v ? v : '-'}</span>
|
||||
{swipedRowId === record.id && (
|
||||
<div style={{ display: 'inline-flex', gap: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#1677ff',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<EditOutlined style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
}
|
||||
onClick={() => {
|
||||
setEditingRecord(record);
|
||||
setMobileEditing(true);
|
||||
setSwipedRowId(null);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
icon={
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#f5222d',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
}
|
||||
onClick={() => confirmDelete(record.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} as ColumnType<Resource>,
|
||||
// 非移动端显示操作列
|
||||
...(!isMobile
|
||||
? ([{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_: any, record: Resource) => (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: [
|
||||
...(!isMobile
|
||||
? [
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#1677ff',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<EditOutlined style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#f5222d',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'delete') confirmDelete(record.id);
|
||||
if (key === 'edit' && !isMobile) {
|
||||
setEditingRecord(record);
|
||||
setEditModalVisible(true);
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button type="text" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
),
|
||||
}] as ColumnsType<Resource>)
|
||||
: ([] as ColumnsType<Resource>)),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -570,11 +885,104 @@ const ResourceList: React.FC = () => {
|
||||
资源列表
|
||||
</Typography.Title>
|
||||
|
||||
{isMobile && mobileEditing && (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ color: 'var(--text-primary)' }}>
|
||||
编辑资源
|
||||
</Typography.Title>
|
||||
<PeopleForm
|
||||
initialData={editingRecord || undefined}
|
||||
hideSubmitButton
|
||||
onFormReady={(f) => (editFormRef.current = f)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={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('更新成功');
|
||||
setMobileEditing(false);
|
||||
setEditingRecord(null);
|
||||
await reloadResources();
|
||||
} else {
|
||||
message.error(res.error_info || '更新失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) {
|
||||
message.error('请完善表单后再保存');
|
||||
} else {
|
||||
message.error('更新失败');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileEditing(false);
|
||||
setEditingRecord(null);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table<Resource>
|
||||
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,
|
||||
@@ -590,9 +998,10 @@ const ResourceList: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: '8px 24px' }}>
|
||||
{isMobile && (
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 12, color: '#cbd5e1' }}>
|
||||
{record.height !== undefined && <div>身高: {record.height}</div>}
|
||||
{record.marital_status && <div>婚姻状况: {record.marital_status}</div>}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 12, color: '#434343ff' }}>
|
||||
{record.age !== undefined && <div><span style={{ color: '#000000ff', fontWeight: 600 }}>年龄:</span> <span style={{ color: '#2d2d2dff' }}>{record.age}</span></div>}
|
||||
{record.height !== undefined && <div><span style={{ color: '#000000ff', fontWeight: 600 }}>身高:</span> <span style={{ color: '#2d2d2dff' }}>{record.height}</span></div>}
|
||||
{record.marital_status && <div><span style={{ color: '#000000ff', fontWeight: 600 }}>婚姻状况:</span> <span style={{ color: '#2d2d2dff' }}>{record.marital_status}</span></div>}
|
||||
</div>
|
||||
)}
|
||||
{intro.length > 0 ? (
|
||||
@@ -614,8 +1023,8 @@ const ResourceList: React.FC = () => {
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#9ca3af' }}>{k}</span>
|
||||
<span style={{ color: '#e5e7eb' }}>{String(v)}</span>
|
||||
<span style={{ color: '#000000ff', fontWeight: 600 }}>{k}</span>
|
||||
<span style={{ color: '#2d2d2dff' }}>{String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -628,6 +1037,94 @@ const ResourceList: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 列表页右侧输入抽屉,挂载到标题栏下方容器 */}
|
||||
<InputDrawer
|
||||
open={inputOpen}
|
||||
onClose={onCloseInput || (() => {})}
|
||||
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'}
|
||||
/>
|
||||
|
||||
{/* 图片预览弹窗 */}
|
||||
<ImageModal
|
||||
visible={imageModalVisible}
|
||||
imageUrl={currentImageUrl}
|
||||
onClose={() => {
|
||||
setImageModalVisible(false);
|
||||
setCurrentImageUrl('');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 编辑弹窗,仅桌面端显示 */}
|
||||
{!isMobile && (
|
||||
<Modal
|
||||
open={editModalVisible}
|
||||
title="编辑"
|
||||
width="50%"
|
||||
style={{ minWidth: 768 }}
|
||||
onCancel={() => {
|
||||
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="取消"
|
||||
>
|
||||
<PeopleForm
|
||||
initialData={editingRecord || undefined}
|
||||
hideSubmitButton
|
||||
onFormReady={(f) => (editFormRef.current = f)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
|
||||
import { CodeOutlined, HomeOutlined, UnorderedListOutlined, MenuOutlined } from '@ant-design/icons';
|
||||
import { HeartOutlined, FormOutlined, UnorderedListOutlined, MenuOutlined } from '@ant-design/icons';
|
||||
import './SiderMenu.css';
|
||||
|
||||
const { Sider } = Layout;
|
||||
@@ -19,6 +19,18 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
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);
|
||||
@@ -32,9 +44,8 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
}, [selectedKey]);
|
||||
|
||||
const items = [
|
||||
{ key: 'home', label: '注册', icon: <HomeOutlined /> },
|
||||
{ key: 'home', label: '注册', icon: <FormOutlined /> },
|
||||
{ key: 'menu1', label: '列表', icon: <UnorderedListOutlined /> },
|
||||
// { key: 'menu2', label: '菜单2', icon: <AppstoreOutlined /> },
|
||||
];
|
||||
|
||||
// 移动端:使用 Drawer 覆盖主内容
|
||||
@@ -58,10 +69,11 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
width="100%"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
|
||||
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
|
||||
>
|
||||
<div className="sider-header">
|
||||
<CodeOutlined style={{ fontSize: 22 }} />
|
||||
<HeartOutlined style={{ fontSize: 22 }} />
|
||||
<div>
|
||||
<div className="sider-title">单身管理</div>
|
||||
<div className="sider-desc">录入、展示与搜索你的单身资源</div>
|
||||
@@ -96,7 +108,7 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
theme="dark"
|
||||
>
|
||||
<div className={`sider-header ${collapsed ? 'collapsed' : ''}`}>
|
||||
<CodeOutlined style={{ fontSize: 22 }} />
|
||||
<HeartOutlined style={{ fontSize: 22 }} />
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div className="sider-title">单身管理</div>
|
||||
|
||||
@@ -6,10 +6,10 @@ import './TopBar.css';
|
||||
type Props = {
|
||||
onToggleMenu?: () => void;
|
||||
onToggleInput?: () => void;
|
||||
isHome?: boolean;
|
||||
showInput?: boolean;
|
||||
};
|
||||
|
||||
const TopBar: React.FC<Props> = ({ onToggleMenu, onToggleInput, isHome }) => {
|
||||
const TopBar: React.FC<Props> = ({ onToggleMenu, onToggleInput, showInput }) => {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
@@ -28,7 +28,7 @@ const TopBar: React.FC<Props> = ({ onToggleMenu, onToggleInput, isHome }) => {
|
||||
</div>
|
||||
|
||||
<div className="topbar-right">
|
||||
{isHome && (
|
||||
{showInput && (
|
||||
<button className="icon-btn" onClick={onToggleInput} aria-label="打开/收起输入">
|
||||
<RobotOutlined />
|
||||
</button>
|
||||
|
||||
@@ -8,7 +8,12 @@ import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_relativeSplatPath: true,
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
/* 消除 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;
|
||||
@@ -28,6 +35,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: auto; /* 仅主内容区滚动 */
|
||||
background: var(--bg-app);
|
||||
}
|
||||
.content-body {
|
||||
|
||||
Reference in New Issue
Block a user