feat: 实现业务首页(三栏布局 + 轮播图)
## 首页整体架构
- 新增 src/pages/home/ — 完整首页模块(10 个组件)
- HomePage / HomeContent:三栏弹性布局,拖动分隔条调整列宽
- BannerCarousel:顶部轮播图(antd Carousel,2280×90 长条图片适配)
- LeftPanel:模型选择器(Menu 分组) + 任务记录(Table 分页),纵向拖拽调整比例
- CenterPanel / ModelInputForm:动态表单,根据模型 param_schema/ui_config 渲染控件
- RightPanel / OutputPreview:任务输出预览(Image/Video)
- 新增 Layout 组件:h-screen flex 布局,首页 Banner + NavBar + Outlet
## 数据层(数据与视图分离)
- 新增 src/services/modules/ — API 模块(banner/models/task/auth)
- TaskAPI 静态方法,TaskInfoItemResponseBody 判别联合类型
- ModelAPI / BannerAPI,const 断言路由常量
- 新增 src/hooks/use-async.ts — 通用异步 Hook
- useAsyncData:竞态处理、条件请求(enabled)、自动错误日志
- useAsyncMutation:数据变更,onSuccess/onError
- getErrorMessage():RequestErrorType → 可读错误信息
- 新增 src/hooks/use-api.ts — 业务 Hook
- useBannerList / useModelList / useTaskList / useTaskDetail / useSubmitTask
## 主题与交互
- 重写暗色令牌:科技感蓝紫(#080C24 底 / #0F1340 面板 / #2A3278 边框)
- 三栏分隔线:token.colorBorderSecondary 常驻 + 手柄竖线 hover 高亮
- 左面板纵向拖拽:模型区/任务区可调比例,ResizeObserver 自适应
- 暗色/亮色同步:tokens.ts + antd-theme.ts + globals.css 三处一致
## 基础设施
- EventBus 新增 TASK_SUBMITTED 事件(替代 Context 数字递增 hack)
- HomeContext 状态管理(选中模型、任务分页、折叠状态)
- 所有组件边框使用 token.colorBorderSecondary(主题自适应)
- Table 滚动高度 ResizeObserver 动态计算(修复分页器遮挡)
This commit is contained in:
261
src/pages/home/HomeContent.tsx
Normal file
261
src/pages/home/HomeContent.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
// ============================================================
|
||||
// HomeContent — 首页三列正文布局 + HomeProvider
|
||||
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { theme as antTheme, Empty } from 'antd';
|
||||
|
||||
import { HomeContext } from '@/contexts/home-context';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
|
||||
import { LeftPanel } from './LeftPanel';
|
||||
import { CenterPanel } from './CenterPanel';
|
||||
import { RightPanel } from './RightPanel';
|
||||
|
||||
import { Typography } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ---------- 常量 ----------
|
||||
|
||||
/** 左列最小/默认宽度 */
|
||||
const LEFT_MIN = 400;
|
||||
const LEFT_DEFAULT = 750;
|
||||
/** 右列最小/默认宽度 */
|
||||
const RIGHT_MIN = 300;
|
||||
const RIGHT_DEFAULT = 480;
|
||||
/** 拖拽分隔条宽度 */
|
||||
const DIVIDER_WIDTH = 6;
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function HomeContent() {
|
||||
const { token } = antTheme.useToken();
|
||||
const { isLoggedIn } = useAppContext();
|
||||
|
||||
// ======== HomeContext 状态 ========
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState<ModelsListResponseBody | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<TaskInfoItemResponseBody | null>(null);
|
||||
const [taskPage, setTaskPage] = useState(1);
|
||||
const [taskPageSize, setTaskPageSize] = useState(10);
|
||||
const [isLeftCollapsed, setIsLeftCollapsed] = useState(false);
|
||||
|
||||
const toggleLeftCollapsed = useCallback(() => {
|
||||
setIsLeftCollapsed((v) => !v);
|
||||
}, []);
|
||||
|
||||
const handleSetSelectedModel = useCallback(
|
||||
(model: ModelsListResponseBody | null) => {
|
||||
setSelectedModel(model);
|
||||
setSelectedTask(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetSelectedTask = useCallback(
|
||||
(task: TaskInfoItemResponseBody | null) => {
|
||||
setSelectedTask(task);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue = {
|
||||
selectedModel,
|
||||
setSelectedModel: handleSetSelectedModel,
|
||||
taskPage,
|
||||
taskPageSize,
|
||||
setTaskPage,
|
||||
setTaskPageSize,
|
||||
selectedTask,
|
||||
setSelectedTask: handleSetSelectedTask,
|
||||
isLeftCollapsed,
|
||||
toggleLeftCollapsed,
|
||||
};
|
||||
|
||||
// ======== 拖拽调整列宽 ========
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT);
|
||||
const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 拖拽状态
|
||||
const [dragging, setDragging] = useState<'left' | 'right' | null>(null);
|
||||
// 分隔条 hover 状态
|
||||
const [hoveredDivider, setHoveredDivider] = useState<'left' | 'right' | null>(null);
|
||||
const dragState = useRef<{
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
}>({ startX: 0, startWidth: 0 });
|
||||
|
||||
const handleDividerMouseDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
dragState.current = { startX: e.clientX, startWidth: side === 'left' ? leftWidth : rightWidth };
|
||||
setDragging(side);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
|
||||
const ds = dragState.current;
|
||||
const delta = e.clientX - ds.startX;
|
||||
|
||||
if (dragging === 'left') {
|
||||
const newWidth = Math.max(LEFT_MIN, ds.startWidth + delta);
|
||||
setLeftWidth(newWidth);
|
||||
} else {
|
||||
const newWidth = Math.max(RIGHT_MIN, ds.startWidth - delta);
|
||||
setRightWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragging(null);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
// ======== 分隔条样式工厂 ========
|
||||
|
||||
const dividerStyle = (side: 'left' | 'right'): React.CSSProperties => {
|
||||
const isActive = dragging === side;
|
||||
const isHovered = hoveredDivider === side;
|
||||
return {
|
||||
width: DIVIDER_WIDTH,
|
||||
flexShrink: 0,
|
||||
cursor: 'col-resize',
|
||||
background: isActive
|
||||
? token.colorPrimary
|
||||
: isHovered
|
||||
? `${token.colorPrimary}60`
|
||||
: token.colorBorderSecondary,
|
||||
position: 'relative' as const,
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s ease',
|
||||
};
|
||||
};
|
||||
|
||||
// ======== 未登录提示 ========
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<HomeContext.Provider value={contextValue}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
background: token.colorBgLayout,
|
||||
}}
|
||||
>
|
||||
<Empty
|
||||
description={
|
||||
<Text type="secondary">
|
||||
请先登录后再使用首页功能
|
||||
</Text>
|
||||
}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
</HomeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ======== 三列布局(可拖拽) ========
|
||||
|
||||
return (
|
||||
<HomeContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
padding: 12,
|
||||
gap: 0,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
background: token.colorBgLayout,
|
||||
cursor: !!dragging ? 'col-resize' : undefined,
|
||||
userSelect: !!dragging ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* 左列 */}
|
||||
{!isLeftCollapsed && (
|
||||
<>
|
||||
<div style={{ width: leftWidth, overflow: 'hidden', flexShrink: 0 }}>
|
||||
<LeftPanel />
|
||||
</div>
|
||||
|
||||
{/* 左分隔条 */}
|
||||
<div
|
||||
onMouseDown={handleDividerMouseDown('left')}
|
||||
style={dividerStyle('left')}
|
||||
onMouseEnter={() => setHoveredDivider('left')}
|
||||
onMouseLeave={() => setHoveredDivider(null)}
|
||||
>
|
||||
{/* 拖拽手柄竖线 */}
|
||||
<div style={{
|
||||
width: 2,
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
background: dragging === 'left'
|
||||
? '#fff'
|
||||
: hoveredDivider === 'left'
|
||||
? token.colorPrimary
|
||||
: token.colorBorder,
|
||||
transition: 'background 0.2s ease',
|
||||
}} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 中列 */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', minWidth: 300 }}>
|
||||
<CenterPanel />
|
||||
</div>
|
||||
|
||||
{/* 右分隔条 */}
|
||||
<div
|
||||
onMouseDown={handleDividerMouseDown('right')}
|
||||
style={{
|
||||
...dividerStyle('right'),
|
||||
marginLeft: 12,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredDivider('right')}
|
||||
onMouseLeave={() => setHoveredDivider(null)}
|
||||
>
|
||||
{/* 拖拽手柄竖线 */}
|
||||
<div style={{
|
||||
width: 2,
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
background: dragging === 'right'
|
||||
? '#fff'
|
||||
: hoveredDivider === 'right'
|
||||
? token.colorPrimary
|
||||
: token.colorBorder,
|
||||
transition: 'background 0.2s ease',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 右列 */}
|
||||
<div style={{ width: rightWidth, overflow: 'hidden', flexShrink: 0, marginLeft: 6 }}>
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
</HomeContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user