소스 검색

✨ (dashboard-v6): 右边栏字典改为多标签页,tab 标题显示所查单词

- 去掉「字典」标题栏,换成 antd editable-card tabs,关闭按钮在同一行最右侧
- 点 + 新开空白查词 tab;全局查词只送到当前 tab,其余保持各自的词
- tab 标题为该 tab 查的词,未查过显示「字典」占位
- RightToolbar 新增 hideHeader,面板可自带顶栏
- Dictionary 回顶改用 ref,多实例不再互相串

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 2 일 전
부모
커밋
84cbae7216

+ 33 - 7
dashboard-v6/src/components/dict/DictComponent.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
 
 import { useAppSelector } from "../../hooks";
 import { lookup, lookupWord, myDictIsDirty } from "../../reducers/command";
@@ -9,26 +9,52 @@ import Dictionary from "./Dictionary";
 
 export interface IWidgetDict {
   word?: string;
+  /**
+   * 是否跟随全局查词(redux `lookup`),默认 true。
+   * 右边栏开了多个字典 tab 时只有当前 tab 跟随,其余保持各自的词。
+   */
+  follow?: boolean;
+  /** 首次挂载时不显示当前全局查词,从空白开始(新开的 tab 用) */
+  blank?: boolean;
+  /** 显示的词变化时回调(含首次挂载),给外层做 tab 标题用 */
+  onWordChange?: (word?: string) => void;
 }
 
-const DictComponentWidget = ({ word }: IWidgetDict) => {
+const DictComponentWidget = ({
+  word,
+  follow = true,
+  blank = false,
+  onWordChange,
+}: IWidgetDict) => {
   const search = useAppSelector(lookupWord);
   const myDictDirty = useAppSelector(myDictIsDirty);
 
   useEffect(() => {
-    if (myDictDirty) {
+    // 多个实例时只让跟随的那个提示,避免重复弹出
+    if (myDictDirty && follow) {
       notification.warning({
         message: "用户词典有未保存内容,请保存后再查词",
       });
     }
-  }, [myDictDirty]);
+  }, [myDictDirty, follow]);
 
-  // 直接从 redux state 派生展示的词,无需本地 state
-  const wordSearch = typeof search === "string" && !myDictDirty ? search : word;
+  const live = typeof search === "string" && !myDictDirty ? search : word;
+
+  // 渲染期派生:全局查词变化时,只有跟随的实例更新自己显示的词
+  const [prevLive, setPrevLive] = useState(live);
+  const [shown, setShown] = useState(blank ? undefined : live);
+  if (live !== prevLive) {
+    setPrevLive(live);
+    if (follow) setShown(live);
+  }
+
+  useEffect(() => {
+    onWordChange?.(shown);
+  }, [shown, onWordChange]);
 
   return (
     <Dictionary
-      word={wordSearch}
+      word={shown}
       compact={true}
       onSearch={(value) => {
         console.debug("onSearch", value);

+ 4 - 2
dashboard-v6/src/components/dict/Dictionary.tsx

@@ -53,6 +53,8 @@ function parseQuery(value?: string): IQuery {
 const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
   const [split, setSplit] = useState<string>();
   const [container, setContainer] = useState<HTMLDivElement | null>(null);
+  // 右边栏可同时开多个字典 tab,不能用全局 id 定位顶部
+  const topRef = useRef<HTMLDivElement>(null);
   // 首次挂载就带着 word 时(如从 URL 进入)直接按它查,不必等 prop 变化
   const [initial] = useState(() => parseQuery(word));
   const [wordSearch, setWordSearch] = useState(initial.word);
@@ -89,7 +91,7 @@ const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
       mounted.current = true;
       return;
     }
-    document.getElementById("pcd_dict_top")?.scrollIntoView();
+    topRef.current?.scrollIntoView();
   }, [wordSearch, dictType, wordId]);
 
   const wordInputChange = (value: string | undefined) => {
@@ -123,7 +125,7 @@ const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
 
   return (
     <div ref={setContainer}>
-      <div id="pcd_dict_top"></div>
+      <div ref={topRef}></div>
       <Affix
         offsetTop={0}
         target={compact ? () => container : undefined}

+ 2 - 0
dashboard-v6/src/components/editor/Editor.tsx

@@ -118,6 +118,8 @@ export default function Editor({
       key: "dict",
       icon: <DictIcon />,
       label: "字典",
+      // DictPanel 自带 tabs 顶栏(含关闭按钮)
+      hideHeader: true,
       content: <DictPanel />,
     },
     {

+ 20 - 0
dashboard-v6/src/components/editor/panels/DictPanel.module.css

@@ -0,0 +1,20 @@
+/* 字典面板顶栏:tabs + 关闭按钮一行,滚动时吸顶 */
+.tabs > :global(.ant-tabs-nav) {
+  position: sticky;
+  top: 0;
+  z-index: 10;
+  margin: 0;
+  padding: 4px 4px 0 8px;
+  min-height: 40px;
+  background: var(--ant-color-bg-container, #fff);
+}
+
+/* 长词截断,悬停看全文 */
+.label {
+  display: inline-block;
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  vertical-align: bottom;
+}

+ 94 - 1
dashboard-v6/src/components/editor/panels/DictPanel.tsx

@@ -1,14 +1,107 @@
+import { CloseOutlined } from "@ant-design/icons";
+import { Button, Tabs } from "antd";
+import { useCallback, useState } from "react";
+import { useIntl } from "react-intl";
 import DictComponent from "../../dict/DictComponent";
+import { useSplitLayout } from "../../general/SplitLayout";
+import styles from "./DictPanel.module.css";
 
 /**
  * 字典面板
  * 封装成独立组件,让 React 管理生命周期,
  * 避免内联 JSX 在 rightTabs 重建时丢失内部状态。
+ *
+ * 顶栏是一行 tabs + 最右侧关闭按钮(替代右边栏通用的「字典」标题栏)。
+ * 点 + 新开一个空白查词 tab;全局查词(点经文里的词)只送到当前 tab,
+ * 其余 tab 保持各自的词,方便对照。tab 标题显示该 tab 查的词,没查过显示「字典」。
  */
+
+/**
+ * 从查词串取 tab 标题。普通词原样返回;高级格式(`type:term id:xxx`)
+ * 只认 `word:`,取不到返回 undefined,由调用方保留原标题。
+ */
+function labelOf(value?: string): string | undefined {
+  if (!value) return undefined;
+  if (!value.includes(":")) return value;
+  return value.match(/(?:^|\s)word:(\S+)/)?.[1];
+}
+
 export default function DictPanel() {
+  const intl = useIntl();
+  const { closeRightPanel } = useSplitLayout();
+  const [tabs, setTabs] = useState<string[]>(["1"]);
+  const [activeKey, setActiveKey] = useState("1");
+  const [nextKey, setNextKey] = useState(2);
+  /** tab key → 该 tab 查的词 */
+  const [labels, setLabels] = useState<Record<string, string>>({});
+
+  // 值没变时返回原对象,React 跳过重渲染,子组件每次渲染回调也不会循环
+  const setWord = useCallback((key: string, word?: string) => {
+    const label = labelOf(word);
+    if (label === undefined) return;
+    setLabels((prev) =>
+      prev[key] === label ? prev : { ...prev, [key]: label },
+    );
+  }, []);
+
+  const title = intl.formatMessage({ id: "columns.library.dict.title" });
+
+  const add = () => {
+    const key = String(nextKey);
+    setNextKey(nextKey + 1);
+    setTabs([...tabs, key]);
+    setActiveKey(key);
+  };
+
+  const remove = (key: string) => {
+    const index = tabs.indexOf(key);
+    const rest = tabs.filter((t) => t !== key);
+    setTabs(rest);
+    if (key === activeKey) {
+      setActiveKey(rest[Math.max(0, index - 1)]);
+    }
+  };
+
   return (
     <div className="dict_component">
-      <DictComponent />
+      <Tabs
+        className={styles.tabs}
+        type="editable-card"
+        size="small"
+        activeKey={activeKey}
+        onChange={setActiveKey}
+        onEdit={(key, action) => {
+          if (action === "add") add();
+          else if (typeof key === "string") remove(key);
+        }}
+        tabBarExtraContent={{
+          right: (
+            <Button
+              type="text"
+              size="small"
+              icon={<CloseOutlined />}
+              onClick={closeRightPanel}
+              title="关闭面板"
+            />
+          ),
+        }}
+        items={tabs.map((key) => ({
+          key,
+          label: (
+            <span className={styles.label} title={labels[key]}>
+              {labels[key] ?? title}
+            </span>
+          ),
+          closable: tabs.length > 1,
+          children: (
+            <DictComponent
+              follow={key === activeKey}
+              blank={key !== "1"}
+              onWordChange={(word) => setWord(key, word)}
+            />
+          ),
+        }))}
+      />
     </div>
   );
 }

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

@@ -59,6 +59,11 @@
   flex-direction: column;
 }
 
+/* 面板自带顶栏(hideHeader)时占满整个高度 */
+.panelBodyFull {
+  height: 100vh;
+}
+
 /* ── 纵向图标工具栏 ── */
 .toolbar {
   display: flex;

+ 21 - 12
dashboard-v6/src/components/general/SplitLayout/RightToolbar.tsx

@@ -16,6 +16,11 @@ export interface RightToolbarTab {
    * - 不传则点击图标不展开面板(纯按钮行为)。
    */
   content?: ReactNode;
+  /**
+   * 不渲染面板 header(标题 + 关闭按钮)。
+   * 面板自己提供顶栏时用,关闭按钮通过 useSplitLayout().closeRightPanel 自行放置。
+   */
+  hideHeader?: boolean;
 }
 
 interface RightToolbarProps {
@@ -56,20 +61,24 @@ export default function RightToolbar({
       {panelOpen && (
         <div className={styles.panel}>
           {/* 面板 header */}
-          <div className={styles.panelHeader}>
-            <span className={styles.panelTitle}>{activeTab!.label}</span>
-            <Button
-              type="text"
-              size="small"
-              icon={<CloseOutlined />}
-              onClick={onClose}
-              className={styles.closeBtn}
-              title="关闭面板"
-            />
-          </div>
+          {!activeTab!.hideHeader && (
+            <div className={styles.panelHeader}>
+              <span className={styles.panelTitle}>{activeTab!.label}</span>
+              <Button
+                type="text"
+                size="small"
+                icon={<CloseOutlined />}
+                onClick={onClose}
+                className={styles.closeBtn}
+                title="关闭面板"
+              />
+            </div>
+          )}
 
           {/* 各 tab 内容:懒创建,隐藏不销毁 */}
-          <div className={styles.panelBody}>
+          <div
+            className={`${styles.panelBody} ${activeTab!.hideHeader ? styles.panelBodyFull : ""}`}
+          >
             {tabs
               .filter((t) => t.content && mounted.has(t.key))
               .map((t) => (