- CHANGELOG: 新增 0.0.16 版本条目(模型输入栏重构/统一页面调度/依赖联动) - TODO: 标记账户信息页为已完成,拆分未完成页面项 - ARCHITECTURE: 新增 PageDispatcher 叠加层说明 + OPEN_PAGE 事件 - 代码去重:runFieldChecks(validators) + createImageBeforeUpload(uploadValidation) - ESLint: DependentFormItem 中 Form.useWatch 移到组件顶层(修复 rules-of-hooks) - ESLint: message.success / async handleSubmit 加 void(修复 no-floating-promises) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
// ============================================================
|
||
// HomeContent — 首页三列正文布局 + HomeProvider(Context + Provider + Hook)
|
||
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
||
// ============================================================
|
||
|
||
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||
import { theme as antTheme, Empty } from 'antd';
|
||
|
||
import { useAppContext } from '@/components/AppProvider';
|
||
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
||
import { LeftPanel } from './components/LeftPanel';
|
||
import { CenterPanel } from './components/CenterPanel';
|
||
import { RightPanel } from './components/RightPanel';
|
||
|
||
import { Typography } from 'antd';
|
||
|
||
const { Text } = Typography;
|
||
|
||
// ---------- Context 类型 ----------
|
||
|
||
export interface HomeContextValue {
|
||
/** 当前选中的模型 */
|
||
selectedModel: ModelsListResponseBody | null;
|
||
setSelectedModel: (model: ModelsListResponseBody | null) => void;
|
||
/** 任务记录分页 — 当前页码 */
|
||
taskPage: number;
|
||
/** 任务记录分页 — 每页条数 */
|
||
taskPageSize: number;
|
||
setTaskPage: (page: number) => void;
|
||
setTaskPageSize: (size: number) => void;
|
||
/** 当前选中的任务记录(用于右侧预览) */
|
||
selectedTask: TaskInfoItemResponseBody | null;
|
||
setSelectedTask: (task: TaskInfoItemResponseBody | null) => void;
|
||
/** 侧边栏折叠状态 */
|
||
isLeftCollapsed: boolean;
|
||
toggleLeftCollapsed: () => void;
|
||
}
|
||
|
||
export const HomeContext = createContext<HomeContextValue | null>(null);
|
||
|
||
// ---------- Consumer Hook ----------
|
||
|
||
export function useHomeContext(): HomeContextValue {
|
||
const ctx = useContext(HomeContext);
|
||
if (!ctx) {
|
||
throw new Error('useHomeContext() 必须在 <HomeContent> 内部调用');
|
||
}
|
||
return ctx;
|
||
}
|
||
|
||
// ---------- 常量 ----------
|
||
|
||
/** 左列最小/默认宽度 */
|
||
const LEFT_MIN = 400;
|
||
const LEFT_DEFAULT = 750;
|
||
/** 右列最小/默认宽度 */
|
||
const RIGHT_MIN = 300;
|
||
const RIGHT_DEFAULT = 480;
|
||
/** 中列最小宽度 */
|
||
const CENTER_MIN = 300;
|
||
/** 拖拽分隔条宽度 */
|
||
const DIVIDER_WIDTH = 6;
|
||
/** 布局固定消耗:左右 padding(12*2) + 两个分隔条(6*2) */
|
||
const LAYOUT_OVERHEAD = 36; // 24 + 12
|
||
|
||
// ---------- 组件 ----------
|
||
|
||
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 = useMemo(() => ({
|
||
selectedModel,
|
||
setSelectedModel: handleSetSelectedModel,
|
||
taskPage,
|
||
taskPageSize,
|
||
setTaskPage,
|
||
setTaskPageSize,
|
||
selectedTask,
|
||
setSelectedTask: handleSetSelectedTask,
|
||
isLeftCollapsed,
|
||
toggleLeftCollapsed,
|
||
}), [
|
||
selectedModel,
|
||
handleSetSelectedModel,
|
||
taskPage,
|
||
taskPageSize,
|
||
selectedTask,
|
||
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 });
|
||
|
||
// 用 ref 追踪最新列宽,避免拖拽 mousemove 闭包中读到过期值
|
||
const leftWidthRef = useRef(leftWidth);
|
||
const rightWidthRef = useRef(rightWidth);
|
||
const draggingRef = useRef(dragging);
|
||
|
||
useEffect(() => {
|
||
leftWidthRef.current = leftWidth;
|
||
rightWidthRef.current = rightWidth;
|
||
draggingRef.current = dragging;
|
||
}, [leftWidth, rightWidth, dragging]);
|
||
|
||
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 containerEl = containerRef.current;
|
||
if (!containerEl) return;
|
||
const containerWidth = containerEl.clientWidth;
|
||
const ds = dragState.current;
|
||
const delta = e.clientX - ds.startX;
|
||
|
||
if (dragging === 'left') {
|
||
// 左列最大宽度 = 容器宽 - 固定消耗 - 中列最小宽 - 右列当前宽
|
||
// 确保中列至少保留 CENTER_MIN、右列不被挤出视口
|
||
const maxLeft = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN - rightWidthRef.current;
|
||
const newWidth = Math.max(LEFT_MIN, Math.min(maxLeft, ds.startWidth + delta));
|
||
setLeftWidth(newWidth);
|
||
} else {
|
||
// 右列最大宽度同理:不能把中列挤到 CENTER_MIN 以下
|
||
const maxRight = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN - leftWidthRef.current;
|
||
const newWidth = Math.max(RIGHT_MIN, Math.min(maxRight, 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]);
|
||
|
||
// ======== 视口缩小自动收窄面板 ========
|
||
|
||
useEffect(() => {
|
||
const el = containerRef.current;
|
||
if (!el) return;
|
||
|
||
const observer = new ResizeObserver(() => {
|
||
// 拖拽中由 mousemove handler 处理约束,避免冲突
|
||
if (draggingRef.current) return;
|
||
|
||
const containerWidth = el.clientWidth;
|
||
const currentLeft = leftWidthRef.current;
|
||
const currentRight = rightWidthRef.current;
|
||
|
||
// 检查当前总宽度是否超出容器
|
||
const totalNeeded = currentLeft + LAYOUT_OVERHEAD + CENTER_MIN + currentRight;
|
||
if (totalNeeded <= containerWidth) return;
|
||
|
||
// 超出容器 → 优先收缩右侧面板,再收左侧
|
||
const deficit = totalNeeded - containerWidth;
|
||
const rightSlack = currentRight - RIGHT_MIN;
|
||
|
||
if (rightSlack >= deficit) {
|
||
setRightWidth(currentRight - deficit);
|
||
} else {
|
||
setRightWidth(RIGHT_MIN);
|
||
const remainingDeficit = deficit - rightSlack;
|
||
setLeftWidth(Math.max(LEFT_MIN, currentLeft - remainingDeficit));
|
||
}
|
||
});
|
||
|
||
observer.observe(el);
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
// ======== 分隔条样式工厂 ========
|
||
|
||
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.15s linear',
|
||
};
|
||
};
|
||
|
||
// ======== 未登录提示 ========
|
||
|
||
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.15s linear',
|
||
}} />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 中列 */}
|
||
<div style={{ flex: 1, overflow: 'hidden', minWidth: CENTER_MIN }}>
|
||
<CenterPanel />
|
||
</div>
|
||
|
||
{/* 右分隔条 */}
|
||
<div
|
||
onMouseDown={handleDividerMouseDown('right')}
|
||
style={dividerStyle('right')}
|
||
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.15s linear',
|
||
}} />
|
||
</div>
|
||
|
||
{/* 右列 */}
|
||
<div style={{ width: rightWidth, overflow: 'hidden', flexShrink: 0 }}>
|
||
<RightPanel />
|
||
</div>
|
||
</div>
|
||
</HomeContext.Provider>
|
||
);
|
||
}
|