Просмотр исходного кода

feat(dashboard-v6): 字典、语法手册独立页面,查词结果可由地址栏重入

- 新路由 /workspace/dictionary(/:word) 与 /workspace/grammar-book,
  与编辑器右边栏的「字典」「语法手册」是同一组件;/workspace/dict 仍是用户字典
- 查词写进地址栏,刷新、分享、前进后退都能重入;地址里也可写
  type:term id:xxx / type:term word:xxx 直接打开术语
- 主导航「工具」加入两个入口,顺序调整为 字典、语法手册、用户字典、标签、网盘
- Dictionary:首次挂载就按带入的 word 查(迁移自 v4 时丢失);
  word prop 变化时按 v4 解析高级格式;滚回顶部挪进 useEffect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 1 день назад
Родитель
Сommit
bd17f52907

+ 2 - 0
dashboard-v6/src/Router.tsx

@@ -17,6 +17,7 @@ import transferRoutes from "./routes/transferRoutes";
 import tagRoutes from "./routes/tagRoutes";
 import driverRoutes from "./routes/driverRoutes";
 import dictRoutes from "./routes/dictRoutes";
+import dictionaryRoutes from "./routes/dictionaryRoutes";
 import courseRoutes from "./routes/courseRoutes";
 import nissayaRoutes from "./routes/nissayaRoutes";
 import discussionRoutes from "./routes/discussionRoutes";
@@ -106,6 +107,7 @@ const router = createBrowserRouter(
             ...tagRoutes,
             ...driverRoutes,
             ...dictRoutes,
+            ...dictionaryRoutes,
             ...courseRoutes,
             ...nissayaRoutes,
             ...discussionRoutes,

+ 82 - 59
dashboard-v6/src/components/dict/Dictionary.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { Layout, Affix, Col, Row } from "antd";
 
 import DictSearch from "./DictSearch";
@@ -14,88 +14,111 @@ interface IWidget {
   onSearch?: (word?: string) => void;
 }
 
+interface IQuery {
+  type: string;
+  word?: string;
+  id?: string;
+}
+
+/**
+ * 解析查词串。普通词按小写查字典;带冒号的是高级格式,
+ * 如 `type:term id:xxx`、`type:term word:dhamma`。纯函数,不碰 state。
+ */
+function parseQuery(value?: string): IQuery {
+  if (!value?.includes(":")) {
+    return { type: "dict", word: value?.toLowerCase() };
+  }
+  // 与 v4 一致:没写 type 时沿用字典
+  const query: IQuery = { type: "dict", word: "" };
+  value.split(" ").forEach((item) => {
+    const kv = item.split(":");
+    if (kv.length !== 2) return;
+    switch (kv[0]) {
+      case "type":
+        query.type = kv[1];
+        break;
+      case "word":
+        query.word = kv[1];
+        break;
+      case "id":
+        query.id = kv[1];
+        break;
+      default:
+        break;
+    }
+  });
+  return query;
+}
+
 const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
   const [split, setSplit] = useState<string>();
-  const [wordSearch, setWordSearch] = useState<string>();
   const [container, setContainer] = useState<HTMLDivElement | null>(null);
-  const [dictType, setDictType] = useState("dict");
+  // 首次挂载就带着 word 时(如从 URL 进入)直接按它查,不必等 prop 变化
+  const [initial] = useState(() => parseQuery(word));
+  const [wordSearch, setWordSearch] = useState(initial.word);
+  const [dictType, setDictType] = useState(initial.type);
+  const [wordId, setWordId] = useState(initial.id);
   const [wordInput, setWordInput] = useState(word);
-  const [wordId, setWordId] = useState<string>();
 
   // 用 state 记录上一次的 prop,渲染期派生的标准做法
   const [prevWordProp, setPrevWordProp] = useState<string | undefined>(word);
-  const [prevSearch, setPrevSearch] = useState<string | undefined>(undefined);
+  const [prevSearch, setPrevSearch] = useState<string | undefined>(word);
 
-  const wordChange = useCallback((value?: string) => {
-    let currWord: string | undefined = "";
-    if (value?.includes(":")) {
-      const param = value.split(" ");
-      param.forEach((item) => {
-        const kv = item.split(":");
-        if (kv.length === 2) {
-          switch (kv[0]) {
-            case "type":
-              setDictType(kv[1]);
-              break;
-            case "word":
-              currWord = kv[1];
-              break;
-            case "id":
-              setWordId(kv[1]);
-              break;
-            default:
-              break;
-          }
-        }
-      });
-    } else {
-      setDictType("dict");
-      currWord = value?.toLowerCase();
-    }
-    document.getElementById("pcd_dict_top")?.scrollIntoView();
-    return currWord;
-  }, []);
+  /** 只更新本组件的 state,可在渲染期调用 */
+  const applyQuery = (query: IQuery) => {
+    setDictType(query.type);
+    // 只有高级格式带 id;普通词不清掉旧 id,与 v4 一致
+    if (query.id !== undefined) setWordId(query.id);
+    setWordSearch(query.word);
+  };
 
-  // 渲染期派生:word prop 变化时同步更新 wordInput / wordSearch
+  // 渲染期派生:word prop 变化(路由前进后退、redux 查词)时同步输入框并查词
   if (word !== prevWordProp) {
     setPrevWordProp(word);
     setWordInput(word);
-
     if (word !== prevSearch) {
       setPrevSearch(word);
-      const currWord = word?.includes(":")
-        ? word // 复杂格式保持原值,wordChange 会在实际搜索时解析
-        : word?.toLowerCase();
-      setWordSearch(currWord);
+      applyQuery(parseQuery(word));
     }
   }
 
-  const wordInputChange = useCallback(
-    (value: string | undefined) => {
-      setWordInput(value);
-      if (value === prevSearch) {
-        console.debug("same word, skip search", value);
-        return;
-      }
-      setPrevSearch(value);
-      const currWord = wordChange(value);
-      setWordSearch(currWord);
-    },
-    [wordChange, prevSearch]
-  );
+  // 换词后滚回顶部。副作用不能放在渲染期,所以放这里;首次挂载不滚
+  const mounted = useRef(false);
+  useEffect(() => {
+    if (!mounted.current) {
+      mounted.current = true;
+      return;
+    }
+    document.getElementById("pcd_dict_top")?.scrollIntoView();
+  }, [wordSearch, dictType, wordId]);
+
+  const wordInputChange = (value: string | undefined) => {
+    setWordInput(value);
+    if (value === prevSearch) {
+      console.debug("same word, skip search", value);
+      return;
+    }
+    setPrevSearch(value);
+    applyQuery(parseQuery(value));
+  };
 
   const dictSearch = useCallback(
     (value: string, isFactor?: boolean) => {
       console.info("onSearch", value);
-      const currWord = wordChange(value);
+      const query = parseQuery(value);
+      // 与 v4 一致,交给外部(路由 / redux)的只是解析出的单词,搜索框里的
+      // 高级格式不进地址栏;要重入请直接在地址栏写 type:term id:xxx
       if (typeof onSearch !== "undefined" && !isFactor) {
-        onSearch(currWord);
+        onSearch(query.word);
       }
-      // 用户主动搜索不去重
-      setPrevSearch(value);
-      setWordSearch(currWord);
+      // 用户主动搜索不去重。记下交给外部的词,它回传成 word prop 时不再重查,
+      // 否则会按普通词重新解析,把刚切到的术语视图冲掉
+      setPrevSearch(query.word);
+      setDictType(query.type);
+      if (query.id !== undefined) setWordId(query.id);
+      setWordSearch(query.word);
     },
-    [wordChange, onSearch]
+    [onSearch],
   );
 
   return (

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

@@ -287,18 +287,18 @@ const Widget = ({ onSearch }: Props) => {
       label: intl.formatMessage({ id: "labels.tools" }),
       children: [
         {
-          key: "/workspace/tag",
+          key: "/workspace/dictionary",
           label: intl.formatMessage({
-            id: "columns.studio.tag.title",
+            id: "columns.library.dict.title",
           }),
-          activeId: "workspace.tag",
+          activeId: "workspace.dictionary",
         },
         {
-          key: "/workspace/driver",
+          key: "/workspace/grammar-book",
           label: intl.formatMessage({
-            id: "columns.studio.attachment.title",
+            id: "columns.library.palihandbook.title",
           }),
-          activeId: "workspace.driver",
+          activeId: "workspace.grammar-book",
         },
         {
           key: "/workspace/dict",
@@ -307,6 +307,20 @@ const Widget = ({ onSearch }: Props) => {
           }),
           activeId: "workspace.dict",
         },
+        {
+          key: "/workspace/tag",
+          label: intl.formatMessage({
+            id: "columns.studio.tag.title",
+          }),
+          activeId: "workspace.tag",
+        },
+        {
+          key: "/workspace/driver",
+          label: intl.formatMessage({
+            id: "columns.studio.attachment.title",
+          }),
+          activeId: "workspace.driver",
+        },
       ],
     },
     {

+ 50 - 0
dashboard-v6/src/pages/workspace/dictionary/index.tsx

@@ -0,0 +1,50 @@
+import { useEffect, useRef } from "react";
+import { useIntl } from "react-intl";
+import { useNavigate, useParams } from "react-router";
+
+import Dictionary from "../../../components/dict/Dictionary";
+import { useAppSelector } from "../../../hooks";
+import { lookupWord } from "../../../reducers/command";
+
+/**
+ * 字典(独立页面)
+ * 查的词放在路由里(/workspace/dictionary/:word),刷新、分享、前进后退都能重入。
+ */
+const Widget = () => {
+  const intl = useIntl();
+  const navigate = useNavigate();
+  const { word } = useParams();
+
+  // 回车时 AutoComplete 的 onSelect 与 Input.Search 的 onSearch 会接连触发同一个词,
+  // 而此时 word 还是旧值,所以用 ref 记住刚跳过去的词,避免历史记录里压两条。
+  const lastWord = useRef(word);
+  useEffect(() => {
+    lastWord.current = word;
+  }, [word]);
+  const search = (value?: string) => {
+    if (!value || value === lastWord.current) return;
+    lastWord.current = value;
+    navigate(`/workspace/dictionary/${encodeURIComponent(value)}`);
+  };
+
+  // 结果里的巴利词点击查词走的是 redux 的 lookup,这里跟上它。
+  // 只响应挂载之后的变化,避免进页面时被 redux 里残留的旧词带跑。
+  const lookup = useAppSelector(lookupWord);
+  const prevLookup = useRef(lookup);
+  useEffect(() => {
+    if (lookup === prevLookup.current) return;
+    prevLookup.current = lookup;
+    if (typeof lookup === "string") search(lookup);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [lookup]);
+
+  return (
+    <>
+      {/* React 19 的 <title> 只接受单个字符串子节点 */}
+      <title>{`${word ? `${word} - ` : ""}${intl.formatMessage({ id: "columns.library.dict.title" })}`}</title>
+      <Dictionary word={word} onSearch={search} />
+    </>
+  );
+};
+
+export default Widget;

+ 21 - 0
dashboard-v6/src/pages/workspace/grammar-book/index.tsx

@@ -0,0 +1,21 @@
+import { useIntl } from "react-intl";
+
+import GrammarBook from "../../../components/term/GrammarBook";
+
+/**
+ * 语法手册(独立页面)
+ * 与编辑器右边栏的语法手册面板是同一个组件。
+ */
+const Widget = () => {
+  const intl = useIntl();
+  return (
+    <div style={{ maxWidth: 960, margin: "0 auto", padding: 16 }}>
+      <title>
+        {intl.formatMessage({ id: "columns.library.palihandbook.title" })}
+      </title>
+      <GrammarBook />
+    </div>
+  );
+};
+
+export default Widget;

+ 35 - 0
dashboard-v6/src/routes/dictionaryRoutes.ts

@@ -0,0 +1,35 @@
+// src/routes/dictionaryRoutes.ts
+// 编辑器右边栏的「字典」「语法手册」的独立页面。
+// 注意 /workspace/dict 是「用户字典」,不要混淆。
+import { lazy } from "react";
+import type { RouteObject, UIMatch } from "react-router";
+
+const WorkspaceDictionary = lazy(() => import("../pages/workspace/dictionary"));
+const WorkspaceGrammarBook = lazy(
+  () => import("../pages/workspace/grammar-book"),
+);
+
+const dictionaryRoutes: RouteObject[] = [
+  {
+    path: "dictionary",
+    handle: { id: "workspace.dictionary", crumb: "dictionary" },
+    children: [
+      { index: true, Component: WorkspaceDictionary },
+      // 查词结果可重入:/workspace/dictionary/dhamma
+      {
+        path: ":word",
+        Component: WorkspaceDictionary,
+        handle: {
+          crumb: (match: UIMatch) => match.params.word ?? "",
+        },
+      },
+    ],
+  },
+  {
+    path: "grammar-book",
+    Component: WorkspaceGrammarBook,
+    handle: { id: "workspace.grammar-book", crumb: "grammar-book" },
+  },
+];
+
+export default dictionaryRoutes;