Compare commits

...

10 Commits

10 changed files with 342 additions and 82 deletions

View File

@@ -1,2 +1,2 @@
# 生产环境配置 # 生产环境配置
VITE_API_BASE_URL=http://47.109.95.59:20080 VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443

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

@@ -8,8 +8,11 @@
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
align-items: center; /* 居中内部内容 */ align-items: center; /* 居中内部内容 */
width: 100%;
} }
.input-drawer-title { .input-drawer-title {
font-size: 24px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: 0.5px; letter-spacing: 0.5px;

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

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) => {
@@ -52,12 +53,12 @@ const LayoutWrapper: React.FC = () => {
<Layout className="layout-wrapper app-root"> <Layout className="layout-wrapper app-root">
{/* 顶部标题栏,位于左侧菜单栏之上 */} {/* 顶部标题栏,位于左侧菜单栏之上 */}
<TopBar <TopBar
onToggleMenu={() => setMobileMenuOpen((v) => !v)} onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
onToggleInput={() => isHome && 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} className="layout-shell">
<SiderMenu <SiderMenu
onNavigate={handleNavigate} onNavigate={handleNavigate}
selectedKey={selectedKey} 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>} /> <Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>2</div>} />
</Routes> </Routes>
</Layout> </Layout>

View File

@@ -1,18 +1,35 @@
import React from 'react'; 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 } from 'antd';
import type { ColumnsType, ColumnType } from 'antd/es/table'; import type { ColumnsType, ColumnType } from 'antd/es/table';
import type { FilterDropdownProps } from 'antd/es/table/interface'; 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, EllipsisOutlined, DeleteOutlined, ManOutlined, WomanOutlined, ExclamationCircleOutlined } 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';
import { deletePeople } from '../apis/people';
const { Content } = Layout; const { Content } = Layout;
// 数据类型定义 - 使用 API 中的 People 类型 // 数据类型定义 - 使用 API 中的 People 类型
export type DictValue = Record<string, string>; 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 || '',
}));
}
// 获取人员列表数据 // 获取人员列表数据
async function fetchResources(): Promise<Resource[]> { async function fetchResources(): Promise<Resource[]> {
@@ -31,16 +48,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 +505,17 @@ 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 [swipedRowId, setSwipedRowId] = React.useState<string | null>(null);
const touchStartRef = React.useRef<{ x: number; y: number } | null>(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 });
@@ -521,32 +534,95 @@ 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> = [ const columns: ColumnsType<Resource> = [
{ {
title: '姓名', title: '姓名',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
render: (text: string) => <span style={{ fontWeight: 600 }}>{text}</span>, filterIcon: <SearchOutlined />,
}, filterDropdown: ({ selectedKeys, setSelectedKeys, confirm }) => {
{ return (
title: '性别', <div className="byte-table-custom-filter">
dataIndex: 'gender', <Input.Search
key: 'gender', placeholder="搜索资源..."
filters: [ value={selectedKeys[0] || ""}
{ text: '男', value: '男' }, onChange={(e) => {
{ text: '女', value: '女' }, setSelectedKeys(e.target.value ? [e.target.value] : []);
{ text: '其他/保密', value: '其他/保密' }, }}
], onSearch={() => {
onFilter: (value: React.Key | boolean, record: Resource) => String(record.gender) === String(value), confirm();
render: (g: string) => { }}
const color = g === '男' ? 'blue' : g === '女' ? 'magenta' : 'default'; />
return <Tag color={color}>{g}</Tag>; </div>
);
},
onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record.name).includes(String(filterValue)),
render: (text: string, record: Resource) => {
if (!isMobile) return <span style={{ fontWeight: 600 }}>{text}</span>;
const g = record.gender;
const icon = 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>
{icon}
</span>
);
}, },
}, },
buildNumberRangeFilter('age', '年龄'),
// 非移动端显示更多列 // 非移动端显示更多列
...(!isMobile ...(!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 <Tag color={color}>{g}</Tag>;
},
},
buildNumberRangeFilter('age', '年龄'),
buildNumberRangeFilter('height', '身高'), buildNumberRangeFilter('height', '身高'),
{ {
title: '婚姻状况', title: '婚姻状况',
@@ -559,8 +635,84 @@ const ResourceList: React.FC = () => {
title: '联系人', title: '联系人',
dataIndex: 'contact', dataIndex: 'contact',
key: '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 && (
<Button
type="primary"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => confirmDelete(record.id)}
/>
)}
</div>
);
},
} as ColumnType<Resource>, } as ColumnType<Resource>,
// 非移动端显示操作列
...(!isMobile
? ([{
title: '操作',
key: 'actions',
width: 80,
render: (_: any, record: Resource) => (
<Dropdown
trigger={["click"]}
menu={{
items: [
{
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);
},
}}
>
<Button type="text" icon={<EllipsisOutlined />} />
</Dropdown>
),
}] as ColumnsType<Resource>)
: ([] as ColumnsType<Resource>)),
]; ];
return ( return (
@@ -575,6 +727,30 @@ const ResourceList: React.FC = () => {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
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={{
...pagination, ...pagination,
showSizeChanger: true, showSizeChanger: true,
@@ -590,9 +766,10 @@ const ResourceList: React.FC = () => {
return ( return (
<div style={{ padding: '8px 24px' }}> <div style={{ padding: '8px 24px' }}>
{isMobile && ( {isMobile && (
<div style={{ display: 'flex', gap: 16, marginBottom: 12, color: '#cbd5e1' }}> <div style={{ display: 'flex', gap: 16, marginBottom: 12, color: '#434343ff' }}>
{record.height !== undefined && <div>: {record.height}</div>} {record.age !== undefined && <div><span style={{ color: '#000000ff', fontWeight: 600 }}>:</span> <span style={{ color: '#2d2d2dff' }}>{record.age}</span></div>}
{record.marital_status && <div>: {record.marital_status}</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> </div>
)} )}
{intro.length > 0 ? ( {intro.length > 0 ? (
@@ -614,8 +791,8 @@ const ResourceList: React.FC = () => {
wordBreak: 'break-word', wordBreak: 'break-word',
}} }}
> >
<span style={{ color: '#9ca3af' }}>{k}</span> <span style={{ color: '#000000ff', fontWeight: 600 }}>{k}</span>
<span style={{ color: '#e5e7eb' }}>{String(v)}</span> <span style={{ color: '#2d2d2dff' }}>{String(v)}</span>
</div> </div>
))} ))}
</div> </div>
@@ -628,6 +805,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

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Layout, Menu, Grid, Drawer, Button } from 'antd'; 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'; import './SiderMenu.css';
const { Sider } = Layout; const { Sider } = Layout;
@@ -19,6 +19,18 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
const [collapsed, setCollapsed] = React.useState(false); const [collapsed, setCollapsed] = React.useState(false);
const [selectedKeys, setSelectedKeys] = React.useState<string[]>(['home']); const [selectedKeys, setSelectedKeys] = React.useState<string[]>(['home']);
const [internalMobileOpen, setInternalMobileOpen] = React.useState(false); 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(() => { React.useEffect(() => {
setCollapsed(isMobile); setCollapsed(isMobile);
@@ -32,9 +44,8 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
}, [selectedKey]); }, [selectedKey]);
const items = [ const items = [
{ key: 'home', label: '注册', icon: <HomeOutlined /> }, { key: 'home', label: '注册', icon: <FormOutlined /> },
{ key: 'menu1', label: '列表', icon: <UnorderedListOutlined /> }, { key: 'menu1', label: '列表', icon: <UnorderedListOutlined /> },
// { key: 'menu2', label: '菜单2', icon: <AppstoreOutlined /> },
]; ];
// 移动端:使用 Drawer 覆盖主内容 // 移动端:使用 Drawer 覆盖主内容
@@ -58,10 +69,11 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
width="100%" width="100%"
open={open} open={open}
onClose={() => setOpen(false)} 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"> <div className="sider-header">
<CodeOutlined style={{ fontSize: 22 }} /> <HeartOutlined style={{ fontSize: 22 }} />
<div> <div>
<div className="sider-title"></div> <div className="sider-title"></div>
<div className="sider-desc"></div> <div className="sider-desc"></div>
@@ -96,7 +108,7 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
theme="dark" theme="dark"
> >
<div className={`sider-header ${collapsed ? 'collapsed' : ''}`}> <div className={`sider-header ${collapsed ? 'collapsed' : ''}`}>
<CodeOutlined style={{ fontSize: 22 }} /> <HeartOutlined style={{ fontSize: 22 }} />
{!collapsed && ( {!collapsed && (
<div> <div>
<div className="sider-title"></div> <div className="sider-title"></div>

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>

View File

@@ -6,6 +6,13 @@
/* 消除 Ant Layout 默认白底,避免顶栏下方看到白边 */ /* 消除 Ant Layout 默认白底,避免顶栏下方看到白边 */
.layout-wrapper .ant-layout { background: transparent; } .layout-wrapper .ant-layout { background: transparent; }
/* 顶栏下的主布局容器:固定视口高度,隐藏外层滚动 */
.layout-shell {
height: calc(100vh - 56px);
overflow: hidden;
}
.layout-shell .ant-layout { height: 100%; }
/* 侧栏头部区域 */ /* 侧栏头部区域 */
.sider-header { .sider-header {
display: flex; display: flex;
@@ -28,6 +35,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: auto; /* 仅主内容区滚动 */
background: var(--bg-app); background: var(--bg-app);
} }
.content-body { .content-body {