feat: support AI search in resource list page
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './HintText.css';
|
import './HintText.css';
|
||||||
|
|
||||||
const HintText: React.FC = () => {
|
type Props = { showUpload?: boolean };
|
||||||
return (
|
|
||||||
<div className="hint-text">
|
const HintText: React.FC<Props> = ({ showUpload = true }) => {
|
||||||
提示:支持输入多行文本与上传图片。按 Enter 发送,Shift+Enter 换行。
|
const text = showUpload
|
||||||
</div>
|
? '提示:支持输入多行文本与上传图片。按 Enter 发送,Shift+Enter 换行。'
|
||||||
);
|
: '提示:支持输入多行文本。按 Enter 发送,Shift+Enter 换行。';
|
||||||
|
return <div className="hint-text">{text}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HintText;
|
export default HintText;
|
||||||
@@ -9,9 +9,11 @@ type Props = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onResult?: (data: any) => void;
|
onResult?: (data: any) => void;
|
||||||
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
|
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 screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const [topbarHeight, setTopbarHeight] = React.useState<number>(56);
|
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-inner">
|
||||||
<div className="input-drawer-title">AI FIND U</div>
|
<div className="input-drawer-title">AI FIND U</div>
|
||||||
<div className="input-drawer-box">
|
<div className="input-drawer-box">
|
||||||
<InputPanel onResult={handleResult} />
|
<InputPanel onResult={handleResult} showUpload={showUpload} mode={mode} />
|
||||||
<HintText />
|
<HintText showUpload={showUpload} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -1,25 +1,54 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Input, Upload, message, Button, Spin } from 'antd';
|
import { Input, Upload, message, Button, Spin } from 'antd';
|
||||||
import { PictureOutlined, SendOutlined, LoadingOutlined } from '@ant-design/icons';
|
import { PictureOutlined, SendOutlined, LoadingOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import { postInput, postInputImage } from '../apis';
|
import { postInput, postInputImage, getPeoples } from '../apis';
|
||||||
import './InputPanel.css';
|
import './InputPanel.css';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
interface InputPanelProps {
|
interface InputPanelProps {
|
||||||
onResult?: (data: any) => void;
|
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 [value, setValue] = React.useState('');
|
||||||
const [fileList, setFileList] = React.useState<any[]>([]);
|
const [fileList, setFileList] = React.useState<any[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
const hasText = value.trim().length > 0;
|
const trimmed = value.trim();
|
||||||
const hasImage = fileList.length > 0;
|
const hasText = trimmed.length > 0;
|
||||||
if (!hasText && !hasImage) {
|
const hasImage = showUpload && fileList.length > 0;
|
||||||
message.info('请输入内容或上传图片');
|
|
||||||
|
// 搜索模式:仅以文本触发检索,忽略图片
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +68,8 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
|
|||||||
response = await postInputImage(file);
|
response = await postInputImage(file);
|
||||||
} else {
|
} else {
|
||||||
// 只有文本时,调用文本处理 API
|
// 只有文本时,调用文本处理 API
|
||||||
console.log('处理文本:', value.trim());
|
console.log('处理文本:', trimmed);
|
||||||
response = await postInput(value.trim());
|
response = await postInput(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('API响应:', response);
|
console.log('API响应:', response);
|
||||||
@@ -88,7 +117,7 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
|
|||||||
<TextArea
|
<TextArea
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
placeholder="请输入个人信息描述,或上传图片…"
|
placeholder={showUpload ? '请输入个人信息描述,或上传图片…' : '请输入个人信息描述…'}
|
||||||
autoSize={{ minRows: 6, maxRows: 12 }}
|
autoSize={{ minRows: 6, maxRows: 12 }}
|
||||||
style={{ fontSize: 14 }}
|
style={{ fontSize: 14 }}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
@@ -96,23 +125,26 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
|
|||||||
/>
|
/>
|
||||||
</Spin>
|
</Spin>
|
||||||
<div className="input-actions">
|
<div className="input-actions">
|
||||||
<Upload
|
{showUpload && (
|
||||||
accept="image/*"
|
<Upload
|
||||||
beforeUpload={() => false}
|
accept="image/*"
|
||||||
fileList={fileList}
|
beforeUpload={() => false}
|
||||||
onChange={({ fileList }) => setFileList(fileList as any)}
|
fileList={fileList}
|
||||||
maxCount={9}
|
onChange={({ fileList }) => setFileList(fileList as any)}
|
||||||
showUploadList={{ showPreviewIcon: false }}
|
maxCount={9}
|
||||||
disabled={loading}
|
showUploadList={{ showPreviewIcon: false }}
|
||||||
>
|
disabled={loading}
|
||||||
<Button type="text" icon={<PictureOutlined />} disabled={loading} />
|
>
|
||||||
</Upload>
|
<Button type="text" icon={<PictureOutlined />} disabled={loading} />
|
||||||
|
</Upload>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={loading ? <LoadingOutlined /> : <SendOutlined />}
|
icon={loading ? <LoadingOutlined /> : (mode === 'search' ? <SearchOutlined /> : <SendOutlined />)}
|
||||||
onClick={send}
|
onClick={send}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
aria-label={mode === 'search' ? '搜索' : '发送'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const LayoutWrapper: React.FC = () => {
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||||
const [inputOpen, setInputOpen] = React.useState(false);
|
const [inputOpen, setInputOpen] = React.useState(false);
|
||||||
const isHome = location.pathname === '/';
|
const isHome = location.pathname === '/';
|
||||||
|
const isList = location.pathname === '/resources';
|
||||||
const layoutShellRef = React.useRef<HTMLDivElement>(null);
|
const layoutShellRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const pathToKey = (path: string) => {
|
const pathToKey = (path: string) => {
|
||||||
@@ -53,8 +54,8 @@ const LayoutWrapper: React.FC = () => {
|
|||||||
{/* 顶部标题栏,位于左侧菜单栏之上 */}
|
{/* 顶部标题栏,位于左侧菜单栏之上 */}
|
||||||
<TopBar
|
<TopBar
|
||||||
onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
|
onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
|
||||||
onToggleInput={() => {if (isHome) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
|
onToggleInput={() => {if (isHome || isList) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
|
||||||
isHome={isHome}
|
showInput={isHome || isList}
|
||||||
/>
|
/>
|
||||||
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
|
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
|
||||||
<Layout ref={layoutShellRef as any}>
|
<Layout ref={layoutShellRef as any}>
|
||||||
@@ -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>} />
|
<Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>菜单2的内容暂未实现</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FilterDropdownProps } from 'antd/es/table/interface';
|
|||||||
import type { TableProps } from 'antd';
|
import type { TableProps } from 'antd';
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import './MainContent.css';
|
import './MainContent.css';
|
||||||
|
import InputDrawer from './InputDrawer.tsx';
|
||||||
import { getPeoples } from '../apis';
|
import { getPeoples } from '../apis';
|
||||||
import type { People } from '../apis';
|
import type { People } from '../apis';
|
||||||
|
|
||||||
@@ -14,6 +15,20 @@ const { Content } = Layout;
|
|||||||
export type DictValue = Record<string, string>;
|
export type DictValue = Record<string, string>;
|
||||||
export type Resource = People;
|
export type Resource = People;
|
||||||
|
|
||||||
|
// 统一转换 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 || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// 获取人员列表数据
|
// 获取人员列表数据
|
||||||
async function fetchResources(): Promise<Resource[]> {
|
async function fetchResources(): Promise<Resource[]> {
|
||||||
try {
|
try {
|
||||||
@@ -31,16 +46,7 @@ async function fetchResources(): Promise<Resource[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 转换数据格式以匹配组件期望的结构
|
// 转换数据格式以匹配组件期望的结构
|
||||||
return response.data?.map((person: any) => ({
|
return transformPeoples(response.data || []);
|
||||||
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 || '',
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('获取人员列表失败:', error);
|
console.error('获取人员列表失败:', error);
|
||||||
@@ -497,12 +503,15 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
|
|||||||
} as ColumnType<Resource>;
|
} as ColumnType<Resource>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResourceList: React.FC = () => {
|
type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null };
|
||||||
|
|
||||||
|
const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [data, setData] = React.useState<Resource[]>([]);
|
const [data, setData] = React.useState<Resource[]>([]);
|
||||||
const [pagination, setPagination] = React.useState<{ current: number; pageSize: number }>({ current: 1, pageSize: 10 });
|
const [pagination, setPagination] = React.useState<{ current: number; pageSize: number }>({ current: 1, pageSize: 10 });
|
||||||
|
const [inputResult, setInputResult] = React.useState<any>(null);
|
||||||
|
|
||||||
const handleTableChange: TableProps<Resource>['onChange'] = (pg) => {
|
const handleTableChange: TableProps<Resource>['onChange'] = (pg) => {
|
||||||
setPagination({ current: pg?.current ?? 1, pageSize: pg?.pageSize ?? 10 });
|
setPagination({ current: pg?.current ?? 1, pageSize: pg?.pageSize ?? 10 });
|
||||||
@@ -633,6 +642,21 @@ const ResourceList: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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'}
|
||||||
|
/>
|
||||||
</Content>
|
</Content>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import './TopBar.css';
|
|||||||
type Props = {
|
type Props = {
|
||||||
onToggleMenu?: () => void;
|
onToggleMenu?: () => void;
|
||||||
onToggleInput?: () => 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 screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ const TopBar: React.FC<Props> = ({ onToggleMenu, onToggleInput, isHome }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
{isHome && (
|
{showInput && (
|
||||||
<button className="icon-btn" onClick={onToggleInput} aria-label="打开/收起输入">
|
<button className="icon-btn" onClick={onToggleInput} aria-label="打开/收起输入">
|
||||||
<RobotOutlined />
|
<RobotOutlined />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user