refactor: add top bar and adjust home page layout

- add top bar to show the title of web site
- move ai input and input image into right side drawer
This commit is contained in:
2025-10-28 16:46:45 +08:00
parent 1284698948
commit bd817279db
11 changed files with 280 additions and 33 deletions

View File

@@ -12,7 +12,7 @@ import type { PostInputRequest, ApiResponse } from './types';
export async function postInput(text: string): Promise<ApiResponse> { export async function postInput(text: string): Promise<ApiResponse> {
const requestData: PostInputRequest = { text }; const requestData: PostInputRequest = { text };
// 为 postInput 设置 30 秒超时时间 // 为 postInput 设置 30 秒超时时间
return post<ApiResponse>(API_ENDPOINTS.INPUT, requestData, { timeout: 30000 }); return post<ApiResponse>(API_ENDPOINTS.INPUT, requestData, { timeout: 120000 });
} }
/** /**
@@ -22,5 +22,5 @@ export async function postInput(text: string): Promise<ApiResponse> {
*/ */
export async function postInputData(data: PostInputRequest): Promise<ApiResponse> { export async function postInputData(data: PostInputRequest): Promise<ApiResponse> {
// 为 postInputData 设置 30 秒超时时间 // 为 postInputData 设置 30 秒超时时间
return post<ApiResponse>(API_ENDPOINTS.INPUT, data, { timeout: 30000 }); return post<ApiResponse>(API_ENDPOINTS.INPUT, data, { timeout: 120000 });
} }

View File

@@ -15,7 +15,7 @@ export async function postInputImage(file: File): Promise<ApiResponse> {
throw new Error('只能上传图片文件'); throw new Error('只能上传图片文件');
} }
return upload<ApiResponse>(API_ENDPOINTS.INPUT_IMAGE, file, 'image', { timeout: 30000 }); return upload<ApiResponse>(API_ENDPOINTS.INPUT_IMAGE, file, 'image', { timeout: 120000 });
} }
/** /**
@@ -75,7 +75,7 @@ export async function postInputImageWithProgress(
// 发送请求 // 发送请求
xhr.open('POST', `http://127.0.0.1:8099${API_ENDPOINTS.INPUT_IMAGE}`); xhr.open('POST', `http://127.0.0.1:8099${API_ENDPOINTS.INPUT_IMAGE}`);
xhr.timeout = 30000; // 30秒超时 xhr.timeout = 120000; // 30秒超时
xhr.send(formData); xhr.send(formData);
}); });
} }

View File

@@ -0,0 +1,40 @@
/* 右侧输入抽屉样式(参考示例配色) */
.input-drawer .ant-drawer-body {
background: #e9b6b6; /* 淡粉色背景,贴近参考图 */
}
.input-drawer-inner {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center; /* 居中内部内容 */
}
.input-drawer-title {
font-weight: 700;
color: #3b3b3b;
letter-spacing: 0.5px;
text-align: center;
}
.input-drawer-box {
background: rgba(255,255,255,0.75);
border-radius: 12px;
padding: 16px 18px; /* 增大内边距 */
box-shadow: 0 1px 6px rgba(0,0,0,0.08);
width: 100%;
max-width: 680px; /* 桌面居中显示更宽 */
margin: 0 auto; /* 水平居中 */
box-sizing: border-box;
}
/* 抽屉底部按钮区域与页面底栏保持间距(如有) */
.input-drawer .ant-drawer-footer { border-top: none; }
/* 抽屉与遮罩不再额外向下偏移,依赖 getContainer 挂载到标题栏下方的容器 */
@media (max-width: 768px) {
.input-drawer-box {
max-width: 100%;
padding: 14px; /* 移动端更紧凑 */
}
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { Drawer, Grid } from 'antd';
import InputPanel from './InputPanel.tsx';
import HintText from './HintText.tsx';
import './InputDrawer.css';
type Props = {
open: boolean;
onClose: () => void;
onResult?: (data: any) => void;
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
};
const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
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);
}, []);
// 在输入处理成功onResult 被调用)后,自动关闭抽屉
const handleResult = React.useCallback(
(data: any) => {
onResult?.(data);
onClose();
},
[onResult, onClose]
);
return (
<Drawer
className="input-drawer"
placement="right"
width={isMobile ? '100%' : '33%'}
open={open}
onClose={onClose}
mask={false}
getContainer={containerEl ? () => containerEl : undefined}
closable={false}
zIndex={1500}
rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
styles={{
header: { display: 'none' },
body: {
padding: 16,
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
mask: { top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` },
}}
>
<div className="input-drawer-inner">
<div className="input-drawer-title">AI FIND U</div>
<div className="input-drawer-box">
<InputPanel onResult={handleResult} />
<HintText />
</div>
</div>
</Drawer>
);
};
export default InputDrawer;

View File

@@ -2,13 +2,14 @@
.input-panel { .input-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 12px; /* 增大间距 */
} }
.input-panel .ant-input-outlined, .input-panel .ant-input-outlined,
.input-panel .ant-input { .input-panel .ant-input {
background: rgba(255,255,255,0.04); background: rgba(255,255,255,0.04);
color: #e5e7eb; color: #e5e7eb;
min-height: 180px; /* 提升基础高度,配合 autoSize 更宽裕 */
} }
.input-actions { .input-actions {

View File

@@ -88,8 +88,9 @@ 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="请输入个人信息描述,或上传图片…"
autoSize={{ minRows: 3, maxRows: 6 }} autoSize={{ minRows: 6, maxRows: 12 }}
style={{ fontSize: 14 }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
disabled={loading} disabled={loading}
/> />

View File

@@ -4,12 +4,17 @@ import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import SiderMenu from './SiderMenu.tsx'; import SiderMenu from './SiderMenu.tsx';
import MainContent from './MainContent.tsx'; import MainContent from './MainContent.tsx';
import ResourceList from './ResourceList.tsx'; import ResourceList from './ResourceList.tsx';
import TopBar from './TopBar.tsx';
import '../styles/base.css'; import '../styles/base.css';
import '../styles/layout.css'; import '../styles/layout.css';
const LayoutWrapper: React.FC = () => { const LayoutWrapper: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
const [inputOpen, setInputOpen] = React.useState(false);
const isHome = location.pathname === '/';
const layoutShellRef = React.useRef<HTMLDivElement>(null);
const pathToKey = (path: string) => { const pathToKey = (path: string) => {
switch (path) { switch (path) {
@@ -39,17 +44,42 @@ const LayoutWrapper: React.FC = () => {
navigate('/'); navigate('/');
break; break;
} }
// 切换页面时收起输入抽屉
setInputOpen(false);
}; };
return ( return (
<Layout className="layout-wrapper app-root"> <Layout className="layout-wrapper app-root">
<SiderMenu onNavigate={handleNavigate} selectedKey={selectedKey} /> {/* 顶部标题栏,位于左侧菜单栏之上 */}
<Layout> <TopBar
<Routes> onToggleMenu={() => setMobileMenuOpen((v) => !v)}
<Route path="/" element={<MainContent />} /> onToggleInput={() => isHome && setInputOpen((v) => !v)}
<Route path="/resources" element={<ResourceList />} /> isHome={isHome}
<Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>2</div>} /> />
</Routes> {/* 下方为主布局:左侧菜单 + 右侧内容 */}
<Layout ref={layoutShellRef as any}>
<SiderMenu
onNavigate={handleNavigate}
selectedKey={selectedKey}
mobileOpen={mobileMenuOpen}
onMobileToggle={(open) => setMobileMenuOpen(open)}
/>
<Layout>
<Routes>
<Route
path="/"
element={
<MainContent
inputOpen={inputOpen}
onCloseInput={() => setInputOpen(false)}
containerEl={layoutShellRef.current}
/>
}
/>
<Route path="/resources" element={<ResourceList />} />
<Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>2</div>} />
</Routes>
</Layout>
</Layout> </Layout>
</Layout> </Layout>
); );

View File

@@ -1,13 +1,13 @@
import React from 'react'; import React from 'react';
import { Layout, Typography } from 'antd'; import { Layout, Typography } from 'antd';
import PeopleForm from './PeopleForm.tsx'; import PeopleForm from './PeopleForm.tsx';
import InputPanel from './InputPanel.tsx'; import InputDrawer from './InputDrawer.tsx';
import HintText from './HintText.tsx';
import './MainContent.css'; import './MainContent.css';
const { Content } = Layout; const { Content } = Layout;
const MainContent: React.FC = () => { type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null };
const MainContent: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
const [formData, setFormData] = React.useState<any>(null); const [formData, setFormData] = React.useState<any>(null);
const handleInputResult = (data: any) => { const handleInputResult = (data: any) => {
@@ -27,10 +27,13 @@ const MainContent: React.FC = () => {
<PeopleForm initialData={formData} /> <PeopleForm initialData={formData} />
</div> </div>
<div className="input-panel-wrapper"> {/* 首页右侧输入抽屉,仅在顶栏点击后弹出;挂载到标题栏下方容器 */}
<InputPanel onResult={handleInputResult} /> <InputDrawer
<HintText /> open={inputOpen}
</div> onClose={onCloseInput || (() => {})}
onResult={handleInputResult}
containerEl={containerEl}
/>
</Content> </Content>
); );
}; };

View File

@@ -6,14 +6,19 @@ import './SiderMenu.css';
const { Sider } = Layout; const { Sider } = Layout;
// 新增:支持外部导航回调 + 受控选中态 // 新增:支持外部导航回调 + 受控选中态
type Props = { onNavigate?: (key: string) => void; selectedKey?: string }; type Props = {
onNavigate?: (key: string) => void;
selectedKey?: string;
mobileOpen?: boolean; // 外部控制移动端抽屉开关
onMobileToggle?: (open: boolean) => void; // 顶栏触发开关
};
const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey }) => { const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMobileToggle }) => {
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.md; const isMobile = !screens.md;
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 [mobileOpen, setMobileOpen] = React.useState(false); const [internalMobileOpen, setInternalMobileOpen] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setCollapsed(isMobile); setCollapsed(isMobile);
@@ -34,20 +39,25 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey }) => {
// 移动端:使用 Drawer 覆盖主内容 // 移动端:使用 Drawer 覆盖主内容
if (isMobile) { if (isMobile) {
const open = mobileOpen ?? internalMobileOpen;
const setOpen = (v: boolean) => (onMobileToggle ? onMobileToggle(v) : setInternalMobileOpen(v));
const showInternalTrigger = !onMobileToggle; // 若无外部控制,则显示内部按钮
return ( return (
<> <>
<Button {showInternalTrigger && (
className="mobile-menu-trigger" <Button
type="default" className="mobile-menu-trigger"
icon={<MenuOutlined />} type="default"
onClick={() => setMobileOpen((open) => !open)} icon={<MenuOutlined />}
/> onClick={() => setInternalMobileOpen((o) => !o)}
/>
)}
<Drawer <Drawer
className="mobile-menu-drawer" className="mobile-menu-drawer"
placement="left" placement="left"
width="100%" width="100%"
open={mobileOpen} open={open}
onClose={() => setMobileOpen(false)} onClose={() => setOpen(false)}
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
> >
<div className="sider-header"> <div className="sider-header">
@@ -64,7 +74,7 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey }) => {
onClick={({ key }) => { onClick={({ key }) => {
const k = String(key); const k = String(key);
setSelectedKeys([k]); setSelectedKeys([k]);
setMobileOpen(false); // 选择后自动收起 setOpen(false); // 选择后自动收起
onNavigate?.(k); onNavigate?.(k);
}} }}
items={items} items={items}

46
src/components/TopBar.css Normal file
View File

@@ -0,0 +1,46 @@
/* 顶部网站标题栏样式 */
.topbar {
position: sticky;
top: 0;
z-index: 2000; /* 保证位于抽屉与遮罩之上 */
height: 56px;
display: grid;
grid-template-columns: 56px 1fr 56px;
align-items: center;
background: linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.35) 100%);
border-bottom: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(4px);
}
.topbar-title {
text-align: center;
font-size: 18px;
font-weight: 600;
font-style: italic;
letter-spacing: 1px;
color: #e5e7eb;
}
.topbar-left,
.topbar-right {
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.04);
color: #93c5fd; /* 主题蓝 */
}
.icon-btn:hover { background: rgba(255,255,255,0.08); }
@media (min-width: 768px) {
.topbar { grid-template-columns: 56px 1fr 56px; }
}

41
src/components/TopBar.tsx Normal file
View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Grid } from 'antd';
import { MenuOutlined, RobotOutlined } from '@ant-design/icons';
import './TopBar.css';
type Props = {
onToggleMenu?: () => void;
onToggleInput?: () => void;
isHome?: boolean;
};
const TopBar: React.FC<Props> = ({ onToggleMenu, onToggleInput, isHome }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
return (
<div className="topbar">
<div className="topbar-left">
{isMobile && (
<button className="icon-btn" onClick={onToggleMenu} aria-label="打开/收起菜单">
<MenuOutlined />
</button>
)}
</div>
<div className="topbar-title" role="heading" aria-level={1}>
I FIND U
</div>
<div className="topbar-right">
{isHome && (
<button className="icon-btn" onClick={onToggleInput} aria-label="打开/收起输入">
<RobotOutlined />
</button>
)}
</div>
</div>
);
};
export default TopBar;