visuddhinanda 1 month ago
parent
commit
789daae842

+ 11 - 5
dashboard-v6/src/api/sentence.ts

@@ -1,7 +1,6 @@
 import type { IntlShape } from "react-intl";
 import type { ArticleMode, TContentType } from "./Article";
-import store from "../store";
-import { statusChange } from "../reducers/net-status";
+
 import { get, put } from "../request";
 import { message } from "antd";
 import { toISentence } from "../components/sentence/utils";
@@ -154,7 +153,8 @@ export const sentSave = async (
   ok?: (res: ISentence) => void,
   finish?: () => void
 ): Promise<ISentenceData | null> => {
-  store.dispatch(statusChange({ status: "loading" }));
+  //FIXME
+  //store.dispatch(statusChange({ status: "loading" }));
   const id = `${sent.book}_${sent.para}_${sent.wordStart}_${sent.wordEnd}_${sent.channel.id}`;
   const url = `/v2/sentence/${id}?mode=edit&html=true`;
   console.info("SentWbwEdit url", url);
@@ -177,22 +177,28 @@ export const sentSave = async (
         const newData: ISentence = toISentence(res.data);
         ok(newData);
       }
-
+      /** 
+       * FIXME 
       store.dispatch(
         statusChange({
           status: "success",
           message: intl.formatMessage({ id: "flashes.success" }),
         })
       );
+      */
       return res.data;
     } else {
       message.error(res.message);
-      store.dispatch(
+      /**
+       * FIXME
+             store.dispatch(
         statusChange({
           status: "fail",
           message: res.message,
         })
       );
+       */
+
       return null;
     }
   } catch (e) {

+ 3 - 2
dashboard-v6/src/components/anthology/TextBookToc.tsx

@@ -2,11 +2,12 @@ import { useEffect, useState } from "react";
 import AnthologyTocTree from "./AnthologyTocTree";
 import { get } from "../../request";
 import type { ICourseResponse } from "../../api/Course";
+import type { TTarget } from "../../types";
 
 interface IWidget {
   courseId?: string | null;
   channels?: string[];
-  onClick?: (article: string, target: string) => void;
+  onClick?: (article: string, target?: TTarget) => void;
 }
 const TextBookTocWidget = ({ courseId, channels, onClick }: IWidget) => {
   const [anthologyId, setAnthologyId] = useState<string>();
@@ -29,7 +30,7 @@ const TextBookTocWidget = ({ courseId, channels, onClick }: IWidget) => {
     <AnthologyTocTree
       anthologyId={anthologyId}
       channels={channels}
-      onClick={(_anthology: string, article: string, target: string) => {
+      onClick={(_anthology, article, target) => {
         console.debug("AnthologyTocTree onClick", article);
         if (typeof onClick !== "undefined") {
           onClick(article, target);

+ 2 - 1
dashboard-v6/src/components/article/TypeAnthology.tsx

@@ -2,6 +2,7 @@ import "./article.css";
 
 import type { ArticleMode } from "../../api/Article";
 import AnthologyDetail from "../anthology/AnthologyReader";
+import type { TTarget } from "../../types";
 
 interface IWidget {
   id?: string;
@@ -10,7 +11,7 @@ interface IWidget {
   onArticleChange?: (
     type: string,
     articleId: string,
-    target: string,
+    target?: TTarget,
     extra?: { anthologyId?: string }
   ) => void;
 }

+ 46 - 0
dashboard-v6/src/components/general/SplitLayout/RightToolbar.module.css

@@ -0,0 +1,46 @@
+/* ─────────────────────────────────────────────
+   RightToolbar.module.css
+   纵向图标工具栏,固定宽度,居中排列
+   ───────────────────────────────────────────── */
+
+.toolbar {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 2px;
+  padding: 8px 4px;
+  width: 40px;
+  flex-shrink: 0;
+  height: 100%;
+  border-left: 1px solid var(--ant-color-split, #f0f0f0);
+  background: var(--ant-color-bg-container, #fff);
+  box-sizing: border-box;
+}
+
+.tabBtn {
+  width: 28px !important;
+  height: 28px !important;
+  display: flex !important;
+  align-items: center;
+  justify-content: center;
+  border-radius: 6px !important;
+  color: var(--ant-color-text-secondary, rgba(0, 0, 0, 0.45)) !important;
+  font-size: 16px;
+  padding: 0 !important;
+  flex-shrink: 0;
+}
+
+.tabBtn:hover {
+  color: var(--ant-color-text, rgba(0, 0, 0, 0.88)) !important;
+  background: var(--ant-color-fill-secondary, rgba(0, 0, 0, 0.06)) !important;
+}
+
+.active {
+  color: var(--ant-color-primary, #1677ff) !important;
+  background: var(--ant-color-primary-bg, #e6f4ff) !important;
+}
+
+.active:hover {
+  color: var(--ant-color-primary, #1677ff) !important;
+  background: var(--ant-color-primary-bg, #e6f4ff) !important;
+}

+ 44 - 0
dashboard-v6/src/components/general/SplitLayout/RightToolbar.tsx

@@ -0,0 +1,44 @@
+import { Button, Tooltip } from "antd";
+import type { ReactNode } from "react";
+import styles from "./RightToolbar.module.css";
+
+export interface RightToolbarTab {
+  /** 唯一 key,对应面板内容 */
+  key: string;
+  /** 图标 */
+  icon: ReactNode;
+  /** Tooltip 提示文字 */
+  label: string;
+}
+
+interface RightToolbarProps {
+  tabs: RightToolbarTab[];
+  /** 当前激活的 tab key,null 表示面板已关闭 */
+  activeKey: string | null;
+  /** 点击图标时回调:已激活则关闭(传 null),未激活则打开 */
+  onTabClick: (key: string) => void;
+}
+
+export default function RightToolbar({
+  tabs,
+  activeKey,
+  onTabClick,
+}: RightToolbarProps) {
+  return (
+    <div className={styles.toolbar}>
+      {tabs.map((tab) => (
+        <Tooltip key={tab.key} title={tab.label} placement="left">
+          <Button
+            type="text"
+            size="small"
+            icon={tab.icon}
+            onClick={() => onTabClick(tab.key)}
+            className={`${styles.tabBtn} ${activeKey === tab.key ? styles.active : ""}`}
+            aria-label={tab.label}
+            aria-pressed={activeKey === tab.key}
+          />
+        </Tooltip>
+      ))}
+    </div>
+  );
+}

+ 90 - 25
dashboard-v6/src/components/general/SplitLayout/SplitLayout.module.css

@@ -2,37 +2,41 @@
    SplitLayout.module.css
    ───────────────────────────────────────────── */
 
-/* 整体 Splitter 容器:撑满父元素 */
-.splitter {
+/* 整体容器:flex row,撑满父元素 */
+.root {
+  display: flex;
+  flex-direction: row;
   width: 100%;
   height: 100%;
-}
-
-/* ── 左侧面板 ── */
-.leftPanel {
+  min-height: 0;   /* 防止 flex 子项撑破高度导致换行 */
   overflow: hidden;
-  /* 收起动画:宽度过渡由 antd Splitter 控制,
-     此处只做内容淡出保护 */
-  transition: opacity 0.2s ease;
 }
 
-/* 左侧面板内部竖向 flex 容器 */
-.sidebarInner {
+/* ── 左侧面板 ── */
+.sidebar {
+  flex-shrink: 0;
   display: flex;
   flex-direction: column;
-  height: 100%;
-  overflow: hidden;
+  border-right: 1px solid var(--ant-color-split, #f0f0f0);
+  transition: width 0.15s ease;
+  /* overflow visible:收起时按钮不被裁切 */
+  overflow: visible;
+  position: relative;
+  z-index: 1;
 }
 
-/* 标题行 */
+/* 标题行:始终渲染 */
 .sidebarHeader {
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  padding: 8px 8px 8px 12px;
+  justify-content: flex-end;   /* 按钮始终靠右对齐 */
+  gap: 4px;
+  padding: 6px 6px 6px 12px;
   flex-shrink: 0;
   border-bottom: 1px solid var(--ant-color-split, #f0f0f0);
   min-height: 40px;
+  box-sizing: border-box;
+  overflow: hidden;
 }
 
 .sidebarTitle {
@@ -43,40 +47,101 @@
   text-overflow: ellipsis;
   white-space: nowrap;
   flex: 1;
+  min-width: 0;
 }
 
-/* 收起按钮:标题行右侧 */
 .collapseBtn {
   flex-shrink: 0;
   color: var(--ant-color-text-secondary, rgba(0, 0, 0, 0.45));
 }
-
 .collapseBtn:hover {
   color: var(--ant-color-primary, #1677ff) !important;
   background: var(--ant-color-primary-bg, #e6f4ff) !important;
 }
 
-/* 侧边栏主体,允许滚动 */
 .sidebarContent {
   flex: 1;
   overflow-y: auto;
   overflow-x: hidden;
+  min-height: 0;
 }
 
-/* ── 右侧面板 ── */
-.rightPanel {
+/* ── 右侧主区域(flex:1,容纳 Splitter)── */
+.mainArea {
+  flex: 1;
+  min-width: 0;
+  min-height: 0;
   overflow: hidden;
-  position: relative;
 }
 
-/* ── 展开按钮(右侧使用)── */
-/* 框架提供默认样式;右侧组件可自行覆盖 */
+/* Splitter 撑满 mainArea */
+.splitter {
+  width: 100%;
+  height: 100%;
+}
+
+/* ── 中间内容面板 ── */
+.centerPanel {
+  overflow: hidden;
+  height: 100%;
+}
+
+/* ── 展开按钮(由中间内容区组件放置)── */
 .expandBtn {
   color: var(--ant-color-text-secondary, rgba(0, 0, 0, 0.45));
   border-radius: 4px;
 }
-
 .expandBtn:hover {
   color: var(--ant-color-primary, #1677ff) !important;
   background: var(--ant-color-primary-bg, #e6f4ff) !important;
 }
+
+/* ── 右边栏 Splitter.Panel 容器 ── */
+.rightAreaPanel {
+  overflow: hidden;
+  padding: 0 !important;
+}
+
+/* 右边栏内部:flex row(面板内容 + 工具栏) */
+.rightArea {
+  display: flex;
+  flex-direction: row;
+  height: 100%;
+  overflow: hidden;
+  border-left: 1px solid var(--ant-color-split, #f0f0f0);
+}
+
+/* 右边栏面板内容区 */
+.rightPanelContent {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  min-width: 0;
+}
+
+.rightPanelHeader {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 8px 8px 12px;
+  flex-shrink: 0;
+  border-bottom: 1px solid var(--ant-color-split, #f0f0f0);
+  min-height: 40px;
+}
+
+.rightPanelTitle {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--ant-color-text, rgba(0, 0, 0, 0.88));
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.rightPanelBody {
+  flex: 1;
+  overflow-y: auto;
+  overflow-x: hidden;
+}

+ 217 - 56
dashboard-v6/src/components/general/SplitLayout/SplitLayout.tsx

@@ -1,49 +1,80 @@
-import { useCallback, useState, type ReactNode } from "react";
+import {
+  CloseOutlined,
+  MenuFoldOutlined,
+  MenuUnfoldOutlined,
+} from "@ant-design/icons";
 import { Button, Splitter } from "antd";
-import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
+import { useCallback, useState, type ReactNode } from "react";
+import RightToolbar, { type RightToolbarTab } from "./RightToolbar";
 import styles from "./SplitLayout.module.css";
 import {
   SplitLayoutContext,
   type SplitLayoutContextValue,
 } from "./SplitLayoutContext";
 
+// ─────────────────────────────────────────────
+// 常量:手工调整左侧栏宽度
+// ─────────────────────────────────────────────
+
+/** 左侧面板固定宽度(px)。左侧栏不参与拖拽,修改此值即可调整宽度。 */
+const SIDEBAR_WIDTH = 260;
+
+/** 右边工具栏固定宽度(px) */
+const TOOLBAR_WIDTH = 40;
+
 // ─────────────────────────────────────────────
 // Props
 // ─────────────────────────────────────────────
 
 export interface SplitLayoutProps {
-  /** 左侧面板标题区域(左侧),支持任意 ReactNode */
+  /** 左侧面板标题,支持任意 ReactNode */
   sidebarTitle: ReactNode;
   /** 左侧面板内容 */
   sidebar: ReactNode;
   /**
-   * 右侧内容。
-   *
-   * 支持两种用法:
+   * 中间内容区。支持两种用法:
    *
-   * 1. Render Props(方案 A)—— 框架直接把 expandButton 传入:
-   *    ```tsx
-   *    <SplitLayout ...>
-   *      {({ expandButton }) => <MyPage headerExtra={expandButton} />}
-   *    </SplitLayout>
-   *    ```
+   * 方案 A — Render Props,框架把 expandButton 作为参数传入:
+   * ```tsx
+   * <SplitLayout ...>
+   *   {({ expandButton }) => <MyPage headerExtra={expandButton} />}
+   * </SplitLayout>
+   * ```
    *
-   * 2. 普通 ReactNode(方案 B)—— 右侧组件自己调用 useSplitLayout():
-   *    ```tsx
-   *    <SplitLayout ...>
-   *      <ComplexPage />
-   *    </SplitLayout>
-   *    ```
+   * 方案 B — 普通 ReactNode,内部自己调用 useSplitLayout():
+   * ```tsx
+   * <SplitLayout ...>
+   *   <ComplexPage />
+   * </SplitLayout>
+   * ```
    */
   children:
     | ReactNode
     | ((ctx: Pick<SplitLayoutContextValue, "expandButton">) => ReactNode);
-  /** 左侧面板默认宽度(px),默认 240 */
-  defaultSidebarSize?: number;
-  /** 左侧面板最小宽度(px),默认 160 */
-  minSidebarSize?: number;
-  /** 左侧面板最大宽度(px),默认 480 */
-  maxSidebarSize?: number;
+
+  /**
+   * 右边栏 tab 配置。不传则不渲染右边栏。
+   * ```tsx
+   * rightTabs={[
+   *   { key: "chat",   icon: <CommentOutlined />, label: "对话" },
+   *   { key: "search", icon: <SearchOutlined />,  label: "搜索" },
+   * ]}
+   * ```
+   */
+  rightTabs?: RightToolbarTab[];
+  /**
+   * 右边栏面板内容映射,key 对应 rightTabs 中的 key。
+   * ```tsx
+   * rightPanels={{ chat: <ChatPanel />, search: <SearchPanel /> }}
+   * ```
+   */
+  rightPanels?: Record<string, ReactNode>;
+  /** 右边栏面板默认宽度(px),默认 500 */
+  defaultRightSize?: number;
+  /** 右边栏面板最小宽度(px),默认 280 */
+  minRightSize?: number;
+  /** 右边栏面板最大宽度(px),默认 800 */
+  maxRightSize?: number;
 }
 
 // ─────────────────────────────────────────────
@@ -54,15 +85,33 @@ export default function SplitLayout({
   sidebarTitle,
   sidebar,
   children,
-  defaultSidebarSize = 240,
-  minSidebarSize = 160,
-  maxSidebarSize = 480,
+  rightTabs,
+  rightPanels,
+  defaultRightSize = 400,
+  minRightSize = 280,
+  maxRightSize = 800,
 }: SplitLayoutProps) {
-  const [collapsed, setCollapsed] = useState(false);
-
-  const toggle = useCallback(() => setCollapsed((v) => !v), []);
+  // ── 左侧收起状态(持久化到 localStorage)──
+  const COLLAPSED_KEY = "split-layout:sidebar-collapsed";
+  const [collapsed, setCollapsed] = useState<boolean>(() => {
+    try {
+      return localStorage.getItem(COLLAPSED_KEY) === "true";
+    } catch {
+      return false;
+    }
+  });
+  const toggle = useCallback(() => {
+    setCollapsed((v) => {
+      const next = !v;
+      try {
+        localStorage.setItem(COLLAPSED_KEY, String(next));
+      } catch {
+        // 静默降级
+      }
+      return next;
+    });
+  }, []);
 
-  // 展开按钮:仅在收起状态下渲染真实节点
   const expandButton = collapsed ? (
     <Button
       type="text"
@@ -74,28 +123,85 @@ export default function SplitLayout({
     />
   ) : null;
 
-  const ctx: SplitLayoutContextValue = { collapsed, toggle, expandButton };
+  // ── 右边栏状态 ──
+  const [rightActiveKey, setRightActiveKey] = useState<string | null>(null);
+  const rightOpen = rightActiveKey !== null;
+  const hasRight = !!rightTabs?.length;
+
+  const onRightTabClick = useCallback((key: string) => {
+    setRightActiveKey((prev) => (prev === key ? null : key));
+  }, []);
 
-  // 右侧内容:支持 render props 和普通 ReactNode 两种形式
-  const rightContent =
+  const closeRightPanel = useCallback(() => setRightActiveKey(null), []);
+
+  // ── Context ──
+  const ctx: SplitLayoutContextValue = {
+    collapsed,
+    toggle,
+    expandButton,
+    rightActiveKey,
+    onRightTabClick,
+    closeRightPanel,
+  };
+
+  const centerContent =
     typeof children === "function" ? children({ expandButton }) : children;
 
+  // 右边栏面板宽度:从 localStorage 读取初始值,拖拽后写回
+  const STORAGE_KEY = "split-layout:right-panel-width";
+  const [rightPanelWidth, setRightPanelWidth] = useState<number>(() => {
+    try {
+      const stored = localStorage.getItem(STORAGE_KEY);
+      if (stored) {
+        const parsed = Number(stored);
+        if (
+          Number.isFinite(parsed) &&
+          parsed >= minRightSize &&
+          parsed <= maxRightSize
+        ) {
+          return parsed;
+        }
+      }
+    } catch {
+      // localStorage 不可用(隐私模式等),静默降级
+    }
+    return defaultRightSize;
+  });
+
+  // Splitter sizes 受控:关闭时固定工具栏宽,展开时恢复持久化宽度
+  const rightSize = rightOpen ? rightPanelWidth + TOOLBAR_WIDTH : TOOLBAR_WIDTH;
+
+  const handleSplitterResize = useCallback(
+    (sizes: number[]) => {
+      if (rightOpen && sizes[1] !== undefined) {
+        const contentWidth = sizes[1] - TOOLBAR_WIDTH;
+        if (contentWidth > 0) {
+          setRightPanelWidth(contentWidth);
+          try {
+            localStorage.setItem(STORAGE_KEY, String(contentWidth));
+          } catch {
+            // 静默降级
+          }
+        }
+      }
+    },
+    [rightOpen]
+  );
+
   return (
     <SplitLayoutContext.Provider value={ctx}>
-      <Splitter className={styles.splitter}>
-        {/* ── 左侧面板 ── */}
-        <Splitter.Panel
-          size={collapsed ? 0 : defaultSidebarSize}
-          min={collapsed ? 0 : minSidebarSize}
-          max={maxSidebarSize}
-          className={styles.leftPanel}
-          collapsible
-        >
-          <div
-            className={styles.sidebarInner}
-            style={{ display: collapsed ? "none" : "flex" }}
-          >
-            {/* 标题行:左侧 title,右侧收起按钮 */}
+      {/*
+       * 整体布局:flex row
+       *   左侧栏(固定宽度,不参与 Splitter)
+       *   + 单个 Splitter lazy(中间内容 ↔ 右边栏)
+       *
+       * 左侧栏固定宽度由 SIDEBAR_WIDTH 常量控制,无拖拽,
+       * 完全隔离于右侧 Splitter,彻底避免嵌套 Splitter 的坐标偏移 bug。
+       */}
+      <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
@@ -107,17 +213,72 @@ export default function SplitLayout({
                 title="收起侧边栏"
               />
             </div>
-
-            {/* 侧边栏主体内容 */}
             <div className={styles.sidebarContent}>{sidebar}</div>
           </div>
-        </Splitter.Panel>
+        )}
+
+        {/* ── 右侧主区域:单个 Splitter lazy(中间 ↔ 右边栏)── */}
+        <div className={styles.mainArea}>
+          {hasRight ? (
+            <Splitter
+              lazy
+              className={styles.splitter}
+              onResize={handleSplitterResize}
+            >
+              {/* 中间内容 */}
+              <Splitter.Panel className={styles.centerPanel}>
+                {centerContent}
+              </Splitter.Panel>
+
+              {/* 右边栏 */}
+              <Splitter.Panel
+                size={rightSize}
+                min={minRightSize + TOOLBAR_WIDTH}
+                max={maxRightSize + TOOLBAR_WIDTH}
+                resizable={rightOpen}
+                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>
+                  )}
 
-        {/* ── 右侧面板 ── */}
-        <Splitter.Panel className={styles.rightPanel}>
-          {rightContent}
-        </Splitter.Panel>
-      </Splitter>
+                  {/* 工具栏:始终可见,固定在最右侧 */}
+                  <RightToolbar
+                    tabs={rightTabs!}
+                    activeKey={rightActiveKey}
+                    onTabClick={onRightTabClick}
+                  />
+                </div>
+              </Splitter.Panel>
+            </Splitter>
+          ) : (
+            // 没有右边栏时,中间内容直接撑满
+            <div className={styles.centerPanel}>{centerContent}</div>
+          )}
+        </div>
+      </div>
     </SplitLayoutContext.Provider>
   );
 }

+ 19 - 0
dashboard-v6/src/components/general/SplitLayout/SplitLayoutContext.ts

@@ -1,10 +1,26 @@
 import { createContext, useContext } from "react";
 import type { ReactNode } from "react";
+import type { RightToolbarTab } from "./RightToolbar";
+
+// ── 左侧栏 ──────────────────────────────────
 
 export interface SplitLayoutContextValue {
+  /** 左侧面板是否已收起 */
   collapsed: boolean;
   toggle: () => void;
+  /**
+   * 展开按钮节点(左侧收起时为真实按钮,展开时为 null)。
+   * 右侧内容区通过 render props 或 useSplitLayout() 取得,自行决定放置位置。
+   */
   expandButton: ReactNode;
+
+  // ── 右边栏 ────────────────────────────────
+  /** 当前激活的右边栏 tab key,null 表示面板已关闭 */
+  rightActiveKey: string | null;
+  /** 切换右边栏 tab(已激活则关闭,未激活则打开) */
+  onRightTabClick: (key: string) => void;
+  /** 关闭右边栏面板 */
+  closeRightPanel: () => void;
 }
 
 export const SplitLayoutContext = createContext<SplitLayoutContextValue | null>(
@@ -18,3 +34,6 @@ export function useSplitLayout(): SplitLayoutContextValue {
   }
   return ctx;
 }
+
+// Re-export so callers only need to import from this file
+export type { RightToolbarTab };

+ 126 - 32
dashboard-v6/src/components/general/SplitLayout/SplitLayoutTest.tsx

@@ -1,9 +1,16 @@
-import { FileOutlined, FolderOutlined } from "@ant-design/icons";
-import { Segmented, Tree, Typography } from "antd";
+import {
+  BugOutlined,
+  FileOutlined,
+  FolderOutlined,
+  SearchOutlined,
+  CommentOutlined,
+} from "@ant-design/icons";
+import { Input, Segmented, Tree, Typography } from "antd";
 import type { TreeDataNode } from "antd";
 import { useState, type ReactNode } from "react";
 import SplitLayout from "./SplitLayout";
 import { useSplitLayout } from "./SplitLayoutContext";
+import type { RightToolbarTab } from "./RightToolbar";
 
 // ─────────────────────────────────────────────
 // 模拟文件树数据
@@ -62,18 +69,6 @@ const treeData: TreeDataNode[] = [
       },
     ],
   },
-  {
-    title: "staging",
-    key: "staging",
-    icon: <FolderOutlined />,
-    children: [
-      {
-        title: "inventory.yml",
-        key: "staging/inventory.yml",
-        icon: <FileOutlined />,
-      },
-    ],
-  },
   { title: ".gitignore", key: ".gitignore", icon: <FileOutlined /> },
   { title: "ansible.cfg", key: "ansible.cfg", icon: <FileOutlined /> },
 ];
@@ -97,6 +92,90 @@ workers:
       MODEL: gpt-4o
       CONCURRENCY: 4`;
 
+// ─────────────────────────────────────────────
+// 右边栏 tabs 配置
+// ─────────────────────────────────────────────
+
+const rightTabs: RightToolbarTab[] = [
+  { key: "chat", icon: <CommentOutlined />, label: "对话" },
+  { key: "search", icon: <SearchOutlined />, label: "搜索" },
+  { key: "debug", icon: <BugOutlined />, label: "调试" },
+];
+
+// ─────────────────────────────────────────────
+// 右边栏面板内容
+// ─────────────────────────────────────────────
+
+const rightPanels: Record<string, ReactNode> = {
+  chat: (
+    <div style={{ padding: 16 }}>
+      <Typography.Text type="secondary" style={{ fontSize: 12 }}>
+        模拟对话面板
+      </Typography.Text>
+      <div
+        style={{
+          marginTop: 12,
+          display: "flex",
+          flexDirection: "column",
+          gap: 8,
+        }}
+      >
+        {[
+          "部署流程是什么?",
+          "如何回滚到上个版本?",
+          "worker 数量如何调整?",
+        ].map((q) => (
+          <div
+            key={q}
+            style={{
+              padding: "8px 12px",
+              background: "var(--ant-color-fill-quaternary, #f5f5f5)",
+              borderRadius: 6,
+              fontSize: 13,
+              cursor: "pointer",
+            }}
+          >
+            {q}
+          </div>
+        ))}
+      </div>
+    </div>
+  ),
+  search: (
+    <div style={{ padding: 16 }}>
+      <Input.Search placeholder="搜索文件内容..." size="small" />
+      <div style={{ marginTop: 12 }}>
+        <Typography.Text type="secondary" style={{ fontSize: 12 }}>
+          搜索结果将显示在此处
+        </Typography.Text>
+      </div>
+    </div>
+  ),
+  debug: (
+    <div style={{ padding: 16 }}>
+      <Typography.Text type="secondary" style={{ fontSize: 12 }}>
+        调试信息
+      </Typography.Text>
+      <pre
+        style={{
+          marginTop: 8,
+          fontSize: 12,
+          background: "var(--ant-color-fill-quaternary, #f5f5f5)",
+          borderRadius: 6,
+          padding: 12,
+          overflow: "auto",
+        }}
+      >
+        {JSON.stringify(
+          { env: "production", workers: 3, status: "running" },
+          null,
+          2
+        )}
+      </pre>
+    </div>
+  ),
+};
+
 // ─────────────────────────────────────────────
 // 共用样式
 // ─────────────────────────────────────────────
@@ -111,15 +190,6 @@ const headerStyle: React.CSSProperties = {
   flexShrink: 0,
 };
 
-const preStyle: React.CSSProperties = {
-  background: "var(--ant-color-fill-quaternary, #f5f5f5)",
-  borderRadius: 6,
-  padding: 16,
-  fontSize: 13,
-  lineHeight: 1.7,
-  overflow: "auto",
-};
-
 // ─────────────────────────────────────────────
 // 模拟侧边栏
 // ─────────────────────────────────────────────
@@ -136,8 +206,7 @@ function MockSidebar() {
 }
 
 // ─────────────────────────────────────────────
-// 方案 A:MockContentA
-// expandButton 由外部(render props)注入,组件本身无框架依赖
+// 方案 A:expandButton 由外部 render props 注入
 // ─────────────────────────────────────────────
 
 interface MockContentAProps {
@@ -170,15 +239,25 @@ function MockContentA({ headerExtra }: MockContentAProps) {
           Last commit: <strong>add multi ai-translate worker support</strong> ·
           11 months ago
         </Typography.Paragraph>
-        <pre style={preStyle}>{fileContent}</pre>
+        <pre
+          style={{
+            background: "var(--ant-color-fill-quaternary,#f5f5f5)",
+            borderRadius: 6,
+            padding: 16,
+            fontSize: 13,
+            lineHeight: 1.7,
+            overflow: "auto",
+          }}
+        >
+          {fileContent}
+        </pre>
       </div>
     </div>
   );
 }
 
 // ─────────────────────────────────────────────
-// 方案 B:MockContentB
-// 自己调用 useSplitLayout() 取 expandButton,无需外部注入
+// 方案 B:自己调用 useSplitLayout() 取 expandButton
 // ─────────────────────────────────────────────
 
 function MockContentB() {
@@ -209,7 +288,18 @@ function MockContentB() {
           Last commit: <strong>add multi ai-translate worker support</strong> ·
           11 months ago
         </Typography.Paragraph>
-        <pre style={preStyle}>{fileContent}</pre>
+        <pre
+          style={{
+            background: "var(--ant-color-fill-quaternary,#f5f5f5)",
+            borderRadius: 6,
+            padding: 16,
+            fontSize: 13,
+            lineHeight: 1.7,
+            overflow: "auto",
+          }}
+        >
+          {fileContent}
+        </pre>
       </div>
     </div>
   );
@@ -233,7 +323,7 @@ export default function SplitLayoutTest() {
           alignItems: "center",
           gap: 12,
           padding: "8px 16px",
-          borderBottom: "1px solid var(--ant-color-split, #f0f0f0)",
+          borderBottom: "1px solid var(--ant-color-split,#f0f0f0)",
           flexShrink: 0,
         }}
       >
@@ -251,23 +341,27 @@ export default function SplitLayoutTest() {
         />
       </div>
 
-      {/* 方案 A:children 是函数,框架把 expandButton 作为参数传入 */}
+      {/* 方案 A */}
       {mode === "A" && (
         <SplitLayout
           key="mode-a"
           sidebarTitle="mint / deploy"
           sidebar={<MockSidebar />}
+          rightTabs={rightTabs}
+          rightPanels={rightPanels}
         >
           {({ expandButton }) => <MockContentA headerExtra={expandButton} />}
         </SplitLayout>
       )}
 
-      {/* 方案 B:children 是普通 ReactNode,MockContentB 自己取 expandButton */}
+      {/* 方案 B */}
       {mode === "B" && (
         <SplitLayout
           key="mode-b"
           sidebarTitle="mint / deploy"
           sidebar={<MockSidebar />}
+          rightTabs={rightTabs}
+          rightPanels={rightPanels}
         >
           <MockContentB />
         </SplitLayout>

+ 2 - 1
dashboard-v6/src/components/general/SplitLayout/index.ts

@@ -1,4 +1,5 @@
 export { default } from "./SplitLayout";
 export { useSplitLayout } from "./SplitLayoutContext";
 export type { SplitLayoutProps } from "./SplitLayout";
-export type { SplitLayoutContextValue } from "./SplitLayoutContext";
+export type { SplitLayoutContextValue, RightToolbarTab } from "./SplitLayoutContext";
+export { default as RightToolbar } from "./RightToolbar";

+ 31 - 20
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -15,10 +15,11 @@ import {
   TaskIcon,
   TipitakaIcon,
 } from "../../assets/icon";
-import React from "react";
+import React, { useState } from "react";
 import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 import { useRecent } from "../../hooks/useRecent.ts";
+import RecentModal from "../recent/RecentModal.tsx";
 
 /* ================= 类型 ================= */
 
@@ -33,11 +34,6 @@ interface MenuItem {
   activeId?: string | string[];
 }
 
-interface Props {
-  onSearch?: () => void;
-  onRecent?: () => void;
-}
-
 export interface RouteHandle {
   id?: string;
   crumb?: string | ((match: UIMatch) => string);
@@ -99,18 +95,21 @@ function findOpenKeys(
 }
 
 /* ================= 组件 ================= */
-
-const Widget = ({ onSearch, onRecent }: Props) => {
+interface Props {
+  onSearch?: () => void;
+}
+const Widget = ({ onSearch }: Props) => {
   const navigate = useNavigate();
   const routeId = useCurrentRouteId();
   const currUser = useAppSelector(currentUser);
 
   const { data } = useRecent(currUser?.id, 5, 0);
+  const [recentOpen, setRecentOpen] = useState(false);
 
   const recentList: MenuItem[] = data
-    ? data?.data.rows.map((item) => {
+    ? data?.data.rows.map((item, id) => {
         return {
-          key: item.id,
+          key: `recent-${id}`,
           label: item.title,
         };
       })
@@ -235,22 +234,34 @@ const Widget = ({ onSearch, onRecent }: Props) => {
       onSearch?.();
       return;
     } else if (key === "/workspace/recent/list") {
-      onRecent?.();
+      setRecentOpen(true);
       return;
     }
     navigate(key);
   };
 
-  //TODO 在这个组件打开search recent
   return (
-    <Menu
-      mode="inline"
-      selectedKeys={selectedKey ? [selectedKey] : []}
-      defaultOpenKeys={openKeys}
-      items={items as MenuProps["items"]}
-      onClick={handleClick}
-      style={{ borderRight: 0 }}
-    />
+    <>
+      <Menu
+        mode="inline"
+        selectedKeys={selectedKey ? [selectedKey] : []}
+        defaultOpenKeys={openKeys}
+        items={items as MenuProps["items"]}
+        onClick={handleClick}
+        style={{ borderRight: 0 }}
+      />
+      <RecentModal
+        open={recentOpen}
+        onOpenChange={() => setRecentOpen(false)}
+        onSelect={(e, row) => {
+          if (e.ctrlKey || e.metaKey) {
+            window.open("");
+          } else {
+            navigate(`/workspace/${row.type}/${row.articleId}`);
+          }
+        }}
+      />
+    </>
   );
 };
 

+ 2 - 19
dashboard-v6/src/layouts/workspace/index.tsx

@@ -1,5 +1,5 @@
 import { Button, Layout, Space } from "antd";
-import { Outlet, useNavigate } from "react-router";
+import { Outlet } from "react-router";
 import { useState } from "react";
 import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
 import MainMenu from "../../components/navigation/MainMenu";
@@ -7,17 +7,11 @@ import SignInAvatar from "../../components/auth/SignInAvatar";
 import HeaderBreadcrumb from "../../components/navigation/HeaderBreadcrumb";
 import ThemeSwitch from "../../components/theme/ThemeSwitch";
 import { NetworkStatus } from "../../components/general/NetworkStatus";
-import RecentModal from "../../components/recent/RecentModal";
 
 const { Sider, Content } = Layout;
 const Widget = () => {
   const [collapsed, setCollapsed] = useState(false);
-  const [recentOpen, setRecentOpen] = useState(false);
-  const navigate = useNavigate();
 
-  const recent = () => {
-    setRecentOpen(true);
-  };
   return (
     <Layout style={{ minHeight: "100vh" }}>
       <Sider
@@ -34,7 +28,7 @@ const Widget = () => {
             onClick={() => setCollapsed(!collapsed)}
           />
         </div>
-        <MainMenu onRecent={recent} />
+        <MainMenu />
       </Sider>
       <Layout>
         <div
@@ -57,17 +51,6 @@ const Widget = () => {
           <Outlet />
         </Content>
       </Layout>
-      <RecentModal
-        open={recentOpen}
-        onOpenChange={() => setRecentOpen(false)}
-        onSelect={(e, row) => {
-          if (e.ctrlKey || e.metaKey) {
-            window.open("");
-          } else {
-            navigate(`/workspace/${row.type}/${row.articleId}`);
-          }
-        }}
-      />
     </Layout>
   );
 };

+ 8 - 1
dashboard-v6/src/reducers/net-status.ts

@@ -1,6 +1,12 @@
 import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 import type { RootState } from "../store";
 
+export type EApiStatus =
+  | "pending" // 初始未检测
+  | "success" // 初始未检测
+  | "fail" // 检测中
+  | "loading"; // 网络 + API 均正常
+
 /**
  * 网络状态枚举
  *
@@ -11,7 +17,6 @@ import type { RootState } from "../store";
  * api_error   - 网络正常,但 API 返回非 2xx 响应
  * api_timeout - 网络正常,但 API 请求超时未响应
  *
- * @deprecated 旧值 "loading" | "success" | "fail" 已由上述状态替代
  */
 export type ENetStatus =
   | "idle" // 初始未检测
@@ -24,6 +29,7 @@ export type ENetStatus =
 export interface INetStatus {
   /** 当前状态 */
   status: ENetStatus;
+  api_status?: EApiStatus;
   /** 可选描述信息(错误原因、HTTP 状态码等) */
   message?: string;
   /** 上次成功检测时间(ISO string) */
@@ -41,6 +47,7 @@ interface IState {
 const initialState: IState = {
   status: {
     status: "idle",
+    api_status: "pending",
   },
 };