feat: support AI search in resource list page

This commit is contained in:
2025-10-29 16:09:14 +08:00
parent 38676ec2d0
commit a8170f45be
6 changed files with 117 additions and 48 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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,6 +125,7 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
/> />
</Spin> </Spin>
<div className="input-actions"> <div className="input-actions">
{showUpload && (
<Upload <Upload
accept="image/*" accept="image/*"
beforeUpload={() => false} beforeUpload={() => false}
@@ -107,12 +137,14 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
> >
<Button type="text" icon={<PictureOutlined />} disabled={loading} /> <Button type="text" icon={<PictureOutlined />} disabled={loading} />
</Upload> </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>

View File

@@ -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>

View File

@@ -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>
); );
}; };

View File

@@ -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>