|
@@ -1,8 +1,4 @@
|
|
|
-import {
|
|
|
|
|
- CloseOutlined,
|
|
|
|
|
- MenuFoldOutlined,
|
|
|
|
|
- MenuUnfoldOutlined,
|
|
|
|
|
-} from "@ant-design/icons";
|
|
|
|
|
|
|
+import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
|
|
import { Button, Splitter } from "antd";
|
|
import { Button, Splitter } from "antd";
|
|
|
import { useCallback, useState, type ReactNode } from "react";
|
|
import { useCallback, useState, type ReactNode } from "react";
|
|
|
import RightToolbar, { type RightToolbarTab } from "./RightToolbar";
|
|
import RightToolbar, { type RightToolbarTab } from "./RightToolbar";
|
|
@@ -16,12 +12,19 @@ import {
|
|
|
// 常量:手工调整左侧栏宽度
|
|
// 常量:手工调整左侧栏宽度
|
|
|
// ─────────────────────────────────────────────
|
|
// ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
-/** 左侧面板固定宽度(px)。左侧栏不参与拖拽,修改此值即可调整宽度。 */
|
|
|
|
|
-const SIDEBAR_WIDTH = 260;
|
|
|
|
|
|
|
+/** 左侧面板固定宽度(px)。修改此值即可调整宽度,左侧栏不参与拖拽。 */
|
|
|
|
|
+const SIDEBAR_WIDTH = 280;
|
|
|
|
|
|
|
|
/** 右边工具栏固定宽度(px) */
|
|
/** 右边工具栏固定宽度(px) */
|
|
|
const TOOLBAR_WIDTH = 40;
|
|
const TOOLBAR_WIDTH = 40;
|
|
|
|
|
|
|
|
|
|
+// ─────────────────────────────────────────────
|
|
|
|
|
+// localStorage keys
|
|
|
|
|
+// ─────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+const COLLAPSED_KEY = "split-layout:sidebar-collapsed";
|
|
|
|
|
+const RIGHT_WIDTH_KEY = "split-layout:right-panel-width";
|
|
|
|
|
+
|
|
|
// ─────────────────────────────────────────────
|
|
// ─────────────────────────────────────────────
|
|
|
// Props
|
|
// Props
|
|
|
// ─────────────────────────────────────────────
|
|
// ─────────────────────────────────────────────
|
|
@@ -29,23 +32,20 @@ const TOOLBAR_WIDTH = 40;
|
|
|
export interface SplitLayoutProps {
|
|
export interface SplitLayoutProps {
|
|
|
/** 左侧面板标题,支持任意 ReactNode */
|
|
/** 左侧面板标题,支持任意 ReactNode */
|
|
|
sidebarTitle: ReactNode;
|
|
sidebarTitle: ReactNode;
|
|
|
- /** 左侧面板内容 */
|
|
|
|
|
|
|
+ /** 左侧面板内容(收起时隐藏不销毁,避免重复 fetch) */
|
|
|
sidebar: ReactNode;
|
|
sidebar: ReactNode;
|
|
|
/**
|
|
/**
|
|
|
* 中间内容区。支持两种用法:
|
|
* 中间内容区。支持两种用法:
|
|
|
*
|
|
*
|
|
|
- * 方案 A — Render Props,框架把 expandButton 作为参数传入:
|
|
|
|
|
|
|
+ * 方案 A — Render Props:
|
|
|
* ```tsx
|
|
* ```tsx
|
|
|
* <SplitLayout ...>
|
|
* <SplitLayout ...>
|
|
|
* {({ expandButton }) => <MyPage headerExtra={expandButton} />}
|
|
* {({ expandButton }) => <MyPage headerExtra={expandButton} />}
|
|
|
* </SplitLayout>
|
|
* </SplitLayout>
|
|
|
* ```
|
|
* ```
|
|
|
- *
|
|
|
|
|
- * 方案 B — 普通 ReactNode,内部自己调用 useSplitLayout():
|
|
|
|
|
|
|
+ * 方案 B — 普通 ReactNode,内部调用 useSplitLayout():
|
|
|
* ```tsx
|
|
* ```tsx
|
|
|
- * <SplitLayout ...>
|
|
|
|
|
- * <ComplexPage />
|
|
|
|
|
- * </SplitLayout>
|
|
|
|
|
|
|
+ * <SplitLayout ...><ComplexPage /></SplitLayout>
|
|
|
* ```
|
|
* ```
|
|
|
*/
|
|
*/
|
|
|
children:
|
|
children:
|
|
@@ -53,22 +53,13 @@ export interface SplitLayoutProps {
|
|
|
| ((ctx: Pick<SplitLayoutContextValue, "expandButton">) => ReactNode);
|
|
| ((ctx: Pick<SplitLayoutContextValue, "expandButton">) => ReactNode);
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 右边栏 tab 配置。不传则不渲染右边栏。
|
|
|
|
|
- * ```tsx
|
|
|
|
|
- * rightTabs={[
|
|
|
|
|
- * { key: "chat", icon: <CommentOutlined />, label: "对话" },
|
|
|
|
|
- * { key: "search", icon: <SearchOutlined />, label: "搜索" },
|
|
|
|
|
- * ]}
|
|
|
|
|
- * ```
|
|
|
|
|
|
|
+ * 右边栏 tab 配置。每个 tab 可携带 content 面板内容。
|
|
|
|
|
+ * - content 懒创建:首次点击后才挂载 DOM
|
|
|
|
|
+ * - 切换/关闭面板时只隐藏,不销毁
|
|
|
|
|
+ * - 不传 rightTabs 则不渲染右边栏
|
|
|
*/
|
|
*/
|
|
|
rightTabs?: RightToolbarTab[];
|
|
rightTabs?: RightToolbarTab[];
|
|
|
- /**
|
|
|
|
|
- * 右边栏面板内容映射,key 对应 rightTabs 中的 key。
|
|
|
|
|
- * ```tsx
|
|
|
|
|
- * rightPanels={{ chat: <ChatPanel />, search: <SearchPanel /> }}
|
|
|
|
|
- * ```
|
|
|
|
|
- */
|
|
|
|
|
- rightPanels?: Record<string, ReactNode>;
|
|
|
|
|
|
|
+
|
|
|
/** 右边栏面板默认宽度(px),默认 500 */
|
|
/** 右边栏面板默认宽度(px),默认 500 */
|
|
|
defaultRightSize?: number;
|
|
defaultRightSize?: number;
|
|
|
/** 右边栏面板最小宽度(px),默认 280 */
|
|
/** 右边栏面板最小宽度(px),默认 280 */
|
|
@@ -86,13 +77,11 @@ export default function SplitLayout({
|
|
|
sidebar,
|
|
sidebar,
|
|
|
children,
|
|
children,
|
|
|
rightTabs,
|
|
rightTabs,
|
|
|
- rightPanels,
|
|
|
|
|
- defaultRightSize = 400,
|
|
|
|
|
|
|
+ defaultRightSize = 500,
|
|
|
minRightSize = 280,
|
|
minRightSize = 280,
|
|
|
maxRightSize = 800,
|
|
maxRightSize = 800,
|
|
|
}: SplitLayoutProps) {
|
|
}: SplitLayoutProps) {
|
|
|
- // ── 左侧收起状态(持久化到 localStorage)──
|
|
|
|
|
- const COLLAPSED_KEY = "split-layout:sidebar-collapsed";
|
|
|
|
|
|
|
+ // ── 左侧收起状态(持久化)──
|
|
|
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
|
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
|
|
try {
|
|
try {
|
|
|
return localStorage.getItem(COLLAPSED_KEY) === "true";
|
|
return localStorage.getItem(COLLAPSED_KEY) === "true";
|
|
@@ -100,13 +89,14 @@ export default function SplitLayout({
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
const toggle = useCallback(() => {
|
|
const toggle = useCallback(() => {
|
|
|
setCollapsed((v) => {
|
|
setCollapsed((v) => {
|
|
|
const next = !v;
|
|
const next = !v;
|
|
|
try {
|
|
try {
|
|
|
localStorage.setItem(COLLAPSED_KEY, String(next));
|
|
localStorage.setItem(COLLAPSED_KEY, String(next));
|
|
|
} catch {
|
|
} catch {
|
|
|
- // 静默降级
|
|
|
|
|
|
|
+ /* 静默降级 */
|
|
|
}
|
|
}
|
|
|
return next;
|
|
return next;
|
|
|
});
|
|
});
|
|
@@ -147,11 +137,10 @@ export default function SplitLayout({
|
|
|
const centerContent =
|
|
const centerContent =
|
|
|
typeof children === "function" ? children({ expandButton }) : children;
|
|
typeof children === "function" ? children({ expandButton }) : children;
|
|
|
|
|
|
|
|
- // 右边栏面板宽度:从 localStorage 读取初始值,拖拽后写回
|
|
|
|
|
- const STORAGE_KEY = "split-layout:right-panel-width";
|
|
|
|
|
|
|
+ // ── 右边栏面板宽度(持久化)──
|
|
|
const [rightPanelWidth, setRightPanelWidth] = useState<number>(() => {
|
|
const [rightPanelWidth, setRightPanelWidth] = useState<number>(() => {
|
|
|
try {
|
|
try {
|
|
|
- const stored = localStorage.getItem(STORAGE_KEY);
|
|
|
|
|
|
|
+ const stored = localStorage.getItem(RIGHT_WIDTH_KEY);
|
|
|
if (stored) {
|
|
if (stored) {
|
|
|
const parsed = Number(stored);
|
|
const parsed = Number(stored);
|
|
|
if (
|
|
if (
|
|
@@ -163,12 +152,11 @@ export default function SplitLayout({
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
- // localStorage 不可用(隐私模式等),静默降级
|
|
|
|
|
|
|
+ /* 静默降级 */
|
|
|
}
|
|
}
|
|
|
return defaultRightSize;
|
|
return defaultRightSize;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Splitter sizes 受控:关闭时固定工具栏宽,展开时恢复持久化宽度
|
|
|
|
|
const rightSize = rightOpen ? rightPanelWidth + TOOLBAR_WIDTH : TOOLBAR_WIDTH;
|
|
const rightSize = rightOpen ? rightPanelWidth + TOOLBAR_WIDTH : TOOLBAR_WIDTH;
|
|
|
|
|
|
|
|
const handleSplitterResize = useCallback(
|
|
const handleSplitterResize = useCallback(
|
|
@@ -178,9 +166,9 @@ export default function SplitLayout({
|
|
|
if (contentWidth > 0) {
|
|
if (contentWidth > 0) {
|
|
|
setRightPanelWidth(contentWidth);
|
|
setRightPanelWidth(contentWidth);
|
|
|
try {
|
|
try {
|
|
|
- localStorage.setItem(STORAGE_KEY, String(contentWidth));
|
|
|
|
|
|
|
+ localStorage.setItem(RIGHT_WIDTH_KEY, String(contentWidth));
|
|
|
} catch {
|
|
} catch {
|
|
|
- // 静默降级
|
|
|
|
|
|
|
+ /* 静默降级 */
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -190,34 +178,32 @@ export default function SplitLayout({
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<SplitLayoutContext.Provider value={ctx}>
|
|
<SplitLayoutContext.Provider value={ctx}>
|
|
|
- {/*
|
|
|
|
|
- * 整体布局:flex row
|
|
|
|
|
- * 左侧栏(固定宽度,不参与 Splitter)
|
|
|
|
|
- * + 单个 Splitter lazy(中间内容 ↔ 右边栏)
|
|
|
|
|
- *
|
|
|
|
|
- * 左侧栏固定宽度由 SIDEBAR_WIDTH 常量控制,无拖拽,
|
|
|
|
|
- * 完全隔离于右侧 Splitter,彻底避免嵌套 Splitter 的坐标偏移 bug。
|
|
|
|
|
- */}
|
|
|
|
|
<div className={styles.root}>
|
|
<div className={styles.root}>
|
|
|
- {/* ── 左侧面板(固定宽,CSS 控制,collapsed 时完全隐藏)── */}
|
|
|
|
|
- {!collapsed && (
|
|
|
|
|
- <div className={styles.sidebar} style={{ width: SIDEBAR_WIDTH }}>
|
|
|
|
|
- <div className={styles.sidebarHeader}>
|
|
|
|
|
- <span className={styles.sidebarTitle}>{sidebarTitle}</span>
|
|
|
|
|
- <Button
|
|
|
|
|
- type="text"
|
|
|
|
|
- size="small"
|
|
|
|
|
- icon={<MenuFoldOutlined />}
|
|
|
|
|
- onClick={toggle}
|
|
|
|
|
- className={styles.collapseBtn}
|
|
|
|
|
- title="收起侧边栏"
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- <div className={styles.sidebarContent}>{sidebar}</div>
|
|
|
|
|
|
|
+ {/* ── 左侧面板:隐藏不销毁,避免 sidebar 内的 fetch 重复触发 ── */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={styles.sidebar}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ width: collapsed ? 0 : SIDEBAR_WIDTH,
|
|
|
|
|
+ // 收起时用 visibility+overflow 隐藏,不从 DOM 移除
|
|
|
|
|
+ overflow: "hidden",
|
|
|
|
|
+ visibility: collapsed ? "hidden" : "visible",
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className={styles.sidebarHeader}>
|
|
|
|
|
+ <span className={styles.sidebarTitle}>{sidebarTitle}</span>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ icon={<MenuFoldOutlined />}
|
|
|
|
|
+ onClick={toggle}
|
|
|
|
|
+ className={styles.collapseBtn}
|
|
|
|
|
+ title="收起侧边栏"
|
|
|
|
|
+ />
|
|
|
</div>
|
|
</div>
|
|
|
- )}
|
|
|
|
|
|
|
+ <div className={styles.sidebarContent}>{sidebar}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- {/* ── 右侧主区域:单个 Splitter lazy(中间 ↔ 右边栏)── */}
|
|
|
|
|
|
|
+ {/* ── 右侧主区域 ── */}
|
|
|
<div className={styles.mainArea}>
|
|
<div className={styles.mainArea}>
|
|
|
{hasRight ? (
|
|
{hasRight ? (
|
|
|
<Splitter
|
|
<Splitter
|
|
@@ -230,7 +216,7 @@ export default function SplitLayout({
|
|
|
{centerContent}
|
|
{centerContent}
|
|
|
</Splitter.Panel>
|
|
</Splitter.Panel>
|
|
|
|
|
|
|
|
- {/* 右边栏 */}
|
|
|
|
|
|
|
+ {/* 右边栏:RightToolbar 内部管理面板的懒创建与隐藏 */}
|
|
|
<Splitter.Panel
|
|
<Splitter.Panel
|
|
|
size={rightSize}
|
|
size={rightSize}
|
|
|
min={minRightSize + TOOLBAR_WIDTH}
|
|
min={minRightSize + TOOLBAR_WIDTH}
|
|
@@ -238,43 +224,15 @@ export default function SplitLayout({
|
|
|
resizable={rightOpen}
|
|
resizable={rightOpen}
|
|
|
className={styles.rightAreaPanel}
|
|
className={styles.rightAreaPanel}
|
|
|
>
|
|
>
|
|
|
- <div className={styles.rightArea}>
|
|
|
|
|
- {/* 面板内容区(展开时显示) */}
|
|
|
|
|
- {rightOpen && (
|
|
|
|
|
- <div className={styles.rightPanelContent}>
|
|
|
|
|
- <div className={styles.rightPanelHeader}>
|
|
|
|
|
- <span className={styles.rightPanelTitle}>
|
|
|
|
|
- {
|
|
|
|
|
- rightTabs!.find((t) => t.key === rightActiveKey)
|
|
|
|
|
- ?.label
|
|
|
|
|
- }
|
|
|
|
|
- </span>
|
|
|
|
|
- <Button
|
|
|
|
|
- type="text"
|
|
|
|
|
- size="small"
|
|
|
|
|
- icon={<CloseOutlined />}
|
|
|
|
|
- onClick={closeRightPanel}
|
|
|
|
|
- className={styles.collapseBtn}
|
|
|
|
|
- title="关闭面板"
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- <div className={styles.rightPanelBody}>
|
|
|
|
|
- {rightPanels?.[rightActiveKey!]}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {/* 工具栏:始终可见,固定在最右侧 */}
|
|
|
|
|
- <RightToolbar
|
|
|
|
|
- tabs={rightTabs!}
|
|
|
|
|
- activeKey={rightActiveKey}
|
|
|
|
|
- onTabClick={onRightTabClick}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <RightToolbar
|
|
|
|
|
+ tabs={rightTabs!}
|
|
|
|
|
+ activeKey={rightActiveKey}
|
|
|
|
|
+ onTabClick={onRightTabClick}
|
|
|
|
|
+ onClose={closeRightPanel}
|
|
|
|
|
+ />
|
|
|
</Splitter.Panel>
|
|
</Splitter.Panel>
|
|
|
</Splitter>
|
|
</Splitter>
|
|
|
) : (
|
|
) : (
|
|
|
- // 没有右边栏时,中间内容直接撑满
|
|
|
|
|
<div className={styles.centerPanel}>{centerContent}</div>
|
|
<div className={styles.centerPanel}>{centerContent}</div>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|