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:
2026-06-05 16:24:17 +08:00
parent 6204c14b88
commit 1da95eaef3
31 changed files with 2639 additions and 470 deletions

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