visuddhinanda 1 개월 전
부모
커밋
b8cfe1bca4
49개의 변경된 파일4631개의 추가작업 그리고 313개의 파일을 삭제
  1. 67 0
      dashboard-v6/src/api/dict.ts
  2. 51 37
      dashboard-v6/src/components/article/ArticleReader.tsx
  3. 1 1
      dashboard-v6/src/components/article/TypeArticle.tsx
  4. 5 1
      dashboard-v6/src/components/article/components/ArticleHeader.tsx
  5. 1 1
      dashboard-v6/src/components/attachment/AttachmentList.tsx
  6. 256 0
      dashboard-v6/src/components/dict/CaseList.tsx
  7. 352 0
      dashboard-v6/src/components/dict/Community.tsx
  8. 203 0
      dashboard-v6/src/components/dict/Compound.tsx
  9. 24 0
      dashboard-v6/src/components/dict/Confidence.tsx
  10. 41 0
      dashboard-v6/src/components/dict/DictComponent.tsx
  11. 40 0
      dashboard-v6/src/components/dict/DictConfidence.tsx
  12. 149 0
      dashboard-v6/src/components/dict/DictContent.tsx
  13. 43 0
      dashboard-v6/src/components/dict/DictCreate.tsx
  14. 71 0
      dashboard-v6/src/components/dict/DictEdit.tsx
  15. 119 0
      dashboard-v6/src/components/dict/DictEditInner.tsx
  16. 39 0
      dashboard-v6/src/components/dict/DictGroupTitle.tsx
  17. 61 0
      dashboard-v6/src/components/dict/DictInfoCopyRef.tsx
  18. 45 0
      dashboard-v6/src/components/dict/DictList.tsx
  19. 382 0
      dashboard-v6/src/components/dict/DictPreference.tsx
  20. 44 0
      dashboard-v6/src/components/dict/DictSearch.tsx
  21. 167 0
      dashboard-v6/src/components/dict/Dictionary.tsx
  22. 65 0
      dashboard-v6/src/components/dict/GrammarPop.tsx
  23. 314 0
      dashboard-v6/src/components/dict/MyCreate.tsx
  24. 6 6
      dashboard-v6/src/components/dict/SearchVocabulary.tsx
  25. 79 0
      dashboard-v6/src/components/dict/SelectCase.tsx
  26. 593 0
      dashboard-v6/src/components/dict/UserDictList.tsx
  27. 450 0
      dashboard-v6/src/components/dict/UserDictTable.tsx
  28. 91 0
      dashboard-v6/src/components/dict/WordCard.tsx
  29. 79 0
      dashboard-v6/src/components/dict/WordCardByDict.tsx
  30. 328 0
      dashboard-v6/src/components/dict/caseOptions.ts
  31. 76 0
      dashboard-v6/src/components/dict/hooks/useDict.ts
  32. 4 0
      dashboard-v6/src/components/dict/style.css
  33. 19 3
      dashboard-v6/src/components/dict/utils.ts
  34. 59 1
      dashboard-v6/src/components/general/SplitLayout/RightToolbar.module.css
  35. 81 17
      dashboard-v6/src/components/general/SplitLayout/RightToolbar.tsx
  36. 9 58
      dashboard-v6/src/components/general/SplitLayout/SplitLayout.module.css
  37. 58 100
      dashboard-v6/src/components/general/SplitLayout/SplitLayout.tsx
  38. 79 76
      dashboard-v6/src/components/general/SplitLayout/SplitLayoutTest.tsx
  39. 1 1
      dashboard-v6/src/components/wbw/WbwDetailFactor.tsx
  40. 1 1
      dashboard-v6/src/components/wbw/WbwFactorsEditor.tsx
  41. 1 1
      dashboard-v6/src/components/wbw/WbwLookup.tsx
  42. 1 1
      dashboard-v6/src/components/wbw/WbwParentEditor.tsx
  43. 1 1
      dashboard-v6/src/components/wbw/WbwSentCtl.tsx
  44. 1 1
      dashboard-v6/src/components/wbw/WbwWord.tsx
  45. 1 1
      dashboard-v6/src/components/wbw/utils.ts
  46. 63 1
      dashboard-v6/src/pages/workspace/article/show.tsx
  47. 1 1
      dashboard-v6/src/reducers/command.ts
  48. 1 1
      dashboard-v6/src/reducers/inline-dict.ts
  49. 8 2
      dashboard-v6/src/reducers/net-status.ts

+ 67 - 0
dashboard-v6/src/api/Dict.ts → dashboard-v6/src/api/dict.ts

@@ -1,3 +1,5 @@
+// dashboard-v6/src/api/Dict.ts
+
 import type { IStudio, IUser } from "./Auth";
 
 export interface IDict {
@@ -146,3 +148,68 @@ export interface IPreferenceResponse {
   message: string;
   data: IApiResponseDictData;
 }
+
+export interface IWordGrammar {
+  word: string;
+  type: string;
+  grammar: string;
+  parent: string;
+  factors: string;
+  confidence: number;
+}
+
+export interface IWordByDict {
+  dictname: string;
+  description?: string;
+  meta?: IDictInfo;
+  word?: string;
+  note?: string;
+  anchor: string;
+}
+export interface IDictInfo {
+  author: string;
+  publisher: string;
+  published?: string;
+  url: string;
+}
+
+export interface IWordCardData {
+  word: string;
+  factors: string;
+  parents: string;
+  case?: string[];
+  grammar: IWordGrammar[];
+  anchor: string;
+  dict: IWordByDict[];
+}
+
+export interface IDictWords {
+  pass: string;
+  words: IWordCardData[];
+}
+
+export interface IAnchorData {
+  href: string;
+  title: string;
+  children?: IAnchorData[];
+}
+
+export interface IDictContentData {
+  dictlist: IAnchorData[];
+  words: IDictWords[];
+  caselist: ICaseListData[];
+  time?: number;
+  count?: number;
+}
+export interface IApiDictContentData {
+  ok: boolean;
+  message: string;
+  data: IDictContentData;
+}
+
+import { get } from "../request";
+
+export const fetchDictByWord = (word: string): Promise<IApiDictContentData> => {
+  const url = `/api/v2/dict?word=${word}`;
+  return get<IApiDictContentData>(url);
+};

+ 51 - 37
dashboard-v6/src/components/article/ArticleReader.tsx

@@ -17,6 +17,7 @@ import ArticleLayout from "./components/ArticleLayout";
 import ArticleNavigation from "./components/ArticleNavigation";
 import TocPath from "../tipitaka/TocPath";
 import { useArticle } from "./hooks/useArticle";
+import ArticleHeader from "./components/ArticleHeader";
 
 interface IWidget {
   articleId?: string;
@@ -28,6 +29,7 @@ interface IWidget {
   hideInteractive?: boolean;
   hideTitle?: boolean;
   isSubWindow?: boolean;
+  headerExtra?: React.ReactNode;
   onArticleChange?: (type: ArticleType, id: string, target?: TTarget) => void;
   onAnthologySelect?: (
     id: string,
@@ -44,6 +46,7 @@ const ArticleReader = ({
   hideInteractive = false,
   hideTitle = false,
   isSubWindow = false,
+  headerExtra,
   onArticleChange,
   onAnthologySelect,
   onEdit,
@@ -86,44 +89,55 @@ const ArticleReader = ({
         <ErrorResult code={errorCode} />
       ) : (
         <>
-          <TypeArticleReaderToolbar
-            title={title}
-            articleId={articleId}
-            anthologyId={anthologyId}
-            role={articleData?.role}
-            isSubWindow={isSubWindow}
-            onRefresh={refresh}
-            onEdit={() => {
-              if (typeof onEdit !== "undefined") {
-                onEdit();
-              }
-            }}
-            onAnthologySelect={(
-              id: string,
-              e: React.MouseEvent<HTMLElement, MouseEvent>
-            ) => {
-              if (typeof onAnthologySelect !== "undefined") {
-                onAnthologySelect(id, e);
-              }
-            }}
-          />
-          <TocPath
-            data={articleData?.path}
-            channels={[]}
-            onChange={(node, e) => {
-              let newType: ArticleType = "article";
-              if (node.level === 0) {
-                newType = "anthology";
-              }
-              if (typeof onArticleChange !== "undefined") {
-                if (node.key) {
-                  const newArticleId = node.key;
-                  const target = e.ctrlKey || e.metaKey ? "_blank" : "_self";
-                  onArticleChange(newType, newArticleId, target);
-                }
-              }
-            }}
+          <ArticleHeader
+            header={
+              <Space>
+                {headerExtra}
+                <TocPath
+                  data={articleData?.path}
+                  channels={[]}
+                  onChange={(node, e) => {
+                    let newType: ArticleType = "article";
+                    if (node.level === 0) {
+                      newType = "anthology";
+                    }
+                    if (typeof onArticleChange !== "undefined") {
+                      if (node.key) {
+                        const newArticleId = node.key;
+                        const target =
+                          e.ctrlKey || e.metaKey ? "_blank" : "_self";
+                        onArticleChange(newType, newArticleId, target);
+                      }
+                    }
+                  }}
+                />
+              </Space>
+            }
+            action={
+              <TypeArticleReaderToolbar
+                title={title}
+                articleId={articleId}
+                anthologyId={anthologyId}
+                role={articleData?.role}
+                isSubWindow={isSubWindow}
+                onRefresh={refresh}
+                onEdit={() => {
+                  if (typeof onEdit !== "undefined") {
+                    onEdit();
+                  }
+                }}
+                onAnthologySelect={(
+                  id: string,
+                  e: React.MouseEvent<HTMLElement, MouseEvent>
+                ) => {
+                  if (typeof onAnthologySelect !== "undefined") {
+                    onAnthologySelect(id, e);
+                  }
+                }}
+              />
+            }
           />
+
           <ArticleLayout
             title={title}
             subTitle={articleData?.subtitle}

+ 1 - 1
dashboard-v6/src/components/article/TypeArticle.tsx

@@ -48,7 +48,6 @@ const TypeArticle = ({
   const [edit, setEdit] = useState(false);
   return (
     <div>
-      {headerExtra}
       {edit ? (
         <ArticleEdit
           anthologyId={anthologyId ? anthologyId : undefined}
@@ -82,6 +81,7 @@ const TypeArticle = ({
           active={active}
           hideInteractive={hideInteractive}
           hideTitle={hideTitle}
+          headerExtra={headerExtra}
           onArticleChange={onArticleChange}
           onAnthologySelect={(
             id: string,

+ 5 - 1
dashboard-v6/src/components/article/components/ArticleHeader.tsx

@@ -1,4 +1,5 @@
 import type React from "react";
+import styles from "../../general/SplitLayout/SplitLayout.module.css";
 
 interface IWidget {
   header?: React.ReactNode;
@@ -6,7 +7,10 @@ interface IWidget {
 }
 const ArticleHeader = ({ header, action }: IWidget) => {
   return (
-    <div style={{ display: "flex", justifyContent: "space-between" }}>
+    <div
+      className={styles.sidebarHeader}
+      style={{ display: "flex", justifyContent: "space-between" }}
+    >
       <div>{header}</div>
       {/**action */}
       <div>{action}</div>

+ 1 - 1
dashboard-v6/src/components/attachment/AttachmentList.tsx

@@ -23,7 +23,7 @@ import {
 
 import { type ActionType, ProList } from "@ant-design/pro-components";
 
-import type { IUserDictDeleteRequest } from "../../api/Dict";
+import type { IUserDictDeleteRequest } from "../../api/dict";
 import { delete_2, get, put } from "../../request";
 import { useRef, useState } from "react";
 import type { IDeleteResponse } from "../../api/Article";

+ 256 - 0
dashboard-v6/src/components/dict/CaseList.tsx

@@ -0,0 +1,256 @@
+import { Badge, Button, Card, Checkbox, Select, Space, Typography } from "antd";
+import { DownOutlined, UpOutlined } from "@ant-design/icons";
+import { useEffect, useMemo, useReducer, useRef, useState } from "react";
+
+import { get } from "../../request";
+import type {
+  ICaseItem,
+  ICaseListData,
+  ICaseListResponse,
+} from "../../api/dict";
+import type { CheckboxChangeEvent } from "antd/es/checkbox";
+
+const { Text } = Typography;
+
+interface IWidget {
+  word?: string;
+  lines?: number;
+  onChange?: (checkedList: string[]) => void;
+}
+// TODO 移除复杂的状态管理
+// ---------------------------------------------------------------------------
+// State shape + reducer — all mutations go through dispatch, never setState
+// ---------------------------------------------------------------------------
+interface State {
+  words: ICaseItem[];
+  currWord: string | undefined;
+  checkedList: string[];
+}
+
+type Action =
+  | {
+      type: "FETCHED";
+      words: ICaseItem[];
+      currWord: string;
+      checkedList: string[];
+    }
+  | { type: "SELECT_WORD"; currWord: string; checkedList: string[] }
+  | { type: "SET_CHECKED"; checkedList: string[] };
+
+const INITIAL_STATE: State = {
+  words: [],
+  currWord: undefined,
+  checkedList: [],
+};
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case "FETCHED":
+      return {
+        words: action.words,
+        currWord: action.currWord,
+        checkedList: action.checkedList,
+      };
+    case "SELECT_WORD":
+      return {
+        ...state,
+        currWord: action.currWord,
+        checkedList: action.checkedList,
+      };
+    case "SET_CHECKED":
+      return { ...state, checkedList: action.checkedList };
+    default:
+      return state;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+function sortedCases(cases: ICaseListData[]): ICaseListData[] {
+  return cases.slice().sort((a, b) => b.count - a.count);
+}
+
+function allWords(cases: ICaseListData[]): string[] {
+  return sortedCases(cases).map((c) => c.word);
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+const CaseListWidget = ({ word, lines, onChange }: IWidget) => {
+  const [showAll, setShowAll] = useState(!lines);
+  const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
+
+  // Keep a stable ref to onChange — avoids adding it to effect deps
+  const onChangeRef = useRef(onChange);
+  useEffect(() => {
+    onChangeRef.current = onChange;
+  });
+
+  // -------------------------------------------------------------------------
+  // Determine whether the word prop is usable (no spaces)
+  // This is pure computation — no effect needed.
+  // When word has spaces we simply treat state as empty during render.
+  // -------------------------------------------------------------------------
+  const isMultiWord = typeof word === "string" && word.trim().includes(" ");
+
+  const { words, currWord, checkedList } = isMultiWord ? INITIAL_STATE : state;
+
+  // Derive caseData in render — no state, no effect
+  const caseData: ICaseListData[] | undefined = useMemo(
+    () =>
+      currWord
+        ? words
+            .find((item) => item.word === currWord)
+            ?.case.slice()
+            .sort((a, b) => b.count - a.count)
+        : undefined,
+    [words, currWord]
+  );
+
+  // -------------------------------------------------------------------------
+  // Fetch effect — dispatch only happens inside the async .then() callback,
+  // which is NOT synchronous within the effect body → lint rule satisfied.
+  // -------------------------------------------------------------------------
+  useEffect(() => {
+    if (typeof word === "undefined" || word.trim().includes(" ")) return;
+
+    let cancelled = false;
+
+    get<ICaseListResponse>(`/api/v2/case/${word}`).then((json) => {
+      if (cancelled) return;
+      if (json.ok && json.data.rows.length > 0) {
+        const sorted = json.data.rows.slice().sort((a, b) => b.count - a.count);
+        dispatch({
+          type: "FETCHED",
+          words: sorted,
+          currWord: sorted[0].word,
+          checkedList: allWords(sorted[0].case),
+        });
+      }
+    });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [word]);
+
+  // -------------------------------------------------------------------------
+  // Notify parent — only dispatches to onChangeRef (external system), no setState
+  // -------------------------------------------------------------------------
+  const prevCheckedRef = useRef<string[]>([]);
+  useEffect(() => {
+    if (checkedList.length > 0 && checkedList !== prevCheckedRef.current) {
+      onChangeRef.current?.(checkedList);
+    }
+    prevCheckedRef.current = checkedList;
+  }, [checkedList]);
+
+  // -------------------------------------------------------------------------
+  // Event handlers — all state changes happen here, never inside effects
+  // -------------------------------------------------------------------------
+  const handleWordChange = (value: string) => {
+    const cases = words.find((item) => item.word === value)?.case ?? [];
+    dispatch({
+      type: "SELECT_WORD",
+      currWord: value,
+      checkedList: allWords(cases),
+    });
+  };
+
+  const handleCheckAll = (e: CheckboxChangeEvent) => {
+    dispatch({
+      type: "SET_CHECKED",
+      checkedList: e.target.checked
+        ? (caseData?.map((item) => item.word) ?? [])
+        : [],
+    });
+  };
+
+  const handleCheckedChange = (list: string[]) => {
+    dispatch({ type: "SET_CHECKED", checkedList: list as string[] });
+  };
+
+  // -------------------------------------------------------------------------
+  // Derived display values
+  // -------------------------------------------------------------------------
+  const checkAll = !!caseData && caseData.length === checkedList.length;
+  const indeterminate =
+    !!caseData &&
+    checkedList.length > 0 &&
+    checkedList.length < caseData.length;
+  const showWords = showAll ? caseData : caseData?.slice(0, lines);
+
+  return (
+    <div style={{ padding: 4 }}>
+      {currWord ? (
+        <Card
+          size="small"
+          extra={
+            lines ? (
+              <Button type="link" onClick={() => setShowAll((prev) => !prev)}>
+                <Space>
+                  {showAll ? "折叠" : "展开"}
+                  {showAll ? <UpOutlined /> : <DownOutlined />}
+                </Space>
+              </Button>
+            ) : null
+          }
+          title={
+            <Select
+              value={currWord}
+              variant="borderless"
+              onChange={handleWordChange}
+              options={words.map((item) => ({
+                label: (
+                  <Space>
+                    {item.word}
+                    <Badge
+                      count={item.count}
+                      color="lime"
+                      status="default"
+                      size="small"
+                    />
+                  </Space>
+                ),
+                value: item.word,
+              }))}
+            />
+          }
+        >
+          <Checkbox
+            indeterminate={indeterminate}
+            onChange={handleCheckAll}
+            checked={checkAll}
+          >
+            Check all
+          </Checkbox>
+          <Checkbox.Group
+            style={{ display: "grid" }}
+            options={showWords?.map((item) => ({
+              label: (
+                <Space>
+                  <Text strong={item.bold > 0}>{item.word}</Text>
+                  <Badge
+                    size="small"
+                    count={item.count}
+                    overflowCount={9999}
+                    status="default"
+                  />
+                </Space>
+              ),
+              value: item.word,
+            }))}
+            value={checkedList}
+            onChange={(list) => handleCheckedChange(list as string[])}
+          />
+        </Card>
+      ) : (
+        <Text>多词搜索没有变格词表</Text>
+      )}
+    </div>
+  );
+};
+
+export default CaseListWidget;

+ 352 - 0
dashboard-v6/src/components/dict/Community.tsx

@@ -0,0 +1,352 @@
+import {
+  Badge,
+  Button,
+  Card,
+  Dropdown,
+  type MenuProps,
+  Popover,
+  Skeleton,
+  Space,
+  Typography,
+} from "antd";
+import { DownOutlined } from "@ant-design/icons";
+import { useState, useEffect, useCallback } from "react";
+import { useIntl } from "react-intl";
+import { get } from "../../request";
+import type { IApiResponseDictList } from "../../api/dict";
+import GrammarPop from "./GrammarPop";
+import MyCreate from "./MyCreate";
+import type { IUser } from "../../api/Auth";
+import MdView from "../general/MdView";
+
+const { Title, Link, Text } = Typography;
+
+interface IItem<R> {
+  value: R;
+  score: number;
+}
+interface IWord {
+  grammar: IItem<string>[];
+  parent: IItem<string>[];
+  note: IItem<string>[];
+  meaning: IItem<string>[];
+  factors: IItem<string>[];
+  editor: IItem<IUser>[];
+}
+
+interface IWidget {
+  word: string | undefined;
+}
+const CommunityWidget = ({ word }: IWidget) => {
+  const intl = useIntl();
+  const [loaded, setLoaded] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const [wordData, setWordData] = useState<IWord>();
+  const [showCreate, setShowCreate] = useState(false);
+
+  const minScore = 100; //分数阈值。低于这个分数只显示在弹出菜单中
+
+  const dictLoad = useCallback(async (input: string) => {
+    setLoading(true);
+    const url = `/api/v2/userdict?view=community&word=${input}`;
+    console.info("dict community url", url);
+    try {
+      const json = await get<IApiResponseDictList>(url);
+      if (json.ok === false) {
+        console.log("dict community", json.message);
+        return;
+      }
+      console.debug("dict community", json.data);
+      const meaning = new Map<string, number>();
+      const grammar = new Map<string, number>();
+      const parent = new Map<string, number>();
+      const note = new Map<string, number>();
+      const editorId = new Map<string, number>();
+      const editor = new Map<string, IUser>();
+      for (const it of json.data.rows) {
+        let score: number | undefined;
+        if (it.exp) {
+          //分数计算
+          let conf = it.confidence / 100;
+          if (it.confidence <= 1) {
+            conf = 1;
+          }
+          const currScore = Math.floor((it.exp / 3600) * conf);
+          if (it.mean) {
+            score = meaning.get(it.mean);
+            meaning.set(it.mean, score ? score + currScore : currScore);
+          }
+
+          if (it.type || it.grammar) {
+            const strCase = it.type + "$" + it.grammar;
+            score = grammar.get(strCase);
+            grammar.set(strCase, score ? score + currScore : currScore);
+          }
+          if (it.parent) {
+            score = parent.get(it.parent);
+            parent.set(it.parent, score ? score + currScore : currScore);
+          }
+
+          if (it.note) {
+            score = note.get(it.note);
+            note.set(it.note, score ? score + currScore : currScore);
+          }
+
+          if (it.editor) {
+            score = editorId.get(it.editor.id);
+            editorId.set(it.editor.id, score ? score + currScore : currScore);
+            editor.set(it.editor.id, it.editor);
+          }
+        }
+      }
+      const _data: IWord = {
+        grammar: [],
+        parent: [],
+        note: [],
+        meaning: [],
+        factors: [],
+        editor: [],
+      };
+      meaning.forEach((value, key) => {
+        if (key && key.length > 0) {
+          _data.meaning.push({ value: key, score: value });
+        }
+      });
+      _data.meaning.sort((a, b) => b.score - a.score);
+      grammar.forEach((value, key) => {
+        if (key && key.length > 0) {
+          _data.grammar.push({ value: key, score: value });
+        }
+      });
+      _data.grammar.sort((a, b) => b.score - a.score);
+
+      parent.forEach((value, key) => {
+        if (key && key.length > 0) {
+          _data.parent.push({ value: key, score: value });
+        }
+      });
+      _data.parent.sort((a, b) => b.score - a.score);
+
+      note.forEach((value, key) => {
+        if (key && key.length > 0) {
+          _data.note.push({ value: key, score: value });
+        }
+      });
+      _data.note.sort((a, b) => b.score - a.score);
+
+      editorId.forEach((value, key) => {
+        const currEditor = editor.get(key);
+        if (currEditor) {
+          _data.editor.push({ value: currEditor, score: value });
+        }
+      });
+      _data.editor.sort((a, b) => b.score - a.score);
+      setWordData(_data);
+      setLoaded(_data.editor.length > 0);
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    if (typeof word === "undefined") {
+      return;
+    }
+    void dictLoad(word);
+  }, [word, dictLoad]);
+
+  const isShow = (score: number, index: number) => {
+    const Ms = 500,
+      Rd = 5,
+      minScore = 15;
+    const minOrder = Math.log(score) / Math.log(Math.pow(Ms, 1 / Rd));
+    if (index < minOrder && score > minScore) {
+      return true;
+    } else {
+      return false;
+    }
+  };
+
+  const meaningLow = wordData?.meaning.filter(
+    (value, index: number) => !isShow(value.score, index)
+  );
+  const meaningExtra = meaningLow?.map((item, id) => {
+    return <span key={id}>{item.value}</span>;
+  });
+
+  const mainCollaboratorNum = 3; //默认显示的协作者数量,其余的在更多中显示
+  const collaboratorRender = (name: string, id: number, score: number) => {
+    return (
+      <Space key={id}>
+        {name}
+        <Badge color="geekblue" size="small" count={score} />
+      </Space>
+    );
+  };
+  const items: MenuProps["items"] = wordData?.editor
+    .filter((_value, index) => index >= mainCollaboratorNum)
+    .map((item, id) => {
+      return {
+        key: id,
+        label: collaboratorRender(item.value.nickName, id, item.score),
+      };
+    });
+  const more = wordData ? (
+    wordData.editor.length > mainCollaboratorNum ? (
+      <Dropdown menu={{ items }}>
+        <Link>
+          <Space>
+            {intl.formatMessage({
+              id: `buttons.more`,
+            })}
+            <DownOutlined />
+          </Space>
+        </Link>
+      </Dropdown>
+    ) : undefined
+  ) : undefined;
+
+  return (
+    <Card>
+      <Title level={5} id={`community`}>
+        {"社区字典"}
+      </Title>
+      {loading ? (
+        <Skeleton />
+      ) : loaded ? (
+        <div>
+          <div key="meaning">
+            <Space style={{ flexWrap: "wrap" }}>
+              <Text strong>{"意思:"}</Text>
+              {wordData?.meaning
+                .filter((value, index: number) => isShow(value.score, index))
+                .map((item, id) => {
+                  return (
+                    <Space key={id}>
+                      {item.value}
+                      <Badge color="geekblue" size="small" count={item.score} />
+                    </Space>
+                  );
+                })}
+              {meaningLow && meaningLow.length > 0 ? (
+                <Popover
+                  content={<Space>{meaningExtra}</Space>}
+                  placement="bottom"
+                >
+                  <Link>
+                    <Space>
+                      {intl.formatMessage({
+                        id: `buttons.more`,
+                      })}
+                      <DownOutlined />
+                    </Space>
+                  </Link>
+                </Popover>
+              ) : undefined}
+            </Space>
+          </div>
+          <div key="grammar">
+            <Space style={{ flexWrap: "wrap" }}>
+              <Text strong>{"语法:"}</Text>
+              {wordData?.grammar
+                .filter((value) => value.score >= minScore)
+                .map((item, id) => {
+                  const grammar = item.value.split("$");
+                  const grammarGuide = grammar.map((item, id) => {
+                    const strCase = item.replaceAll(".", "");
+
+                    return strCase.length > 0 ? (
+                      <GrammarPop
+                        key={id}
+                        gid={strCase}
+                        text={intl.formatMessage({
+                          id: `dict.fields.type.${strCase}.label`,
+                          defaultMessage: strCase,
+                        })}
+                      />
+                    ) : undefined;
+                  });
+                  return (
+                    <Space key={id}>
+                      <Space
+                        style={{
+                          backgroundColor: "rgba(0.5,0.5,0.5,0.2)",
+                          borderRadius: 5,
+                          paddingLeft: 5,
+                          paddingRight: 5,
+                        }}
+                      >
+                        {grammarGuide}
+                      </Space>
+                      <Badge color="geekblue" size="small" count={item.score} />
+                    </Space>
+                  );
+                })}
+            </Space>
+          </div>
+          <div key="base">
+            <Space style={{ flexWrap: "wrap" }}>
+              <Text strong>{"词干:"}</Text>
+              {wordData?.parent
+                .filter((value) => value.score >= minScore)
+                .map((item, id) => {
+                  return (
+                    <Space key={id}>
+                      {item.value}
+                      <Badge color="geekblue" size="small" count={item.score} />
+                    </Space>
+                  );
+                })}
+            </Space>
+          </div>
+          <div key="collaborator">
+            <Space style={{ flexWrap: "wrap" }}>
+              <Text strong>{"贡献者:"}</Text>
+              {wordData?.editor
+                .filter((_value, index) => index < mainCollaboratorNum)
+                .map((item, id) => {
+                  return collaboratorRender(
+                    item.value.nickName,
+                    id,
+                    item.score
+                  );
+                })}
+              {more}
+            </Space>
+          </div>
+
+          <div key="note">
+            <Text strong>{"注释:"}</Text>
+            <div>
+              {wordData?.note
+                .filter((value) => value.score >= minScore)
+                .slice(0, 1)
+                .map((item, id) => {
+                  return <MdView html={item.value} key={id} />;
+                })}
+            </div>
+          </div>
+        </div>
+      ) : showCreate ? (
+        <MyCreate
+          word={word}
+          onSave={() => {
+            if (word) {
+              dictLoad(word);
+            }
+          }}
+        />
+      ) : (
+        <>
+          <Button type="link" onClick={() => setShowCreate(true)}>
+            新建
+          </Button>
+        </>
+      )}
+    </Card>
+  );
+};
+
+export default CommunityWidget;

+ 203 - 0
dashboard-v6/src/components/dict/Compound.tsx

@@ -0,0 +1,203 @@
+import { List, Select, Typography } from "antd";
+import { useEffect, useState } from "react";
+import { TeamOutlined, RobotOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+import type {
+  IApiResponseDictList,
+  IDictFirstMeaningResponse,
+  IFirstMeaning,
+} from "../../api/dict";
+
+const { Text, Link } = Typography;
+
+interface IFactorInfo {
+  factors: string;
+  type: string;
+  confidence: number;
+}
+interface IOptions {
+  value: string;
+  label: React.ReactNode;
+}
+interface IWidget {
+  word?: string;
+  add?: string;
+  split?: string;
+  onSearch?: (word: string, update?: boolean) => void;
+}
+
+const CompoundWidget = ({ word, add, onSearch }: IWidget) => {
+  // compound 列表:由 word 异步拉取后更新
+  const [compound, setCompound] = useState<IOptions[]>([]);
+  // 用户手动选中的值(undefined 表示尚未手动选择,跟随自动推导)
+  const [manualValue, setManualValue] = useState<string | undefined>(undefined);
+  // 当前 word 的快照,用于判断 word 是否变化从而重置手动选择
+  const [prevWord, setPrevWord] = useState<string | undefined>(word);
+  const [meaningData, setMeaningData] = useState<IFirstMeaning[] | undefined>(
+    undefined
+  );
+
+  // ── 派生 factors ──────────────────────────────────────────────
+  const factors: IOptions[] =
+    typeof add === "undefined"
+      ? compound
+      : [{ value: add, label: add }, ...compound];
+
+  // ── 派生 currValue ────────────────────────────────────────────
+  // 优先级:手动选中 > add prop > compound 第一项
+  const defaultValue =
+    typeof add !== "undefined"
+      ? add
+      : compound.length > 0
+        ? compound[0].value
+        : undefined;
+  const currValue = manualValue ?? defaultValue;
+
+  // ── word 变化时重置手动选择(render-phase derived state 模式)──
+  if (word !== prevWord) {
+    setPrevWord(word);
+    setManualValue(undefined);
+    setCompound([]);
+    setMeaningData(undefined);
+  }
+
+  // ── 用户主动切换下拉 ──────────────────────────────────────────
+  const onSelectChange = (value?: string) => {
+    setManualValue(value);
+    if (typeof value === "undefined") {
+      setMeaningData(undefined);
+      return;
+    }
+    const url =
+      `/api/v2/dict-meaning?lang=zh-Hans&word=` + value.replaceAll("+", "-");
+    console.info("dict compound url", url);
+    get<IDictFirstMeaningResponse>(url).then((json) => {
+      if (json.ok) {
+        setMeaningData(json.data);
+      }
+    });
+  };
+
+  // ── currValue 变化时异步拉取释义(仅异步回调中 setState)────────
+  useEffect(() => {
+    if (typeof currValue === "undefined") {
+      return;
+    }
+    const url =
+      `/api/v2/dict-meaning?lang=zh-Hans&word=` +
+      currValue.replaceAll("+", "-");
+    console.info("dict compound url (auto)", url);
+    let cancelled = false;
+    get<IDictFirstMeaningResponse>(url).then((json) => {
+      if (!cancelled && json.ok) {
+        setMeaningData(json.data); // ✅ 异步回调内 setState,合规
+      }
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, [currValue]);
+
+  // ── word 变化时拉取 compound 列表(仅异步回调中 setState)───────
+  useEffect(() => {
+    if (typeof word === "undefined") {
+      return; // reset 已在 render phase 处理
+    }
+    const url = `/api/v2/userdict?view=word&word=${word}`;
+    console.info("dict compound url", url);
+    let cancelled = false;
+    get<IApiResponseDictList>(url).then((json) => {
+      if (cancelled || !json.ok) return;
+
+      const factorMap = new Map<string, IFactorInfo>();
+      json.data.rows
+        .filter((row) => typeof row.factors === "string")
+        .forEach((row) => {
+          let type = "";
+          if (row.source?.includes("_USER")) type = "user";
+          if (row.type === ".cp.") type = "robot";
+          if (row.factors) {
+            factorMap.set(row.factors, {
+              factors: row.factors,
+              type,
+              confidence: row.confidence,
+            });
+          }
+        });
+
+      const arrFactors: IFactorInfo[] = [];
+      factorMap.forEach((v) => arrFactors.push(v));
+      arrFactors.sort((a, b) => b.confidence - a.confidence);
+
+      setCompound(
+        // ✅ 异步回调内 setState,合规
+        arrFactors.map((item) => ({
+          value: item.factors,
+          label: (
+            <div style={{ display: "flex", justifyContent: "space-between" }}>
+              {item.factors}
+              {item.type === "user" ? (
+                <TeamOutlined />
+              ) : item.type === "robot" ? (
+                <RobotOutlined />
+              ) : (
+                <></>
+              )}
+            </div>
+          ),
+        }))
+      );
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, [word]);
+
+  return (
+    <div
+      className="dict_compound_div"
+      style={{
+        width: "100%",
+        maxWidth: 560,
+        marginLeft: "auto",
+        marginRight: "auto",
+      }}
+    >
+      <Select
+        getPopupContainer={() =>
+          document.getElementsByClassName("dict_compound_div")[0] as HTMLElement
+        }
+        value={currValue}
+        style={{ width: "100%" }}
+        onChange={onSelectChange}
+        options={factors}
+      />
+      {meaningData && meaningData.length > 0 ? (
+        <List
+          size="small"
+          dataSource={meaningData}
+          renderItem={(item) => (
+            <List.Item>
+              <div>
+                <Link
+                  strong
+                  onClick={() => {
+                    if (item.word) {
+                      onSearch?.(item.word, true);
+                    }
+                  }}
+                >
+                  {item.word}
+                </Link>{" "}
+                <Text type="secondary">{item.meaning}</Text>
+              </div>
+            </List.Item>
+          )}
+        />
+      ) : undefined}
+    </div>
+  );
+};
+
+export default CompoundWidget;

+ 24 - 0
dashboard-v6/src/components/dict/Confidence.tsx

@@ -0,0 +1,24 @@
+import { useIntl } from "react-intl";
+import { ProFormSlider } from "@ant-design/pro-components";
+import type { SliderMarks } from "antd/es/slider";
+
+const ConfidenceWidget = () => {
+  const intl = useIntl();
+  const marks: SliderMarks = {
+    0: intl.formatMessage({ id: "forms.fields.confidence.0.label" }),
+    25: intl.formatMessage({ id: "forms.fields.confidence.25.label" }),
+    50: intl.formatMessage({ id: "forms.fields.confidence.50.label" }),
+    75: intl.formatMessage({ id: "forms.fields.confidence.75.label" }),
+    100: intl.formatMessage({ id: "forms.fields.confidence.100.label" }),
+  };
+  return (
+    <ProFormSlider
+      name="confidence"
+      label={intl.formatMessage({ id: "forms.fields.confidence.label" })}
+      width="xl"
+      marks={marks}
+    />
+  );
+};
+
+export default ConfidenceWidget;

+ 41 - 0
dashboard-v6/src/components/dict/DictComponent.tsx

@@ -0,0 +1,41 @@
+import { useEffect } from "react";
+
+import { useAppSelector } from "../../hooks";
+import { lookup, lookupWord, myDictIsDirty } from "../../reducers/command";
+import store from "../../store";
+
+import { notification } from "antd";
+import Dictionary from "./Dictionary";
+
+export interface IWidgetDict {
+  word?: string;
+}
+
+const DictComponentWidget = ({ word }: IWidgetDict) => {
+  const search = useAppSelector(lookupWord);
+  const myDictDirty = useAppSelector(myDictIsDirty);
+
+  useEffect(() => {
+    if (myDictDirty) {
+      notification.warning({
+        message: "用户词典有未保存内容,请保存后再查词",
+      });
+    }
+  }, [myDictDirty]);
+
+  // 直接从 redux state 派生展示的词,无需本地 state
+  const wordSearch = typeof search === "string" && !myDictDirty ? search : word;
+
+  return (
+    <Dictionary
+      word={wordSearch}
+      compact={true}
+      onSearch={(value) => {
+        console.debug("onSearch", value);
+        store.dispatch(lookup(value));
+      }}
+    />
+  );
+};
+
+export default DictComponentWidget;

+ 40 - 0
dashboard-v6/src/components/dict/DictConfidence.tsx

@@ -0,0 +1,40 @@
+import { Dropdown, Progress, Space } from "antd";
+import { useState } from "react";
+import { LoadingOutlined } from "@ant-design/icons";
+
+interface IWidget {
+  value?: number;
+  onChange?: (value: number) => void;
+}
+const DictConfidence = ({ value, onChange }: IWidget) => {
+  const [loading, setLoading] = useState(false);
+
+  const confidence = [0, 40, 60, 80, 100];
+  return (
+    <Space>
+      {loading ? <LoadingOutlined /> : <></>}
+      <div style={{ width: 100 }}>
+        <Dropdown
+          menu={{
+            items: confidence.map((item) => {
+              return { key: item, label: item };
+            }),
+            onClick: async (info) => {
+              setLoading(true);
+              onChange?.(parseInt(info.key));
+              setLoading(false);
+            },
+          }}
+        >
+          <Progress
+            size="small"
+            percent={Math.round(value ?? 0)}
+            status={value !== undefined && value < 50 ? "exception" : undefined}
+          />
+        </Dropdown>
+      </div>
+    </Space>
+  );
+};
+
+export default DictConfidence;

+ 149 - 0
dashboard-v6/src/components/dict/DictContent.tsx

@@ -0,0 +1,149 @@
+import { Col, Divider, Row, Tabs } from "antd";
+
+import WordCard from "./WordCard";
+import CaseList from "./CaseList";
+import DictList from "./DictList";
+import MyCreate from "./MyCreate";
+import { useIntl } from "react-intl";
+import DictGroupTitle from "./DictGroupTitle";
+import UserDictList from "./UserDictList";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { useState } from "react";
+import type { IDictContentData } from "../../api/dict";
+
+interface IMyDict {
+  word?: string;
+}
+const MyDict = ({ word }: IMyDict) => {
+  const user = useAppSelector(currentUser);
+  const [myTab, setMyTab] = useState<string>("list");
+  const [myRefresh, setMyRefresh] = useState(false);
+  return (
+    <div>
+      <Tabs
+        size="small"
+        type="card"
+        style={{ backgroundColor: "white" }}
+        activeKey={myTab}
+        onChange={(activeKey: string) => setMyTab(activeKey)}
+        items={[
+          {
+            label: "列表",
+            key: "list",
+            children: (
+              <UserDictList
+                studioName={user?.realName}
+                word={word}
+                compact={true}
+                refresh={myRefresh}
+                onRefresh={(value: boolean) => setMyRefresh(value)}
+              />
+            ),
+          },
+          {
+            label: "新建",
+            key: "new",
+            children: (
+              <MyCreate
+                word={word}
+                onSave={() => {
+                  setMyRefresh(true);
+                  setMyTab("list");
+                }}
+              />
+            ),
+          },
+        ]}
+      />
+    </div>
+  );
+};
+
+interface IWidget {
+  word?: string;
+  data: IDictContentData;
+  compact?: boolean;
+}
+
+const DictContentWidget = ({ word, data, compact }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <>
+      <Row>
+        <Col flex="200px">
+          {compact ? <></> : <DictList data={data.dictlist} />}
+        </Col>
+        <Col flex="760px">
+          <Tabs
+            size="small"
+            items={[
+              {
+                label: `查询结果`,
+                key: "result",
+                children: (
+                  <div>
+                    <div>
+                      {intl.formatMessage(
+                        {
+                          id: "message.result",
+                        },
+                        { count: data.count }
+                      )}
+                      {" ("}
+                      {intl.formatMessage(
+                        {
+                          id: "message.time",
+                        },
+                        { time: data.time?.toFixed(3) }
+                      )}
+                      {")"}
+                    </div>
+                    <div>
+                      {data.words.map((it) => {
+                        return (
+                          <div>
+                            <DictGroupTitle
+                              title={
+                                <Divider>
+                                  {intl.formatMessage({
+                                    id: `labels.dict.pass.${it.pass}`,
+                                  })}
+                                </Divider>
+                              }
+                              path={[
+                                intl.formatMessage({
+                                  id: `labels.dict.pass.${it.pass}`,
+                                }),
+                              ]}
+                            />
+                            <div>
+                              {it.words.map((word, index) => (
+                                <WordCard key={index} data={word} />
+                              ))}
+                            </div>
+                          </div>
+                        );
+                      })}
+                    </div>
+                  </div>
+                ),
+              },
+              {
+                label: `单词本`,
+                key: "my",
+                children: <MyDict word={word} />,
+              },
+            ]}
+          />
+        </Col>
+        <Col flex="200px">
+          <CaseList word={word} />
+        </Col>
+      </Row>
+    </>
+  );
+};
+
+export default DictContentWidget;

+ 43 - 0
dashboard-v6/src/components/dict/DictCreate.tsx

@@ -0,0 +1,43 @@
+import { useIntl } from "react-intl";
+import { ProForm } from "@ant-design/pro-components";
+import { message } from "antd";
+
+import DictEditInner from "./DictEditInner";
+
+export interface IDictFormData {
+  id: number;
+  word: string;
+  type?: string | null;
+  grammar?: string | null;
+  parent?: string | null;
+  meaning?: string | null;
+  note?: string | null;
+  factors?: string | null;
+  factormeaning?: string | null;
+  lang: string;
+  confidence: number;
+}
+
+type IWidgetDictCreate = {
+  studio: string;
+  word?: string;
+};
+const DictCreateWidget = (prop: IWidgetDictCreate) => {
+  const intl = useIntl();
+
+  return (
+    <>
+      <ProForm<IDictFormData>
+        onFinish={async (values: IDictFormData) => {
+          // TODO 是否要删掉?
+          console.log(values);
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+        }}
+      >
+        <DictEditInner word={prop.word} />
+      </ProForm>
+    </>
+  );
+};
+
+export default DictCreateWidget;

+ 71 - 0
dashboard-v6/src/components/dict/DictEdit.tsx

@@ -0,0 +1,71 @@
+import { useIntl } from "react-intl";
+import { ProForm } from "@ant-design/pro-components";
+import { message } from "antd";
+
+import type { IApiResponseDict, IDictRequest } from "../../api/dict";
+import { get, put } from "../../request";
+
+import DictEditInner from "./DictEditInner";
+import type { IDictFormData } from "./DictCreate";
+
+interface IWidget {
+  wordId?: string;
+}
+const DictEditWidget = ({ wordId }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <>
+      <ProForm<IDictFormData>
+        onFinish={async (values: IDictFormData) => {
+          console.log(values);
+          const request: IDictRequest = {
+            id: values.id,
+            word: values.word,
+            type: values.type,
+            grammar: values.grammar,
+            mean: values.meaning,
+            parent: values.parent,
+            note: values.note,
+            factors: values.factors,
+            factormean: values.factormeaning,
+            language: values.lang,
+            confidence: values.confidence,
+          };
+          const res = await put<IDictRequest, IApiResponseDict>(
+            `/api/v2/userdict/${wordId}`,
+            request
+          );
+          console.log(res);
+          if (res.ok) {
+            message.success(intl.formatMessage({ id: "flashes.success" }));
+          } else {
+            message.success(res.message);
+          }
+        }}
+        formKey="dict_edit"
+        request={async () => {
+          const res: IApiResponseDict = await get(`/api/v2/userdict/${wordId}`);
+          return {
+            id: 1,
+            wordId: res.data.id,
+            word: res.data.word,
+            type: res.data.type,
+            grammar: res.data.grammar,
+            parent: res.data.parent,
+            meaning: res.data.mean,
+            note: res.data.note,
+            factors: res.data.factors,
+            factormeaning: res.data.factormean,
+            lang: res.data.language,
+            confidence: res.data.confidence,
+          };
+        }}
+      >
+        <DictEditInner />
+      </ProForm>
+    </>
+  );
+};
+
+export default DictEditWidget;

+ 119 - 0
dashboard-v6/src/components/dict/DictEditInner.tsx

@@ -0,0 +1,119 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+
+import LangSelect from "../general/LangSelect";
+import SelectCase from "./SelectCase";
+import Confidence from "./Confidence";
+
+type IWidgetDictCreate = {
+  word?: string;
+};
+const DictEditInnerWidget = (prop: IWidgetDictCreate) => {
+  const intl = useIntl();
+  /*
+	const onLangChange = (value: string) => {
+		console.log(`selected ${value}`);
+	};
+
+	const onLangSearch = (value: string) => {
+		console.log("search:", value);
+	};
+	*/
+  return (
+    <>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="word"
+          initialValue={prop.word}
+          required
+          label={intl.formatMessage({ id: "dict.fields.word.label" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <div>语法信息</div>
+        <SelectCase />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="type"
+          label={intl.formatMessage({
+            id: "dict.fields.type.label",
+          })}
+        />
+        <ProFormText
+          width="md"
+          name="grammar"
+          label={intl.formatMessage({
+            id: "dict.fields.grammar.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="parent"
+          label={intl.formatMessage({
+            id: "dict.fields.parent.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <LangSelect />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="meaning"
+          label={intl.formatMessage({
+            id: "dict.fields.meaning.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="factors"
+          label={intl.formatMessage({
+            id: "dict.fields.factors.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="factormeaning"
+          label={intl.formatMessage({
+            id: "dict.fields.factormeaning.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          name="note"
+          label={intl.formatMessage({
+            id: "forms.fields.note.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <Confidence />
+      </ProForm.Group>
+    </>
+  );
+};
+
+export default DictEditInnerWidget;

+ 39 - 0
dashboard-v6/src/components/dict/DictGroupTitle.tsx

@@ -0,0 +1,39 @@
+import { Affix, Breadcrumb } from "antd";
+import { useState } from "react";
+
+interface IWidget {
+  title: React.ReactNode;
+  path: string[];
+}
+
+const DictGroupTitleWidget = ({ title, path }: IWidget) => {
+  const [fixed, setFixed] = useState<boolean>();
+  return (
+    <Affix
+      offsetTop={0}
+      target={() =>
+        document.getElementsByClassName("dict_component")[0] as HTMLElement
+      }
+      onChange={(affixed) => setFixed(affixed)}
+    >
+      {fixed ? (
+        <Breadcrumb
+          style={{
+            backgroundColor: "white",
+            padding: 4,
+            borderBottom: "1px solid gray",
+          }}
+        >
+          <Breadcrumb.Item key={"top"}>Top</Breadcrumb.Item>
+          {path.map((item, index) => {
+            return <Breadcrumb.Item key={index}>{item}</Breadcrumb.Item>;
+          })}
+        </Breadcrumb>
+      ) : (
+        title
+      )}
+    </Affix>
+  );
+};
+
+export default DictGroupTitleWidget;

+ 61 - 0
dashboard-v6/src/components/dict/DictInfoCopyRef.tsx

@@ -0,0 +1,61 @@
+import { Button, message, Segmented, Typography } from "antd";
+import type { SegmentedValue } from "antd/lib/segmented"
+import { useState } from "react";
+import { CopyOutlined } from "@ant-design/icons";
+import type { IWordByDict } from "./WordCardByDict"
+import { useIntl } from "react-intl";
+const { Text } = Typography;
+
+interface IWidget {
+  data: IWordByDict;
+}
+const DictInfoCopyRef = ({ data }: IWidget) => {
+  const apaStr = `${data.meta?.author}. (${data.meta?.published}). ${data.dictname}. ${data.meta?.publisher}.`;
+  const mlaStr = `${data.meta?.author}. ${data.dictname}.  ${data.meta?.publisher}, ${data.meta?.published}.`;
+  const [text, setText] = useState(apaStr);
+  const intl = useIntl();
+
+  return (
+    <div>
+      <div style={{ textAlign: "center", padding: 20 }}>
+        <Segmented
+          options={["APA", "MLA"]}
+          onChange={(value: SegmentedValue) => {
+            switch (value) {
+              case "APA":
+                setText(apaStr);
+                break;
+              case "MLA":
+                setText(mlaStr);
+                break;
+              default:
+                break;
+            }
+          }}
+        />
+      </div>
+      <div>
+        <Text>{text}</Text>
+      </div>
+
+      <div style={{ textAlign: "center", padding: 20 }}>
+        <Button
+          type="primary"
+          style={{ width: 200 }}
+          icon={<CopyOutlined />}
+          onClick={() => {
+            navigator.clipboard.writeText(text).then(() => {
+              message.success("链接地址已经拷贝到剪贴板");
+            });
+          }}
+        >
+          {intl.formatMessage({
+            id: "buttons.copy",
+          })}
+        </Button>
+      </div>
+    </div>
+  );
+};
+
+export default DictInfoCopyRef;

+ 45 - 0
dashboard-v6/src/components/dict/DictList.tsx

@@ -0,0 +1,45 @@
+import { Affix, Anchor, Button, Col, Popover, Row } from "antd";
+import { UnorderedListOutlined } from "@ant-design/icons";
+import type { IAnchorData } from "../../api/dict";
+const { Link } = Anchor;
+
+interface IWidgetDictList {
+  data: IAnchorData[];
+}
+const DictListWidget = (prop: IWidgetDictList) => {
+  const GetLink = (anchors: IAnchorData[]) => {
+    return anchors.map((it, id) => {
+      return (
+        <Link key={id} href={it.href} title={it.title}>
+          {it.children ? GetLink(it.children) : ""}
+        </Link>
+      );
+    });
+  };
+  const dictNav = <Anchor offsetTop={50}>{GetLink(prop.data)}</Anchor>;
+  return (
+    <Row>
+      <Col xs={0} sm={24}>
+        {dictNav}
+      </Col>
+      <Col xs={24} sm={0}>
+        <Affix offsetTop={50}>
+          <Popover
+            placement="bottomRight"
+            arrow={{ pointAtCenter: true }}
+            content={dictNav}
+            trigger="click"
+          >
+            <Button
+              type="primary"
+              shape="circle"
+              icon={<UnorderedListOutlined />}
+            />
+          </Popover>
+        </Affix>
+      </Col>
+    </Row>
+  );
+};
+
+export default DictListWidget;

+ 382 - 0
dashboard-v6/src/components/dict/DictPreference.tsx

@@ -0,0 +1,382 @@
+import { ProList } from "@ant-design/pro-components";
+
+import { EditOutlined, CheckOutlined } from "@ant-design/icons";
+import type {
+  IApiResponseDictData,
+  IPreferenceListResponse,
+  IPreferenceRequest,
+  IPreferenceResponse,
+} from "../../api/dict";
+import { Button, Input, Space, Tag } from "antd";
+import { get, put } from "../../request";
+
+import { useEffect, useState } from "react";
+
+import Lookup from "./Lookup";
+
+import User from "../auth/User";
+import DictConfidence from "./DictConfidence";
+import { setValue } from "./utils";
+import type { IWbw } from "../../types/wbw";
+import WbwFactorsEditor from "../wbw/WbwFactorsEditor";
+import WbwParentEditor from "../wbw/WbwParentEditor";
+import WbwLookup from "../wbw/WbwLookup";
+
+interface IOkButton {
+  data: IApiResponseDictData;
+  onChange?: (data: IApiResponseDictData) => void;
+}
+const OkButton = ({ data, onChange }: IOkButton) => {
+  const [loading, setLoading] = useState(false);
+  return (
+    <Button
+      type="link"
+      icon={<CheckOutlined />}
+      loading={loading}
+      onClick={async () => {
+        setLoading(true);
+        const result = await setValue(data.id, 100);
+        setLoading(false);
+        console.info("api response", result);
+        if (result.ok) {
+          onChange?.(result.data);
+        }
+      }}
+    >
+      确认
+    </Button>
+  );
+};
+
+const toWbw = (data: IApiResponseDictData): IWbw => {
+  return {
+    book: 1,
+    para: 1,
+    sn: [1],
+    word: { value: data.word, status: 5 },
+    real: { value: data.word, status: 5 },
+    factors: { value: data.factors ?? "", status: 5 },
+    parent: { value: data.parent ?? "", status: 5 },
+    confidence: data.confidence ?? 0,
+  };
+};
+interface IFactorsEditorWidget {
+  data: IApiResponseDictData;
+}
+const FactorsEditor = ({ data }: IFactorsEditorWidget) => {
+  const [wbw, setWbw] = useState(toWbw(data));
+  const [input, setInput] = useState(data.factors);
+  const [type, setType] = useState(false);
+
+  useEffect(() => {
+    setWbw(toWbw(data));
+  }, [data]);
+
+  const upload = async (value: string) => {
+    const url = `/api/v2/dict-preference/${data.id}`;
+    const values: IPreferenceRequest = {
+      factors: value,
+      confidence: 100,
+    };
+    console.debug("api request", url, data);
+    const result = await put<IPreferenceRequest, IPreferenceResponse>(
+      url,
+      values
+    );
+    console.info("api response", result);
+    setWbw(toWbw(result.data));
+    setInput(result.data.factors);
+    return result;
+  };
+  return type ? (
+    <div style={{ display: "flex" }}>
+      <Input
+        width={400}
+        value={input ?? ""}
+        placeholder="Title"
+        onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+          setInput(event.target.value);
+        }}
+      />
+      <Button
+        type="text"
+        icon={<CheckOutlined />}
+        onClick={async () => {
+          upload(input ?? "");
+
+          setType(false);
+        }}
+      />
+    </div>
+  ) : (
+    <Space>
+      <WbwFactorsEditor
+        key="factors"
+        initValue={wbw}
+        display={"block"}
+        onChange={async (e: string): Promise<IPreferenceResponse> => {
+          const result = upload(e ?? "");
+          return result;
+        }}
+      />
+      <Button
+        type="text"
+        icon={<EditOutlined />}
+        onClick={() => setType(true)}
+      />
+    </Space>
+  );
+};
+
+interface IParentEditorWidget {
+  data: IApiResponseDictData;
+}
+const ParentEditor = ({ data }: IParentEditorWidget) => {
+  const [wbw, setWbw] = useState(toWbw(data));
+  const [input, setInput] = useState(data.factors);
+  const [type, setType] = useState(false);
+
+  useEffect(() => {
+    setWbw(toWbw(data));
+  }, [data]);
+
+  const upload = async (value: string) => {
+    const url = `/api/v2/dict-preference/${data.id}`;
+    const values: IPreferenceRequest = {
+      parent: value,
+      confidence: 100,
+    };
+    console.debug("api request", url, data);
+    const result = await put<IPreferenceRequest, IPreferenceResponse>(
+      url,
+      values
+    );
+    console.info("api response", result);
+    setWbw(toWbw(result.data));
+    setInput(result.data.factors);
+    return result;
+  };
+  return type ? (
+    <div style={{ display: "flex" }}>
+      <Input
+        width={400}
+        value={input ?? ""}
+        placeholder="Title"
+        onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+          setInput(event.target.value);
+        }}
+      />
+      <Button
+        type="text"
+        icon={<CheckOutlined />}
+        onClick={async () => {
+          upload(input ?? "");
+          setType(false);
+        }}
+      />
+    </div>
+  ) : (
+    <Space>
+      <WbwParentEditor
+        key="factors"
+        initValue={wbw}
+        display={"block"}
+        onChange={async (e: string): Promise<IPreferenceResponse> => {
+          const result = upload(e ?? "");
+          return result;
+        }}
+      />
+      <Button
+        type="text"
+        icon={<EditOutlined />}
+        onClick={() => setType(true)}
+      />
+    </Space>
+  );
+};
+
+export interface IDictPreferenceWidget {
+  currPage?: number;
+  pageSize?: number;
+}
+
+const DictPreference = ({
+  currPage,
+  pageSize = 100,
+}: IDictPreferenceWidget) => {
+  const [lookupWords, setLookupWords] = useState<string[]>([]);
+  const [lookupRun, setLookupRun] = useState(false);
+  const [data, setData] = useState<IApiResponseDictData[]>([]);
+  return (
+    <>
+      <WbwLookup words={lookupWords} run={lookupRun} />
+
+      <ProList<IApiResponseDictData>
+        search={{
+          filterType: "light",
+        }}
+        rowKey="name"
+        headerTitle="单词首选项"
+        dataSource={data}
+        onDataSourceChange={setData}
+        request={async (params = {}) => {
+          let url = `/api/v2/dict-preference`;
+          const mPageSize = pageSize ?? params.pageSize ?? 100;
+          const offset = ((currPage ?? params.current ?? 1) - 1) * mPageSize;
+          url += `?limit=${mPageSize}&offset=${offset}`;
+          url += params.keyword ? "&keyword=" + params.keyword : "";
+          console.info("api request", url);
+          const res = await get<IPreferenceListResponse>(url);
+          console.info("api response", res);
+
+          return {
+            data: res.data.rows.map((item, id) => {
+              return { ...item, sn: id + offset + 1 };
+            }),
+            total: res.data.count,
+            success: true,
+          };
+        }}
+        pagination={
+          currPage
+            ? false
+            : {
+                showQuickJumper: !currPage,
+                showSizeChanger: !currPage,
+                showLessItems: !currPage,
+                showPrevNextJumpers: !currPage,
+                pageSize: 100,
+              }
+        }
+        onRow={(record) => {
+          return {
+            onMouseEnter: () => {
+              console.info(`点击了行:${record.word}`);
+              setLookupWords([record.word]);
+              setLookupRun(true);
+            },
+            onMouseLeave: () => {
+              setLookupRun(false);
+            },
+          };
+        }}
+        metas={{
+          title: {
+            dataIndex: "word",
+            title: "用户",
+            render(_dom, entity) {
+              return (
+                <Space>
+                  {`[${entity.sn}]`}
+                  <Lookup search={entity.word}>{entity.word}</Lookup>
+                </Space>
+              );
+            },
+          },
+          avatar: {
+            dataIndex: "sn",
+            search: false,
+            render(_dom, entity) {
+              return (
+                <User
+                  {...entity.editor}
+                  showName={false}
+                  showUserName={false}
+                />
+              );
+            },
+          },
+          description: {
+            dataIndex: "title",
+            search: false,
+            render(_dom, entity) {
+              return (
+                <Space>
+                  <FactorsEditor data={entity} />
+                  <span>|</span>
+                  <ParentEditor data={entity} />
+                </Space>
+              );
+            },
+          },
+          subTitle: {
+            dataIndex: "labels",
+            render: (_, row) => {
+              return (
+                <Space>
+                  <Tag color="blue" key={row.count}>
+                    {row.count}
+                  </Tag>
+                  <DictConfidence
+                    value={row.confidence}
+                    onChange={async (value) => {
+                      const result = await setValue(row.id, value);
+                      setData((origin) => {
+                        origin.forEach((value, index, array) => {
+                          if (value.id === result.data.id) {
+                            array[index] = {
+                              ...value,
+                              confidence: result.data.confidence,
+                            };
+                          }
+                        });
+                        return origin;
+                      });
+                    }}
+                  />
+                </Space>
+              );
+            },
+            search: false,
+          },
+
+          actions: {
+            render: (_text, row) => {
+              return [
+                <OkButton
+                  data={row}
+                  onChange={(data) => {
+                    setData((origin) => {
+                      origin.forEach((value, index, array) => {
+                        if (value.id === row.id) {
+                          array[index] = {
+                            ...value,
+                            confidence: data.confidence,
+                          };
+                        }
+                      });
+                      return origin;
+                    });
+                  }}
+                />,
+              ];
+            },
+            search: false,
+          },
+          status: {
+            // 自己扩展的字段,主要用于筛选,不在列表中显示
+            title: "状态",
+            valueType: "select",
+            valueEnum: {
+              all: { text: "全部", status: "Default" },
+              open: {
+                text: "未解决",
+                status: "Error",
+              },
+              closed: {
+                text: "已解决",
+                status: "Success",
+              },
+              processing: {
+                text: "解决中",
+                status: "Processing",
+              },
+            },
+          },
+        }}
+      />
+    </>
+  );
+};
+
+export default DictPreference;

+ 44 - 0
dashboard-v6/src/components/dict/DictSearch.tsx

@@ -0,0 +1,44 @@
+// features/dict/DictSearchWidget.tsx
+// 胶水层:把 useDict 的数据喂给 DictContent,注入业务回调
+// 不含纯样式逻辑,不直接发请求
+
+import { Result, Skeleton } from "antd";
+import { useDict } from "./hooks/useDict";
+import DictContent from "./DictContent";
+import type { ResultStatusType } from "antd/es/result";
+
+interface IDictSearchWidgetProps {
+  word: string | undefined;
+  compact?: boolean;
+}
+
+const DictSearchWidget = ({
+  word,
+  compact = false,
+}: IDictSearchWidgetProps) => {
+  const { data, loading, errorCode, errorMessage } = useDict(word);
+
+  if (loading) {
+    return (
+      <div>
+        <div>searching {word}</div>
+        <Skeleton active />
+      </div>
+    );
+  }
+
+  if (errorCode !== null) {
+    return (
+      <div>
+        <Result
+          status={errorCode as ResultStatusType}
+          subTitle={errorMessage}
+        />
+      </div>
+    );
+  }
+
+  return <DictContent word={word} data={data} compact={compact} />;
+};
+
+export default DictSearchWidget;

+ 167 - 0
dashboard-v6/src/components/dict/Dictionary.tsx

@@ -0,0 +1,167 @@
+import { useCallback, useState } from "react";
+import { Layout, Affix, Col, Row } from "antd";
+
+import DictSearch from "./DictSearch";
+import SearchVocabulary from "./SearchVocabulary";
+import Compound from "./Compound";
+import TermShow from "../term/TermShow";
+
+const { Content } = Layout;
+
+interface IWidget {
+  word?: string;
+  compact?: boolean;
+  onSearch?: (word?: string) => void;
+}
+
+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");
+  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 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;
+  }, []);
+
+  // 渲染期派生:word prop 变化时同步更新 wordInput / wordSearch
+  if (word !== prevWordProp) {
+    setPrevWordProp(word);
+    setWordInput(word);
+
+    if (word !== prevSearch) {
+      setPrevSearch(word);
+      const currWord = word?.includes(":")
+        ? word // 复杂格式保持原值,wordChange 会在实际搜索时解析
+        : word?.toLowerCase();
+      setWordSearch(currWord);
+    }
+  }
+
+  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 dictSearch = useCallback(
+    (value: string, isFactor?: boolean) => {
+      console.info("onSearch", value);
+      const currWord = wordChange(value);
+      if (typeof onSearch !== "undefined" && !isFactor) {
+        onSearch(currWord);
+      }
+      // 用户主动搜索不去重
+      setPrevSearch(value);
+      setWordSearch(currWord);
+    },
+    [wordChange, onSearch]
+  );
+
+  return (
+    <div ref={setContainer}>
+      <div id="pcd_dict_top"></div>
+      <Affix
+        offsetTop={0}
+        target={compact ? () => container : undefined}
+        className="dict_search_div"
+      >
+        <div
+          style={{
+            backgroundColor: "rgba(100,100,100,0.3)",
+            backdropFilter: "blur(5px)",
+          }}
+        >
+          <Row style={{ paddingTop: "0.5em", paddingBottom: "0.5em" }}>
+            {compact ? <></> : <Col flex="auto"></Col>}
+            <Col flex="560px">
+              <div style={{ display: "flex" }}>
+                <SearchVocabulary
+                  compact={compact}
+                  value={wordInput?.toLowerCase()}
+                  onSearch={dictSearch}
+                  onSplit={(word) => {
+                    console.log("onSplit", word);
+                    setSplit(word);
+                  }}
+                />
+              </div>
+            </Col>
+            {compact ? <></> : <Col flex="auto"></Col>}
+          </Row>
+        </div>
+      </Affix>
+      <Content style={{ minHeight: 700 }}>
+        <Row>
+          {compact ? <></> : <Col flex="auto"></Col>}
+          <Col flex="1260px">
+            {dictType === "dict" ? (
+              <div>
+                <Compound word={word} add={split} onSearch={dictSearch} />
+                <DictSearch word={wordSearch} compact={compact} />
+              </div>
+            ) : (
+              <TermShow
+                word={wordSearch}
+                wordId={wordId}
+                hideInput
+                onIdChange={(value: string) => {
+                  const newInput = `type:term id:${value}`;
+                  console.debug("term onIdChange setWordInput", newInput);
+                  if (typeof onSearch !== "undefined") {
+                    onSearch(newInput);
+                  } else {
+                    wordInputChange(newInput);
+                  }
+                }}
+              />
+            )}
+          </Col>
+          {compact ? <></> : <Col flex="auto"></Col>}
+        </Row>
+      </Content>
+    </div>
+  );
+};
+
+export default DictionaryWidget;

+ 65 - 0
dashboard-v6/src/components/dict/GrammarPop.tsx

@@ -0,0 +1,65 @@
+import { useState } from "react";
+import { Popover, Typography } from "antd";
+
+import { get } from "../../request";
+import { get as getLang } from "../../locales";
+import type { IGuideResponse } from "../../api/Guide";
+import Marked from "../general/Marked";
+
+const { Link, Paragraph } = Typography;
+
+interface IWidget {
+  text: string;
+  gid: string;
+}
+const GrammarPopWidget = ({ text, gid }: IWidget) => {
+  const [guide, setGuide] = useState("Loading");
+  const grammarPrefix = "guide-grammar-";
+  const handleMouseMouseEnter = () => {
+    //sessionStorage缓存
+    const value = sessionStorage.getItem(grammarPrefix + gid);
+    if (value === null) {
+      fetchData(gid);
+    } else {
+      const sGuide: string = value ? value : "";
+      setGuide(sGuide);
+    }
+  };
+
+  function fetchData(key: string) {
+    const uiLang = getLang();
+    const url = `/api/v2/grammar-guide/${key}_${uiLang}`;
+    get<IGuideResponse>(url).then((json) => {
+      if (json.ok) {
+        sessionStorage.setItem(grammarPrefix + key, json.data);
+        setGuide(json.data);
+      }
+    });
+  }
+  return (
+    <Popover
+      content={
+        <Paragraph style={{ maxWidth: 500, minWidth: 300, margin: 0 }}>
+          <Marked text={guide} />
+        </Paragraph>
+      }
+      placement="bottom"
+    >
+      <Link onMouseEnter={handleMouseMouseEnter}>{text}</Link>
+    </Popover>
+  );
+};
+
+interface IWidgetShell {
+  props: string;
+}
+export const GrammarPopShell = ({ props }: IWidgetShell) => {
+  const prop = JSON.parse(atob(props)) as IWidget;
+  return (
+    <>
+      <GrammarPopWidget {...prop} />
+    </>
+  );
+};
+
+export default GrammarPopWidget;

+ 314 - 0
dashboard-v6/src/components/dict/MyCreate.tsx

@@ -0,0 +1,314 @@
+import { Button, Col, Divider, Input, message, notification, Row } from "antd";
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+import { SaveOutlined } from "@ant-design/icons";
+
+import { get, post } from "../../request";
+import type {
+  IApiResponseDictList,
+  IDictRequest,
+  IDictResponse,
+  IUserDictCreate,
+} from "../../api/dict";
+import { useAppSelector } from "../../hooks";
+import { add, updateIndex, wordIndex } from "../../reducers/inline-dict";
+import store from "../../store";
+import { get as getUiLang } from "../../locales";
+import { myDictDirty } from "../../reducers/command";
+import { currentUser } from "../../reducers/current-user";
+import type { IWbw, IWbwField, TFieldName } from "../../types/wbw";
+import WbwDetailBasic from "../wbw/WbwDetailBasic";
+import WbwDetailNote from "../wbw/WbwDetailNote";
+
+export const UserWbwPost = (data: IDictRequest[], view: string) => {
+  let wordData: IDictRequest[] = data;
+  data.forEach((value: IDictRequest) => {
+    if (value.parent && value.type !== "") {
+      if (!value.type?.includes("base") && value.type !== ".ind.") {
+        let pFactors = "";
+        let pFm;
+        const orgFactors = value.factors?.split("+");
+        if (
+          orgFactors &&
+          orgFactors.length > 0 &&
+          orgFactors[orgFactors.length - 1].includes("[")
+        ) {
+          pFactors = orgFactors.slice(0, -1).join("+");
+          pFm = value.factormean
+            ?.split("+")
+            .slice(0, orgFactors.length - 1)
+            .join("+");
+        }
+        let grammar = value.grammar?.split("$").slice(0, 1).join("");
+        if (value.type?.includes(".v")) {
+          grammar = "";
+        }
+        wordData.push({
+          word: value.parent,
+          type: "." + value.type?.replaceAll(".", "") + ":base.",
+          grammar: grammar,
+          mean: value.mean,
+          parent: value.parent2 ?? undefined,
+          factors: pFactors,
+          factormean: pFm,
+          confidence: value.confidence,
+          language: value.language,
+          status: value.status,
+        });
+      }
+    }
+
+    if (value.factors && value.factors.split("+").length > 0) {
+      const fm = value.factormean?.split("+");
+      const factors: IDictRequest[] = [];
+      value.factors.split("+").forEach((factor: string, index: number) => {
+        const currWord = factor.replaceAll("-", "");
+        console.debug("currWord", currWord);
+        const meaning = fm ? (fm[index].replaceAll("-", "") ?? null) : null;
+        if (meaning) {
+          factors.push({
+            word: currWord,
+            type: ".part.",
+            grammar: "",
+            mean: meaning,
+            confidence: value.confidence,
+            language: value.language,
+            status: value.status,
+          });
+        }
+
+        const subFactorsMeaning: string[] = fm ? fm[index].split("-") : [];
+        factor.split("-").forEach((subFactor, index1) => {
+          if (subFactorsMeaning[index1] && subFactorsMeaning[index1] !== "") {
+            factors.push({
+              word: subFactor,
+              type: ".part.",
+              grammar: "",
+              mean: subFactorsMeaning[index1],
+              confidence: value.confidence,
+              language: value.language,
+              status: value.status,
+            });
+          }
+        });
+      });
+      wordData = [...wordData, ...factors];
+    }
+  });
+  return post<IUserDictCreate, IDictResponse>("/api/v2/userdict", {
+    view: view,
+    data: JSON.stringify(wordData),
+  });
+};
+
+interface IWidget {
+  word?: string;
+  onSave?: () => void;
+}
+const MyCreateWidget = ({ word, onSave }: IWidget) => {
+  const intl = useIntl();
+  const [dirty, setDirty] = useState(false);
+  const [wordSpell, setWordSpell] = useState(word);
+  const [editWord, setEditWord] = useState<IWbw>({
+    word: { value: word ? word : "", status: 7 },
+    real: { value: word ? word : "", status: 7 },
+    book: 0,
+    para: 0,
+    sn: [0],
+    confidence: 100,
+  });
+  const [loading, setLoading] = useState(false);
+  const inlineWordIndex = useAppSelector(wordIndex);
+  const user = useAppSelector(currentUser);
+
+  useEffect(() => {
+    setWordSpell(word);
+  }, [word]);
+
+  useEffect(() => {
+    //查询这个词在内存字典里是否有
+    if (typeof wordSpell === "undefined") {
+      return;
+    }
+    if (inlineWordIndex.includes(wordSpell)) {
+      //已经有了,退出
+      return;
+    }
+    get<IApiResponseDictList>(`/api/v2/wbwlookup?word=${wordSpell}`).then(
+      (json) => {
+        console.log("lookup ok", json.data.count);
+        //存储到redux
+        store.dispatch(add(json.data.rows));
+        store.dispatch(updateIndex([wordSpell]));
+      }
+    );
+  }, [inlineWordIndex, wordSpell]);
+
+  const setDataDirty = (dirty: boolean) => {
+    store.dispatch(myDictDirty(dirty));
+    setDirty(dirty);
+  };
+
+  function fieldChanged(field: TFieldName, value: string) {
+    const mData: IWbw = JSON.parse(JSON.stringify(editWord));
+    switch (field) {
+      case "note":
+        mData.note = { value: value, status: 7 };
+        break;
+      case "word":
+        mData.word = { value: value, status: 7 };
+        break;
+      case "real":
+        mData.real = { value: value, status: 7 };
+        break;
+      case "meaning":
+        mData.meaning = { value: value, status: 7 };
+        break;
+      case "factors":
+        mData.factors = { value: value, status: 7 };
+        break;
+      case "factorMeaning":
+        mData.factorMeaning = { value: value, status: 7 };
+        break;
+      case "parent":
+        mData.parent = { value: value, status: 7 };
+        break;
+      case "case": {
+        console.log("case", value);
+        const _case = value.replaceAll("#", "$").split("$");
+        const _type = _case[0];
+        const _grammar = _case.slice(1).join("$");
+        mData.type = { value: _type, status: 7 };
+        mData.grammar = { value: _grammar, status: 7 };
+        mData.case = { value: value, status: 7 };
+        break;
+      }
+      case "confidence":
+        mData.confidence = parseFloat(value);
+        break;
+      default:
+        break;
+    }
+    console.debug("field changed", mData);
+    setEditWord(mData);
+    setDataDirty(true);
+  }
+
+  const reset = () => {
+    const mData: IWbw = JSON.parse(JSON.stringify(editWord));
+    mData.note = { value: "", status: 7 };
+    mData.meaning = { value: "", status: 7 };
+    mData.type = { value: "", status: 7 };
+    mData.grammar = { value: "", status: 7 };
+    mData.factors = { value: "", status: 7 };
+    mData.factorMeaning = { value: "", status: 7 };
+    setEditWord(mData);
+  };
+  return (
+    <div style={{ padding: "0 5px" }}>
+      <Row>
+        <Col
+          span={4}
+          style={{
+            display: "inline-block",
+            flexGrow: 0,
+            overflow: "hidden",
+            whiteSpace: "nowrap",
+            textAlign: "right",
+            verticalAlign: "middle",
+            padding: 5,
+          }}
+        >
+          拼写
+        </Col>
+        <Col span={20}>
+          <Input
+            value={wordSpell}
+            placeholder="Basic usage"
+            onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+              console.debug("spell onChange", event.target.value);
+              setWordSpell(event.target.value);
+              fieldChanged("word", event.target.value);
+            }}
+          />
+        </Col>
+      </Row>
+
+      <WbwDetailBasic
+        data={editWord}
+        showRelation={false}
+        onChange={(e: IWbwField) => {
+          console.log("WbwDetailBasic onchange", e);
+          fieldChanged(e.field, e.value);
+        }}
+      />
+      <Divider>{intl.formatMessage({ id: "buttons.note" })}</Divider>
+      <WbwDetailNote
+        data={editWord}
+        onChange={(e: IWbwField) => {
+          fieldChanged(e.field, e.value);
+        }}
+      />
+      <Divider></Divider>
+      <div
+        style={{ display: "flex", justifyContent: "space-between", padding: 5 }}
+      >
+        <Button
+          onClick={() => {
+            reset();
+            setDataDirty(false);
+          }}
+        >
+          重置
+        </Button>
+        <Button
+          loading={loading}
+          icon={<SaveOutlined />}
+          disabled={!dirty}
+          onClick={() => {
+            setLoading(true);
+            const data: IDictRequest[] = [
+              {
+                word: editWord.word.value,
+                type: editWord.type?.value,
+                grammar: editWord.grammar?.value,
+                mean: editWord.meaning?.value,
+                parent: editWord.parent?.value,
+                note: editWord.note?.value,
+                factors: editWord.factors?.value,
+                factormean: editWord.factorMeaning?.value,
+                language: getUiLang(),
+                status: user?.roles?.includes("basic") ? 5 : 30,
+                confidence: 100,
+              },
+            ];
+            UserWbwPost(data, "dict")
+              .finally(() => {
+                setLoading(false);
+              })
+              .then((json) => {
+                if (json.ok) {
+                  setDataDirty(false);
+                  reset();
+                  notification.info({
+                    message: intl.formatMessage({ id: "flashes.success" }),
+                  });
+
+                  if (typeof onSave !== "undefined") {
+                    onSave();
+                  }
+                } else {
+                  message.error(json.message);
+                }
+              });
+          }}
+          type="primary"
+        >
+          {intl.formatMessage({ id: "buttons.save" })}
+        </Button>
+      </div>
+    </div>
+  );
+};
+
+export default MyCreateWidget;

+ 6 - 6
dashboard-v6/src/components/dict/SearchVocabulary.tsx

@@ -1,5 +1,5 @@
 import { get } from "../../request";
-import type { IVocabularyListResponse } from "../../api/Dict";
+import type { IVocabularyListResponse } from "../../api/dict";
 import { useRef, useState } from "react";
 import { AutoComplete, Input, Space, Typography } from "antd";
 import { DictIcon } from "../../assets/icon";
@@ -16,10 +16,10 @@ interface IWidget {
   api?: string;
   compact?: boolean;
   onSearch?: (value: string, split?: boolean) => void;
-  onSplit?: (value: string | null) => void;
+  onSplit?: (value?: string) => void;
 }
 
-const SearchVocabularyWidget = ({
+const SearchVocabulary = ({
   value,
   api = "vocabulary",
   compact = false,
@@ -67,7 +67,7 @@ const SearchVocabularyWidget = ({
       onSplit?.(strFactors.replaceAll("-", "+"));
     } else {
       setFactors([]);
-      onSplit?.(null);
+      onSplit?.(undefined);
     }
   };
 
@@ -75,7 +75,7 @@ const SearchVocabularyWidget = ({
     stopLookup();
     if (value === "") return;
 
-    get<IVocabularyListResponse>(`/v2/${api}?view=key&key=${value}`)
+    get<IVocabularyListResponse>(`/api/v2/${api}?view=key&key=${value}`)
       .then((json) => {
         const words: ValueType[] = json.data.rows
           .map((item) => {
@@ -152,4 +152,4 @@ const SearchVocabularyWidget = ({
   );
 };
 
-export default SearchVocabularyWidget;
+export default SearchVocabulary;

+ 79 - 0
dashboard-v6/src/components/dict/SelectCase.tsx

@@ -0,0 +1,79 @@
+import { useIntl } from "react-intl";
+import { Cascader } from "antd";
+import { useMemo, useState } from "react";
+import { buildCaseOptions } from "./caseOptions";
+
+interface IWidget {
+  value?: string | null;
+  readonly?: boolean;
+  onCaseChange?: (value: string) => void;
+}
+
+const SelectCaseWidget = ({
+  value,
+  readonly = false,
+  onCaseChange,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const options = useMemo(() => buildCaseOptions(intl), [intl]);
+
+  // 直接从 value prop 派生,无需 useEffect + useState
+  const currValue = useMemo(() => {
+    if (typeof value !== "string") return undefined;
+    return value
+      .replaceAll("#", "$")
+      .replaceAll(":", ".$.")
+      .split("$")
+      .map((item) => item.replaceAll(".", ""));
+  }, [value]);
+
+  const [internalValue, setInternalValue] = useState<
+    (string | number)[] | undefined
+  >(currValue);
+
+  return (
+    <Cascader
+      disabled={readonly}
+      value={internalValue ?? currValue}
+      options={options}
+      placeholder="Please select case"
+      onChange={(value?: (string | number)[]) => {
+        console.log("case changed", value);
+        if (typeof value === "undefined") {
+          setInternalValue(undefined);
+          onCaseChange?.("");
+          return;
+        }
+
+        let newValue: (string | number)[];
+        if (
+          value.length > 1 &&
+          value[value.length - 1] === value[value.length - 2]
+        ) {
+          newValue = value.slice(0, -1);
+        } else {
+          newValue = value;
+        }
+
+        setInternalValue(newValue);
+
+        if (typeof onCaseChange !== "undefined") {
+          let output = newValue.map((item) => `.${item}.`).join("$");
+          output = output.replace(".$.base", ":base").replace(".$.ind", ":ind");
+          if (output.indexOf("$") > 0) {
+            output =
+              output.substring(0, output.indexOf("$")) +
+              "#" +
+              output.substring(output.indexOf("$") + 1);
+          } else {
+            output += "#";
+          }
+          onCaseChange(output);
+        }
+      }}
+    />
+  );
+};
+
+export default SelectCaseWidget;

+ 593 - 0
dashboard-v6/src/components/dict/UserDictList.tsx

@@ -0,0 +1,593 @@
+import { useIntl } from "react-intl";
+import {
+  Button,
+  Space,
+  Table,
+  Dropdown,
+  Drawer,
+  message,
+  Modal,
+  Typography,
+  Tag,
+  Popover,
+} from "antd";
+import {
+  PlusOutlined,
+  ExclamationCircleOutlined,
+  DeleteOutlined,
+  InfoCircleOutlined,
+} from "@ant-design/icons";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+
+import DictCreate from "./DictCreate";
+import type {
+  IApiResponseDictList,
+  IDictInfo,
+  IUserDictDeleteRequest,
+} from "../../api/dict";
+import { delete_2, get } from "../../request";
+import { useEffect, useRef, useState } from "react";
+import DictEdit from "./DictEdit";
+import type { IDeleteResponse } from "../../api/Article";
+import TimeShow from "../general/TimeShow";
+import { getSorterUrl } from "../../utils";
+import MdView from "../general/MdView";
+
+const { Link } = Typography;
+
+export interface IWord {
+  sn: number;
+  wordId: string;
+  word: string;
+  type?: string | null;
+  grammar?: string | null;
+  parent?: string | null;
+  meaning?: string | null;
+  note?: string | null;
+  factors?: string | null;
+  dict?: IDictInfo;
+  status?: number;
+  updated_at?: string;
+  created_at?: string;
+}
+interface IParams {
+  word?: string;
+  parent?: string;
+  dict?: string;
+}
+interface IWidget {
+  studioName?: string;
+  view?: "studio" | "all";
+  dictName?: string;
+  word?: string;
+  compact?: boolean;
+  refresh?: boolean;
+  onRefresh?: (value: boolean) => void;
+}
+const UserDictListWidget = ({
+  studioName,
+  view = "studio",
+  dictName,
+  word,
+  compact = false,
+  refresh = false,
+  onRefresh,
+}: IWidget) => {
+  const intl = useIntl();
+  const [isEditOpen, setIsEditOpen] = useState(false);
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+  const [wordId, setWordId] = useState<string>();
+  const [drawerTitle, setDrawerTitle] = useState("New Word");
+
+  const showDeleteConfirm = (id: string[], title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_2<IUserDictDeleteRequest, IDeleteResponse>(
+          `/api/v2/userdict/${id}`,
+          {
+            id: JSON.stringify(id),
+          }
+        )
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e: unknown) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  useEffect(() => {
+    if (refresh === true) {
+      ref.current?.reload();
+      if (typeof onRefresh !== "undefined") {
+        onRefresh(false);
+      }
+    }
+  }, [onRefresh, refresh]);
+
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      <ProList<IWord, IParams>
+        actionRef={ref}
+        metas={{
+          title: {
+            dataIndex: "word",
+            title: "拼写",
+            search: word ? false : undefined,
+            render: (_text, entity) => {
+              return (
+                <Space>
+                  <span
+                    onClick={() => {
+                      setWordId(entity.wordId);
+                      setDrawerTitle(entity.word);
+                      setIsEditOpen(true);
+                    }}
+                  >
+                    {entity.word}
+                  </span>
+                  {entity.note ? (
+                    <Popover
+                      placement="bottom"
+                      content={<MdView html={entity.note} />}
+                    >
+                      <InfoCircleOutlined color="blue" />
+                    </Popover>
+                  ) : (
+                    <></>
+                  )}
+                </Space>
+              );
+            },
+          },
+          subTitle: {
+            search: false,
+            render: (_text, row) => {
+              return (
+                <Space>
+                  {row.type ? (
+                    <Tag key="type" color="blue">
+                      {intl.formatMessage({
+                        id: `dict.fields.type.${row.type?.replaceAll(
+                          ".",
+                          ""
+                        )}.label`,
+                        defaultMessage: row.type,
+                      })}
+                    </Tag>
+                  ) : (
+                    <></>
+                  )}
+                  {row.grammar ? (
+                    <Tag key="grammar" color="#5BD8A6">
+                      {row.grammar
+                        ?.replaceAll(".", "")
+                        .split("$")
+                        .map((item) =>
+                          intl.formatMessage({
+                            id: `dict.fields.type.${item}.label`,
+                            defaultMessage: item,
+                          })
+                        )
+                        .join(".")}
+                    </Tag>
+                  ) : (
+                    <></>
+                  )}
+                </Space>
+              );
+            },
+          },
+          description: {
+            dataIndex: "meaning",
+            title: "description",
+            search: false,
+            render(_dom, entity) {
+              return (
+                <div>
+                  <Space>
+                    {entity.meaning}
+                    {"|"}
+                    <TimeShow
+                      updatedAt={entity.updated_at}
+                      createdAt={entity.updated_at}
+                      type="secondary"
+                    />
+                    {"|"}
+                    {entity.status === 5 ? "私有" : "公开"}
+                  </Space>
+                  {compact ? (
+                    <div>
+                      <div>{entity.factors}</div>
+                    </div>
+                  ) : (
+                    <></>
+                  )}
+                </div>
+              );
+            },
+          },
+          content: compact
+            ? undefined
+            : {
+                search: false,
+                render(_dom, entity) {
+                  return (
+                    <div>
+                      <div>{entity.factors}</div>
+                    </div>
+                  );
+                },
+              },
+        }}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 80,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.word.label",
+            }),
+            dataIndex: "word",
+            key: "word",
+            tooltip: "单词过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.type.label",
+            }),
+            dataIndex: "type",
+            key: "type",
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: { text: "全部", status: "Default" },
+              n: { text: "名词", status: "Default" },
+              ti: { text: "三性", status: "Processing" },
+              v: { text: "动词", status: "Success" },
+              ind: { text: "不变词", status: "Success" },
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.grammar.label",
+            }),
+            dataIndex: "grammar",
+            key: "grammar",
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.parent.label",
+            }),
+            dataIndex: "parent",
+            key: "parent",
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.meaning.label",
+            }),
+            dataIndex: "meaning",
+            key: "meaning",
+            tooltip: "意思过长会自动收缩",
+            ellipsis: true,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.note.label",
+            }),
+            dataIndex: "note",
+            key: "note",
+            search: false,
+            tooltip: "注释过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.factors.label",
+            }),
+            dataIndex: "factors",
+            key: "factors",
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.dict.shortname.label",
+            }),
+            dataIndex: "dict",
+            key: "dict",
+            hideInTable: view !== "all",
+            search: view !== "all" ? false : undefined,
+            render: (_text, row) => {
+              return row.dict?.shortname;
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.updated-at.label",
+            }),
+            key: "updated_at",
+            width: 200,
+            search: false,
+            dataIndex: "updated_at",
+            valueType: "date",
+            sorter: true,
+            render: (_text, row) => {
+              return (
+                <TimeShow
+                  updatedAt={row.updated_at}
+                  showIcon={false}
+                  showLabel={false}
+                />
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            hideInTable: view === "all",
+            width: 120,
+            valueType: "option",
+            render: (_text, row, index) => {
+              return [
+                <Dropdown.Button
+                  key={index}
+                  type="link"
+                  menu={{
+                    items: [
+                      {
+                        key: "remove",
+                        label: intl.formatMessage({
+                          id: "buttons.delete",
+                        }),
+                        icon: <DeleteOutlined />,
+                        danger: true,
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "share":
+                          break;
+                        case "remove":
+                          showDeleteConfirm([row.wordId], row.word);
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <Link
+                    onClick={() => {
+                      setWordId(row.wordId);
+                      setDrawerTitle(row.word);
+                      setIsEditOpen(true);
+                    }}
+                  >
+                    {intl.formatMessage({
+                      id: "buttons.edit",
+                    })}
+                  </Link>
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        rowSelection={
+          view === "all"
+            ? undefined
+            : {
+                // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+                // 注释该行则默认不显示下拉选项
+                selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+              }
+        }
+        tableAlertRender={
+          view === "all"
+            ? undefined
+            : ({ selectedRowKeys, onCleanSelected }) => (
+                <Space size={24}>
+                  <span>
+                    {intl.formatMessage({ id: "buttons.selected" })}
+                    {selectedRowKeys.length}
+                    <Button
+                      type="link"
+                      style={{ marginInlineStart: 8 }}
+                      onClick={onCleanSelected}
+                    >
+                      {intl.formatMessage({ id: "buttons.unselect" })}
+                    </Button>
+                  </span>
+                </Space>
+              )
+        }
+        tableAlertOptionRender={
+          view === "all"
+            ? undefined
+            : ({ selectedRowKeys, onCleanSelected }) => {
+                return (
+                  <Space size={16}>
+                    <Button
+                      type="link"
+                      onClick={() => {
+                        console.log(selectedRowKeys);
+                        showDeleteConfirm(
+                          selectedRowKeys.map((item) => item.toString()),
+                          selectedRowKeys.length + "个单词"
+                        );
+                        onCleanSelected();
+                      }}
+                    >
+                      批量删除
+                    </Button>
+                  </Space>
+                );
+              }
+        }
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+
+          let url = "/api/v2/userdict?";
+          switch (view) {
+            case "studio":
+              url += `view=studio&name=${studioName}`;
+              break;
+            case "all":
+              url += `view=all`;
+              break;
+            default:
+              break;
+          }
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+
+          url += params.keyword ? "&search=" + params.keyword : "";
+
+          url += params.word
+            ? `&word=${params.word}`
+            : word
+              ? `&word=${word}`
+              : "";
+          url += params.parent ? `&parent=${params.parent}` : "";
+          url += params.dict ? `&dict=${params.dict}` : "";
+          url += dictName
+            ? dictName !== "all"
+              ? `&dict=${dictName}`
+              : ""
+            : "";
+
+          url += getSorterUrl(sorter);
+
+          console.log(url);
+          const res = await get<IApiResponseDictList>(url);
+          const items: IWord[] = res.data.rows.map((item, id) => {
+            const id2 =
+              ((params.current || 1) - 1) * (params.pageSize || 20) + id + 1;
+            return {
+              sn: id2,
+              wordId: item.id,
+              word: item.word,
+              type: item.type,
+              grammar: item.grammar,
+              parent: item.parent,
+              meaning: item.mean,
+              note: item.note,
+              factors: item.factors,
+              dict: item.dict,
+              status: item.status,
+              updated_at: item.updated_at,
+            };
+          });
+          return {
+            total: res.data.count,
+            success: true,
+            data: items,
+          };
+        }}
+        rowKey="wordId"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={
+          word
+            ? undefined
+            : {
+                filterType: "light",
+              }
+        }
+        options={{
+          search: word ? false : true,
+        }}
+        headerTitle=""
+        toolBarRender={
+          view === "all"
+            ? undefined
+            : () => [
+                <Button
+                  key="button"
+                  icon={<PlusOutlined />}
+                  type="primary"
+                  onClick={() => {
+                    setDrawerTitle("New word");
+                    setIsCreateOpen(true);
+                  }}
+                  disabled={true}
+                >
+                  {intl.formatMessage({ id: "buttons.create" })}
+                </Button>,
+              ]
+        }
+      />
+
+      <Drawer
+        title={drawerTitle}
+        placement="right"
+        open={isCreateOpen}
+        onClose={() => {
+          setIsCreateOpen(false);
+        }}
+        key="create"
+        style={{ maxWidth: "100%" }}
+        styles={{ wrapper: { overflowY: "auto" } }}
+        footer={null}
+      >
+        <DictCreate studio={studioName ? studioName : ""} />
+      </Drawer>
+      <Drawer
+        title={drawerTitle}
+        width={500}
+        placement="right"
+        open={isEditOpen}
+        onClose={() => {
+          setIsEditOpen(false);
+        }}
+        key="edit"
+        style={{ maxWidth: "100%" }}
+        contentWrapperStyle={{ overflowY: "auto" }}
+        footer={null}
+      >
+        <DictEdit wordId={wordId} />
+      </Drawer>
+    </>
+  );
+};
+
+export default UserDictListWidget;

+ 450 - 0
dashboard-v6/src/components/dict/UserDictTable.tsx

@@ -0,0 +1,450 @@
+import { useIntl } from "react-intl";
+import {
+  Button,
+  Space,
+  Table,
+  Dropdown,
+  Drawer,
+  message,
+  Modal,
+  Typography,
+} from "antd";
+import {
+  PlusOutlined,
+  ExclamationCircleOutlined,
+  DeleteOutlined,
+} from "@ant-design/icons";
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+
+import DictCreate from "./DictCreate";
+import type {
+  IApiResponseDictList,
+  IDictInfo,
+  IUserDictDeleteRequest,
+} from "../../api/dict";
+import { delete_2, get } from "../../request";
+import { useRef, useState } from "react";
+import DictEdit from "./DictEdit";
+import type { IDeleteResponse } from "../../api/Article";
+import TimeShow from "../general/TimeShow";
+import { getSorterUrl } from "../../utils";
+
+const { Link } = Typography;
+
+export interface IWord {
+  sn: number;
+  wordId: string;
+  word: string;
+  type?: string | null;
+  grammar?: string | null;
+  parent?: string | null;
+  meaning?: string | null;
+  note?: string | null;
+  factors?: string | null;
+  dict?: IDictInfo;
+  updated_at?: string;
+  created_at?: string;
+}
+interface IParams {
+  word?: string;
+  parent?: string;
+  dict?: string;
+}
+interface IWidget {
+  studioName?: string;
+  view?: "studio" | "all";
+  dictName?: string;
+}
+const UserDictTableWidget = ({
+  studioName,
+  view = "studio",
+  dictName,
+}: IWidget) => {
+  const intl = useIntl();
+  const [isEditOpen, setIsEditOpen] = useState(false);
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+  const [wordId, setWordId] = useState<string>();
+  const [drawerTitle, setDrawerTitle] = useState("New Word");
+
+  const showDeleteConfirm = (id: string[], title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_2<IUserDictDeleteRequest, IDeleteResponse>(
+          `/api/v2/userdict/${id}`,
+          {
+            id: JSON.stringify(id),
+          }
+        )
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e: unknown) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      <ProTable<IWord, IParams>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 80,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.word.label",
+            }),
+            dataIndex: "word",
+            key: "word",
+            tooltip: "单词过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.type.label",
+            }),
+            dataIndex: "type",
+            key: "type",
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: { text: "全部", status: "Default" },
+              n: { text: "名词", status: "Default" },
+              ti: { text: "三性", status: "Processing" },
+              v: { text: "动词", status: "Success" },
+              ind: { text: "不变词", status: "Success" },
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.grammar.label",
+            }),
+            dataIndex: "grammar",
+            key: "grammar",
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.parent.label",
+            }),
+            dataIndex: "parent",
+            key: "parent",
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.meaning.label",
+            }),
+            dataIndex: "meaning",
+            key: "meaning",
+            tooltip: "意思过长会自动收缩",
+            ellipsis: true,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.note.label",
+            }),
+            dataIndex: "note",
+            key: "note",
+            search: false,
+            tooltip: "注释过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.factors.label",
+            }),
+            dataIndex: "factors",
+            key: "factors",
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.dict.shortname.label",
+            }),
+            dataIndex: "dict",
+            key: "dict",
+            hideInTable: view !== "all",
+            search: view !== "all" ? false : undefined,
+            render: (_text, row) => {
+              return row.dict?.shortname;
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.updated-at.label",
+            }),
+            key: "updated_at",
+            width: 200,
+            search: false,
+            dataIndex: "updated_at",
+            valueType: "date",
+            sorter: true,
+            render: (_text, row) => {
+              return (
+                <TimeShow
+                  updatedAt={row.updated_at}
+                  showIcon={false}
+                  showLabel={false}
+                />
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            hideInTable: view === "all",
+            width: 120,
+            valueType: "option",
+            render: (_text, row, index) => {
+              return [
+                <Dropdown.Button
+                  key={index}
+                  type="link"
+                  menu={{
+                    items: [
+                      {
+                        key: "remove",
+                        label: intl.formatMessage({
+                          id: "buttons.delete",
+                        }),
+                        icon: <DeleteOutlined />,
+                        danger: true,
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "share":
+                          break;
+                        case "remove":
+                          showDeleteConfirm([row.wordId], row.word);
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <Link
+                    onClick={() => {
+                      setWordId(row.wordId);
+                      setDrawerTitle(row.word);
+                      setIsEditOpen(true);
+                    }}
+                  >
+                    {intl.formatMessage({
+                      id: "buttons.edit",
+                    })}
+                  </Link>
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        rowSelection={
+          view === "all"
+            ? undefined
+            : {
+                // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+                // 注释该行则默认不显示下拉选项
+                selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+              }
+        }
+        tableAlertRender={
+          view === "all"
+            ? undefined
+            : ({ selectedRowKeys, onCleanSelected }) => (
+                <Space size={24}>
+                  <span>
+                    {intl.formatMessage({ id: "buttons.selected" })}
+                    {selectedRowKeys.length}
+                    <Button
+                      type="link"
+                      style={{ marginInlineStart: 8 }}
+                      onClick={onCleanSelected}
+                    >
+                      {intl.formatMessage({ id: "buttons.unselect" })}
+                    </Button>
+                  </span>
+                </Space>
+              )
+        }
+        tableAlertOptionRender={
+          view === "all"
+            ? undefined
+            : ({ selectedRowKeys, onCleanSelected }) => {
+                return (
+                  <Space size={16}>
+                    <Button
+                      type="link"
+                      onClick={() => {
+                        console.log(selectedRowKeys);
+                        showDeleteConfirm(
+                          selectedRowKeys.map((item) => item.toString()),
+                          selectedRowKeys.length + "个单词"
+                        );
+                        onCleanSelected();
+                      }}
+                    >
+                      批量删除
+                    </Button>
+                  </Space>
+                );
+              }
+        }
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+
+          let url = "/api/v2/userdict?";
+          switch (view) {
+            case "studio":
+              url += `view=studio&name=${studioName}`;
+              break;
+            case "all":
+              url += `view=all`;
+              break;
+            default:
+              break;
+          }
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+
+          url += params.keyword ? "&search=" + params.keyword : "";
+
+          url += params.word ? `&word=${params.word}` : "";
+          url += params.parent ? `&parent=${params.parent}` : "";
+          url += params.dict ? `&dict=${params.dict}` : "";
+          url += dictName
+            ? dictName !== "all"
+              ? `&dict=${dictName}`
+              : ""
+            : "";
+
+          url += getSorterUrl(sorter);
+
+          console.log(url);
+          const res = await get<IApiResponseDictList>(url);
+          const items: IWord[] = res.data.rows.map((item, id) => {
+            const id2 =
+              ((params.current || 1) - 1) * (params.pageSize || 20) + id + 1;
+            return {
+              sn: id2,
+              wordId: item.id,
+              word: item.word,
+              type: item.type,
+              grammar: item.grammar,
+              parent: item.parent,
+              meaning: item.mean,
+              note: item.note,
+              factors: item.factors,
+              dict: item.dict,
+              updated_at: item.updated_at,
+            };
+          });
+          return {
+            total: res.data.count,
+            success: true,
+            data: items,
+          };
+        }}
+        rowKey="wordId"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={{
+          filterType: "light",
+        }}
+        options={{
+          search: true,
+        }}
+        headerTitle=""
+        toolBarRender={
+          view === "all"
+            ? undefined
+            : () => [
+                <Button
+                  key="button"
+                  icon={<PlusOutlined />}
+                  type="primary"
+                  onClick={() => {
+                    setDrawerTitle("New word");
+                    setIsCreateOpen(true);
+                  }}
+                  disabled={true}
+                >
+                  {intl.formatMessage({ id: "buttons.create" })}
+                </Button>,
+              ]
+        }
+      />
+
+      <Drawer
+        title={drawerTitle}
+        placement="right"
+        open={isCreateOpen}
+        onClose={() => {
+          setIsCreateOpen(false);
+        }}
+        key="create"
+        style={{ maxWidth: "100%" }}
+        contentWrapperStyle={{ overflowY: "auto" }}
+        footer={null}
+      >
+        <DictCreate studio={studioName ? studioName : ""} />
+      </Drawer>
+      <Drawer
+        title={drawerTitle}
+        placement="right"
+        open={isEditOpen}
+        onClose={() => {
+          setIsEditOpen(false);
+        }}
+        key="edit"
+        style={{ maxWidth: "100%" }}
+        contentWrapperStyle={{ overflowY: "auto" }}
+        footer={null}
+      >
+        <DictEdit wordId={wordId} />
+      </Drawer>
+    </>
+  );
+};
+
+export default UserDictTableWidget;

+ 91 - 0
dashboard-v6/src/components/dict/WordCard.tsx

@@ -0,0 +1,91 @@
+import { Space, Typography } from "antd";
+
+import GrammarPop from "./GrammarPop";
+import WordCardByDict from "./WordCardByDict";
+import { useIntl } from "react-intl";
+import Community from "./Community";
+import TermCommunity from "../term/TermCommunity";
+import type { IWordCardData } from "../../api/dict";
+
+const { Title, Text } = Typography;
+
+interface IWidgetWordCard {
+  data: IWordCardData;
+}
+const WordCardWidget = ({ data }: IWidgetWordCard) => {
+  const intl = useIntl();
+  const caseList = data.case?.map((element) => {
+    return element.split("|").map((it, id) => {
+      if (it.slice(0, 1) === "@") {
+        const [showText, keyText] = it.slice(1).split("-");
+        return <GrammarPop key={id} gid={keyText} text={showText} />;
+      } else {
+        return <span key={id * 200}>{it}</span>;
+      }
+    });
+  });
+  return (
+    <>
+      <Title level={4} id={data.anchor}>
+        {data.word}
+      </Title>
+      {data.grammar.length > 0 ? (
+        <WordCardByDict
+          data={{
+            dictname: "语法信息",
+            description: "列出可能的语法信息供参考",
+            anchor: "anchor",
+          }}
+        >
+          <div>
+            <Text>
+              {data.grammar.length > 0 ? data.grammar[0].factors : ""}
+            </Text>
+          </div>
+          <div>
+            <Text>{data.parents}</Text>
+          </div>
+          <div>
+            {data.grammar
+              .filter((item) => item.confidence > 0.5)
+              .map((it, id) => {
+                const grammar = it.grammar.split("$");
+                const grammarGuide = grammar.map((item, id) => {
+                  const strCase = item.replaceAll(".", "");
+                  return (
+                    <GrammarPop
+                      key={id}
+                      gid={strCase}
+                      text={intl.formatMessage({
+                        id: `dict.fields.type.${strCase}.label`,
+                        defaultMessage: strCase,
+                      })}
+                    />
+                  );
+                });
+                return (
+                  <div key={id}>
+                    <Space>{grammarGuide}</Space>
+                  </div>
+                );
+              })}
+          </div>
+          <div>
+            <Text>{caseList}</Text>
+          </div>
+        </WordCardByDict>
+      ) : (
+        <></>
+      )}
+      <Community word={data.word} />
+      <TermCommunity word={data.word} />
+      <div>
+        {data.dict.map((it, id) => {
+          return <WordCardByDict key={id} data={it} />;
+        })}
+      </div>
+    </>
+  );
+};
+
+export default WordCardWidget;

+ 79 - 0
dashboard-v6/src/components/dict/WordCardByDict.tsx

@@ -0,0 +1,79 @@
+import { Button, Card, Popover, Space, Tabs } from "antd";
+import { Typography } from "antd";
+import { InfoCircleOutlined } from "@ant-design/icons";
+
+import Marked from "../general/Marked";
+
+import "./style.css";
+import DictInfoCopyRef from "./DictInfoCopyRef";
+import MdView from "../general/MdView";
+import type { IWordByDict } from "../../api/dict";
+
+const { Title, Text } = Typography;
+
+interface IWidgetWordCardByDict {
+  data: IWordByDict;
+  children?: React.ReactNode;
+}
+const WordCardByDictWidget = ({ data, children }: IWidgetWordCardByDict) => {
+  return (
+    <Card>
+      <Space>
+        <Title level={5} id={data.anchor}>
+          {data.dictname}
+        </Title>
+        <Popover
+          overlayStyle={{ maxWidth: 600 }}
+          content={
+            <div>
+              <Tabs
+                size="small"
+                style={{ width: 600 }}
+                items={[
+                  {
+                    label: "详情",
+                    key: "info",
+                    children: (
+                      <div>
+                        <div>
+                          <Text strong>{data.dictname}</Text>
+                        </div>
+                        <div>
+                          <Text type="secondary">Author:</Text>
+                          <Text>{data.meta?.author}</Text>
+                        </div>
+                        <div>
+                          <Text type="secondary">Publish:</Text>
+                          <Text>{data.meta?.publisher}</Text>
+                        </div>
+                        <div>
+                          <Text type="secondary">At:</Text>
+                          <Text>{data.meta?.published}</Text>
+                        </div>
+                        <Marked text={data.description} />
+                      </div>
+                    ),
+                  },
+                  {
+                    label: "复制引用信息",
+                    key: "reference",
+                    children: <DictInfoCopyRef data={data} />,
+                  },
+                ]}
+              />
+            </div>
+          }
+          placement="bottom"
+        >
+          <Button type="link" icon={<InfoCircleOutlined />} />
+        </Popover>
+      </Space>
+      <div className="dict_content">
+        <MdView html={data.note} />
+      </div>
+      {children}
+    </Card>
+  );
+};
+
+export default WordCardByDictWidget;

+ 328 - 0
dashboard-v6/src/components/dict/caseOptions.ts

@@ -0,0 +1,328 @@
+// caseOptions.ts
+// caseOptions.ts
+import type { IntlShape } from "react-intl";
+export interface CascaderOption {
+  value: string | number;
+  label: string;
+  children?: CascaderOption[];
+}
+export const buildCaseOptions = (intl: IntlShape) => {
+  const t = (id: string) => intl.formatMessage({ id });
+
+  const case8 = [
+    {
+      value: "nom",
+      label: t("dict.fields.type.nom.label"),
+    },
+    {
+      value: "acc",
+      label: t("dict.fields.type.acc.label"),
+    },
+    {
+      value: "gen",
+      label: t("dict.fields.type.gen.label"),
+    },
+    {
+      value: "dat",
+      label: t("dict.fields.type.dat.label"),
+    },
+    {
+      value: "inst",
+      label: t("dict.fields.type.inst.label"),
+    },
+    {
+      value: "abl",
+      label: t("dict.fields.type.abl.label"),
+    },
+    {
+      value: "loc",
+      label: t("dict.fields.type.loc.label"),
+    },
+    {
+      value: "voc",
+      label: t("dict.fields.type.voc.label"),
+    },
+    {
+      value: "?",
+      label: t("dict.fields.type.?.label"),
+    },
+  ];
+  const case2 = [
+    {
+      value: "sg",
+      label: t("dict.fields.type.sg.label"),
+      children: case8,
+    },
+    {
+      value: "pl",
+      label: t("dict.fields.type.pl.label"),
+      children: case8,
+    },
+    {
+      value: "?",
+      label: t("dict.fields.type.?.label"),
+    },
+  ];
+  const case3 = [
+    {
+      value: "m",
+      label: t("dict.fields.type.m.label"),
+      children: case2,
+    },
+    {
+      value: "nt",
+      label: t("dict.fields.type.nt.label"),
+      children: case2,
+    },
+    {
+      value: "f",
+      label: t("dict.fields.type.f.label"),
+      children: case2,
+    },
+  ];
+  const case3_ti = [
+    ...case3,
+    {
+      value: "base",
+      label: t("dict.fields.type.base.label"),
+      children: [
+        {
+          value: "base",
+          label: t("dict.fields.type.base.label"),
+        },
+        {
+          value: "prp",
+          label: t("dict.fields.type.prp.label"),
+        },
+        {
+          value: "pp",
+          label: t("dict.fields.type.pp.label"),
+        },
+        {
+          value: "fpp",
+          label: t("dict.fields.type.fpp.label"),
+        },
+      ],
+    },
+  ];
+  const case3_pron = [
+    ...case3,
+    {
+      value: "1p",
+      label: t("dict.fields.type.1p.label"),
+      children: case2,
+    },
+    {
+      value: "2p",
+      label: t("dict.fields.type.2p.label"),
+      children: case2,
+    },
+    {
+      value: "3p",
+      label: t("dict.fields.type.3p.label"),
+      children: case2,
+    },
+    {
+      value: "base",
+      label: t("dict.fields.type.base.label"),
+    },
+  ];
+  const case3_n = [
+    ...case3,
+    {
+      value: "base",
+      label: t("dict.fields.type.base.label"),
+      children: [
+        {
+          value: "m",
+          label: t("dict.fields.type.m.label"),
+        },
+        {
+          value: "nt",
+          label: t("dict.fields.type.nt.label"),
+        },
+        {
+          value: "f",
+          label: t("dict.fields.type.f.label"),
+        },
+      ],
+    },
+  ];
+  const case3_num = [
+    ...case3,
+    {
+      value: "base",
+      label: t("dict.fields.type.base.label"),
+    },
+  ];
+  const caseVerb3 = [
+    {
+      value: "pres",
+      label: t("dict.fields.type.pres.label"),
+    },
+    {
+      value: "aor",
+      label: t("dict.fields.type.aor.label"),
+    },
+    {
+      value: "fut",
+      label: t("dict.fields.type.fut.label"),
+    },
+    {
+      value: "pf",
+      label: t("dict.fields.type.pf.label"),
+    },
+    {
+      value: "imp",
+      label: t("dict.fields.type.imp.label"),
+    },
+    {
+      value: "cond",
+      label: t("dict.fields.type.cond.label"),
+    },
+    {
+      value: "opt",
+      label: t("dict.fields.type.opt.label"),
+    },
+  ];
+  const caseVerb2 = [
+    {
+      value: "sg",
+      label: t("dict.fields.type.sg.label"),
+      children: caseVerb3,
+    },
+    {
+      value: "pl",
+      label: t("dict.fields.type.pl.label"),
+      children: caseVerb3,
+    },
+  ];
+  const caseVerbInd = [
+    {
+      value: "abs",
+      label: t("dict.fields.type.abs.label"),
+    },
+    {
+      value: "ger",
+      label: t("dict.fields.type.ger.label"),
+    },
+    {
+      value: "inf",
+      label: t("dict.fields.type.inf.label"),
+    },
+  ];
+  const caseInd = [
+    {
+      value: "ind",
+      label: t("dict.fields.type.ind.label"),
+    },
+    {
+      value: "adv",
+      label: t("dict.fields.type.adv.label"),
+    },
+    {
+      value: "conj",
+      label: t("dict.fields.type.conj.label"),
+    },
+    {
+      value: "interj",
+      label: t("dict.fields.type.interj.label"),
+    },
+  ];
+  const caseOthers = [
+    {
+      value: "pre",
+      label: t("dict.fields.type.pre.label"),
+    },
+    {
+      value: "suf",
+      label: t("dict.fields.type.suf.label"),
+    },
+    {
+      value: "end",
+      label: t("dict.fields.type.end.label"),
+    },
+    {
+      value: "part",
+      label: t("dict.fields.type.part.label"),
+    },
+    {
+      value: "note",
+      label: t("dict.fields.type.note.label"),
+    },
+  ];
+  const caseVerb1 = [
+    {
+      value: "1p",
+      label: t("dict.fields.type.1p.label"),
+      children: caseVerb2,
+    },
+    {
+      value: "2p",
+      label: t("dict.fields.type.2p.label"),
+      children: caseVerb2,
+    },
+    {
+      value: "3p",
+      label: t("dict.fields.type.3p.label"),
+      children: caseVerb2,
+    },
+    {
+      value: "ind",
+      label: t("dict.fields.type.ind.label"),
+      children: caseVerbInd,
+    },
+    {
+      value: "base",
+      label: t("dict.fields.type.base.label"),
+    },
+  ];
+  const options: CascaderOption[] = [
+    {
+      value: "n",
+      label: t("dict.fields.type.n.label"),
+      children: case3_n,
+    },
+    {
+      value: "ti",
+      label: t("dict.fields.type.ti.label"),
+      children: case3_ti,
+    },
+    {
+      value: "v",
+      label: t("dict.fields.type.v.label"),
+      children: caseVerb1,
+    },
+    {
+      value: "ind",
+      label: t("dict.fields.type.ind.label"),
+      children: caseInd,
+    },
+    {
+      value: "pron",
+      label: t("dict.fields.type.pron.label"),
+      children: case3_pron,
+    },
+    {
+      value: "num",
+      label: t("dict.fields.type.num.label"),
+      children: case3_num,
+    },
+    {
+      value: "un",
+      label: t("dict.fields.type.un.label"),
+    },
+    {
+      value: "adj",
+      label: t("dict.fields.type.adj.label"),
+      children: case3_ti,
+    },
+    {
+      value: "others",
+      label: t("dict.fields.type.others.label"),
+      children: caseOthers,
+    },
+  ];
+
+  return options;
+};

+ 76 - 0
dashboard-v6/src/components/dict/hooks/useDict.ts

@@ -0,0 +1,76 @@
+// hooks/useDict.ts
+// 承载状态管理、异步请求、错误处理,不含 JSX
+// 模式对齐 useArticle:async/await + HttpError + errorCode/errorMessage + refresh
+
+import { useState, useEffect, useCallback } from "react";
+import { fetchDictByWord, type IDictContentData } from "../../../api/dict";
+import { HttpError } from "../../../request";
+
+const DEFAULT_DATA: IDictContentData = {
+  dictlist: [],
+  words: [],
+  caselist: [],
+};
+
+interface IUseDictReturn {
+  data: IDictContentData;
+  loading: boolean;
+  errorCode: number | null;
+  errorMessage: string | null;
+  refresh: () => void;
+}
+
+export const useDict = (word: string | undefined): IUseDictReturn => {
+  const [data, setData] = useState<IDictContentData>(DEFAULT_DATA);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [tick, setTick] = useState(0);
+
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  useEffect(() => {
+    if (!word) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      setLoading(true);
+      setErrorCode(null);
+      setErrorMessage(null);
+
+      try {
+        const res = await fetchDictByWord(word);
+        if (!active) return;
+
+        if (!res.ok) {
+          setErrorCode(400);
+          setErrorMessage(res.message);
+          return;
+        }
+
+        setData(res.data);
+      } catch (e) {
+        console.error("[useDict] fetch failed:", e);
+        if (!active) return;
+        if (e instanceof HttpError) {
+          setErrorCode(e.status);
+          setErrorMessage(e.message);
+        } else {
+          setErrorCode(0); // 0 表示网络层错误
+          setErrorMessage("Network error");
+        }
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [word, tick]);
+
+  return { data, loading, errorCode, errorMessage, refresh };
+};

+ 4 - 0
dashboard-v6/src/components/dict/style.css

@@ -0,0 +1,4 @@
+.dict_content img {
+  max-width: 100%;
+  width: 100vw;
+}

+ 19 - 3
dashboard-v6/src/components/dict/utils.ts

@@ -1,9 +1,11 @@
 import type {
   IDictRequest,
   IDictResponse,
+  IPreferenceRequest,
+  IPreferenceResponse,
   IUserDictCreate,
-} from "../../api/Dict";
-import { post } from "../../request";
+} from "../../api/dict";
+import { post, put } from "../../request";
 
 export const UserWbwPost = (data: IDictRequest[], view: string) => {
   let wordData: IDictRequest[] = data;
@@ -80,8 +82,22 @@ export const UserWbwPost = (data: IDictRequest[], view: string) => {
       wordData = [...wordData, ...factors];
     }
   });
-  return post<IUserDictCreate, IDictResponse>("/v2/userdict", {
+  return post<IUserDictCreate, IDictResponse>("/api/v2/userdict", {
     view: view,
     data: JSON.stringify(wordData),
   });
 };
+
+export const setValue = async (id: string, value: number) => {
+  const url = `/api/v2/dict-preference/${id}`;
+  const values: IPreferenceRequest = {
+    confidence: value,
+  };
+  console.debug("api request", url, values);
+
+  const result = await put<IPreferenceRequest, IPreferenceResponse>(
+    url,
+    values
+  );
+  return result;
+};

+ 59 - 1
dashboard-v6/src/components/general/SplitLayout/RightToolbar.module.css

@@ -1,8 +1,65 @@
 /* ─────────────────────────────────────────────
    RightToolbar.module.css
-   纵向图标工具栏,固定宽度,居中排列
+   面板内容区 + 纵向图标工具栏
    ───────────────────────────────────────────── */
 
+/* 外层容器:flex row,面板在左,工具栏在右 */
+.container {
+  display: flex;
+  flex-direction: row;
+  height: 100%;
+  overflow: hidden;
+}
+
+/* ── 面板内容区 ── */
+.panel {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  min-width: 0;
+  border-left: 1px solid var(--ant-color-split, #f0f0f0);
+}
+
+.panelHeader {
+  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;
+}
+
+.panelTitle {
+  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;
+}
+
+.closeBtn {
+  flex-shrink: 0;
+  color: var(--ant-color-text-secondary, rgba(0, 0, 0, 0.45));
+}
+.closeBtn:hover {
+  color: var(--ant-color-primary, #1677ff) !important;
+  background: var(--ant-color-primary-bg, #e6f4ff) !important;
+}
+
+.panelBody {
+  overflow-y: scroll;
+  overflow-x: hidden;
+  height: calc(100vh - 96px);
+  /* display:contents 的子项可直接撑满 */
+  display: flex;
+  flex-direction: column;
+}
+
+/* ── 纵向图标工具栏 ── */
 .toolbar {
   display: flex;
   flex-direction: column;
@@ -15,6 +72,7 @@
   border-left: 1px solid var(--ant-color-split, #f0f0f0);
   background: var(--ant-color-bg-container, #fff);
   box-sizing: border-box;
+  overflow-y: auto;
 }
 
 .tabBtn {

+ 81 - 17
dashboard-v6/src/components/general/SplitLayout/RightToolbar.tsx

@@ -1,44 +1,108 @@
+import { CloseOutlined } from "@ant-design/icons";
 import { Button, Tooltip } from "antd";
 import type { ReactNode } from "react";
 import styles from "./RightToolbar.module.css";
 
 export interface RightToolbarTab {
-  /** 唯一 key,对应面板内容 */
+  /** 唯一 key */
   key: string;
   /** 图标 */
   icon: ReactNode;
-  /** Tooltip 提示文字 */
+  /** Tooltip 提示文字 & 面板标题 */
   label: string;
+  /**
+   * 面板内容(可选)。
+   * - 首次点击时懒创建,之后切换/关闭只隐藏不销毁。
+   * - 不传则点击图标不展开面板(纯按钮行为)。
+   */
+  content?: ReactNode;
 }
 
 interface RightToolbarProps {
   tabs: RightToolbarTab[];
   /** 当前激活的 tab key,null 表示面板已关闭 */
   activeKey: string | null;
-  /** 点击图标时回调:已激活则关闭(传 null),未激活则打开 */
   onTabClick: (key: string) => void;
+  onClose: () => void;
 }
 
 export default function RightToolbar({
   tabs,
   activeKey,
   onTabClick,
+  onClose,
 }: RightToolbarProps) {
+  // 记录哪些 tab 曾经被打开过(懒创建:首次点击后才挂载 DOM)
+  // 使用模块级 WeakMap 不行,用组件内 Set ref 也可以,
+  // 但最简单的方式是直接在渲染时用 Set 累积 —— 由于 activeKey 驱动,
+  // 这里改用 rendered Set 存在 closure 里不合适,改用 useState 的 Set。
+  // 注意:Set 是引用类型,useState 里直接 mutate 需要注意,
+  // 这里每次 add 都返回新 Set 保证 immutability。
+  const [mounted, setMounted] = React.useState<Set<string>>(new Set());
+
+  // 当 activeKey 变化时,将新 key 加入 mounted set(懒创建)
+  React.useEffect(() => {
+    if (activeKey && !mounted.has(activeKey)) {
+      setMounted((prev) => new Set([...prev, activeKey]));
+    }
+  }, [activeKey, mounted]);
+
+  const activeTab = tabs.find((t) => t.key === activeKey);
+  const panelOpen = activeKey !== null && !!activeTab?.content;
+
   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 className={styles.container}>
+      {/* ── 面板内容区 ── */}
+      {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>
+
+          {/* 各 tab 内容:懒创建,隐藏不销毁 */}
+          <div className={styles.panelBody}>
+            {tabs
+              .filter((t) => t.content && mounted.has(t.key))
+              .map((t) => (
+                <div
+                  key={t.key}
+                  style={{ display: t.key === activeKey ? "contents" : "none" }}
+                >
+                  {t.content}
+                </div>
+              ))}
+          </div>
+        </div>
+      )}
+
+      {/* ── 图标工具栏 ── */}
+      <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>
     </div>
   );
 }
+
+// React import(需要 useEffect / useState)
+import React from "react";

+ 9 - 58
dashboard-v6/src/components/general/SplitLayout/SplitLayout.module.css

@@ -2,41 +2,37 @@
    SplitLayout.module.css
    ───────────────────────────────────────────── */
 
-/* 整体容器:flex row,撑满父元素 */
 .root {
   display: flex;
   flex-direction: row;
+  align-items: stretch;   /* 子项撑满高度,防止上下排列 */
   width: 100%;
   height: 100%;
-  min-height: 0;   /* 防止 flex 子项撑破高度导致换行 */
+  min-height: 0;
   overflow: hidden;
 }
 
 /* ── 左侧面板 ── */
 .sidebar {
   flex-shrink: 0;
+  min-width: 0;           /* 允许宽度收缩到 0,防止撑破 flex 行 */
   display: flex;
   flex-direction: column;
   border-right: 1px solid var(--ant-color-split, #f0f0f0);
   transition: width 0.15s ease;
-  /* overflow visible:收起时按钮不被裁切 */
-  overflow: visible;
-  position: relative;
-  z-index: 1;
+  box-sizing: border-box;
 }
 
-/* 标题行:始终渲染 */
 .sidebarHeader {
   display: flex;
   align-items: center;
-  justify-content: flex-end;   /* 按钮始终靠右对齐 */
+  justify-content: space-between;
   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 {
@@ -66,27 +62,26 @@
   min-height: 0;
 }
 
-/* ── 右侧主区域(flex:1,容纳 Splitter)── */
+/* ── 右侧主区域 ── */
 .mainArea {
   flex: 1;
   min-width: 0;
   min-height: 0;
+  height: 100%;
   overflow: hidden;
 }
 
-/* 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;
@@ -96,52 +91,8 @@
   background: var(--ant-color-primary-bg, #e6f4ff) !important;
 }
 
-/* ── 右边栏 Splitter.Panel 容器 ── */
+/* 右边栏 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;
-}

+ 58 - 100
dashboard-v6/src/components/general/SplitLayout/SplitLayout.tsx

@@ -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 { useCallback, useState, type ReactNode } from "react";
 import RightToolbar, { type RightToolbarTab } from "./RightToolbar";
@@ -16,12 +12,19 @@ import {
 // 常量:手工调整左侧栏宽度
 // ─────────────────────────────────────────────
 
-/** 左侧面板固定宽度(px)。左侧栏不参与拖拽,修改此值即可调整宽度。 */
-const SIDEBAR_WIDTH = 260;
+/** 左侧面板固定宽度(px)。修改此值即可调整宽度,左侧栏不参与拖拽。 */
+const SIDEBAR_WIDTH = 280;
 
 /** 右边工具栏固定宽度(px) */
 const TOOLBAR_WIDTH = 40;
 
+// ─────────────────────────────────────────────
+// localStorage keys
+// ─────────────────────────────────────────────
+
+const COLLAPSED_KEY = "split-layout:sidebar-collapsed";
+const RIGHT_WIDTH_KEY = "split-layout:right-panel-width";
+
 // ─────────────────────────────────────────────
 // Props
 // ─────────────────────────────────────────────
@@ -29,23 +32,20 @@ const TOOLBAR_WIDTH = 40;
 export interface SplitLayoutProps {
   /** 左侧面板标题,支持任意 ReactNode */
   sidebarTitle: ReactNode;
-  /** 左侧面板内容 */
+  /** 左侧面板内容(收起时隐藏不销毁,避免重复 fetch) */
   sidebar: ReactNode;
   /**
    * 中间内容区。支持两种用法:
    *
-   * 方案 A — Render Props,框架把 expandButton 作为参数传入
+   * 方案 A — Render Props:
    * ```tsx
    * <SplitLayout ...>
    *   {({ expandButton }) => <MyPage headerExtra={expandButton} />}
    * </SplitLayout>
    * ```
-   *
-   * 方案 B — 普通 ReactNode,内部自己调用 useSplitLayout():
+   * 方案 B — 普通 ReactNode,内部调用 useSplitLayout():
    * ```tsx
-   * <SplitLayout ...>
-   *   <ComplexPage />
-   * </SplitLayout>
+   * <SplitLayout ...><ComplexPage /></SplitLayout>
    * ```
    */
   children:
@@ -53,22 +53,13 @@ export interface SplitLayoutProps {
     | ((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[];
-  /**
-   * 右边栏面板内容映射,key 对应 rightTabs 中的 key。
-   * ```tsx
-   * rightPanels={{ chat: <ChatPanel />, search: <SearchPanel /> }}
-   * ```
-   */
-  rightPanels?: Record<string, ReactNode>;
+
   /** 右边栏面板默认宽度(px),默认 500 */
   defaultRightSize?: number;
   /** 右边栏面板最小宽度(px),默认 280 */
@@ -86,13 +77,11 @@ export default function SplitLayout({
   sidebar,
   children,
   rightTabs,
-  rightPanels,
-  defaultRightSize = 400,
+  defaultRightSize = 500,
   minRightSize = 280,
   maxRightSize = 800,
 }: SplitLayoutProps) {
-  // ── 左侧收起状态(持久化到 localStorage)──
-  const COLLAPSED_KEY = "split-layout:sidebar-collapsed";
+  // ── 左侧收起状态(持久化)──
   const [collapsed, setCollapsed] = useState<boolean>(() => {
     try {
       return localStorage.getItem(COLLAPSED_KEY) === "true";
@@ -100,13 +89,14 @@ export default function SplitLayout({
       return false;
     }
   });
+
   const toggle = useCallback(() => {
     setCollapsed((v) => {
       const next = !v;
       try {
         localStorage.setItem(COLLAPSED_KEY, String(next));
       } catch {
-        // 静默降级
+        /* 静默降级 */
       }
       return next;
     });
@@ -147,11 +137,10 @@ export default function SplitLayout({
   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);
+      const stored = localStorage.getItem(RIGHT_WIDTH_KEY);
       if (stored) {
         const parsed = Number(stored);
         if (
@@ -163,12 +152,11 @@ export default function SplitLayout({
         }
       }
     } catch {
-      // localStorage 不可用(隐私模式等),静默降级
+      /* 静默降级 */
     }
     return defaultRightSize;
   });
 
-  // Splitter sizes 受控:关闭时固定工具栏宽,展开时恢复持久化宽度
   const rightSize = rightOpen ? rightPanelWidth + TOOLBAR_WIDTH : TOOLBAR_WIDTH;
 
   const handleSplitterResize = useCallback(
@@ -178,9 +166,9 @@ export default function SplitLayout({
         if (contentWidth > 0) {
           setRightPanelWidth(contentWidth);
           try {
-            localStorage.setItem(STORAGE_KEY, String(contentWidth));
+            localStorage.setItem(RIGHT_WIDTH_KEY, String(contentWidth));
           } catch {
-            // 静默降级
+            /* 静默降级 */
           }
         }
       }
@@ -190,34 +178,32 @@ export default function SplitLayout({
 
   return (
     <SplitLayoutContext.Provider value={ctx}>
-      {/*
-       * 整体布局: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
-                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 className={styles.sidebarContent}>{sidebar}</div>
+        </div>
 
-        {/* ── 右侧主区域:单个 Splitter lazy(中间 ↔ 右边栏)── */}
+        {/* ── 右侧主区域 ── */}
         <div className={styles.mainArea}>
           {hasRight ? (
             <Splitter
@@ -230,7 +216,7 @@ export default function SplitLayout({
                 {centerContent}
               </Splitter.Panel>
 
-              {/* 右边栏 */}
+              {/* 右边栏:RightToolbar 内部管理面板的懒创建与隐藏 */}
               <Splitter.Panel
                 size={rightSize}
                 min={minRightSize + TOOLBAR_WIDTH}
@@ -238,43 +224,15 @@ export default function SplitLayout({
                 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>
-                  )}
-
-                  {/* 工具栏:始终可见,固定在最右侧 */}
-                  <RightToolbar
-                    tabs={rightTabs!}
-                    activeKey={rightActiveKey}
-                    onTabClick={onRightTabClick}
-                  />
-                </div>
+                <RightToolbar
+                  tabs={rightTabs!}
+                  activeKey={rightActiveKey}
+                  onTabClick={onRightTabClick}
+                  onClose={closeRightPanel}
+                />
               </Splitter.Panel>
             </Splitter>
           ) : (
-            // 没有右边栏时,中间内容直接撑满
             <div className={styles.centerPanel}>{centerContent}</div>
           )}
         </div>

+ 79 - 76
dashboard-v6/src/components/general/SplitLayout/SplitLayoutTest.tsx

@@ -97,84 +97,89 @@ workers:
 // ─────────────────────────────────────────────
 
 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>
-        ))}
+  {
+    key: "chat",
+    icon: <CommentOutlined />,
+    label: "对话",
+    content: (
+      <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>
-    </div>
-  ),
-  search: (
-    <div style={{ padding: 16 }}>
-      <Input.Search placeholder="搜索文件内容..." size="small" />
-      <div style={{ marginTop: 12 }}>
+    ),
+  },
+  {
+    key: "search",
+    icon: <SearchOutlined />,
+    label: "搜索",
+    content: (
+      <div style={{ padding: 16 }}>
+        <Input.Search placeholder="搜索文件内容..." size="small" />
+        <div style={{ marginTop: 12 }}>
+          <Typography.Text type="secondary" style={{ fontSize: 12 }}>
+            搜索结果将显示在此处
+          </Typography.Text>
+        </div>
+      </div>
+    ),
+  },
+  {
+    key: "debug",
+    icon: <BugOutlined />,
+    label: "调试",
+    content: (
+      <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>
-    </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>
-  ),
-};
+    ),
+  },
+];
 
 // ─────────────────────────────────────────────
 // 共用样式
@@ -348,7 +353,6 @@ export default function SplitLayoutTest() {
           sidebarTitle="mint / deploy"
           sidebar={<MockSidebar />}
           rightTabs={rightTabs}
-          rightPanels={rightPanels}
         >
           {({ expandButton }) => <MockContentA headerExtra={expandButton} />}
         </SplitLayout>
@@ -361,7 +365,6 @@ export default function SplitLayoutTest() {
           sidebarTitle="mint / deploy"
           sidebar={<MockSidebar />}
           rightTabs={rightTabs}
-          rightPanels={rightPanels}
         >
           <MockContentB />
         </SplitLayout>

+ 1 - 1
dashboard-v6/src/components/wbw/WbwDetailFactor.tsx

@@ -9,7 +9,7 @@ import {
 } from "../../reducers/inline-dict";
 import { get } from "../../request";
 import store from "../../store";
-import type { IApiResponseDictList } from "../../api/Dict";
+import type { IApiResponseDictList } from "../../api/dict";
 
 import type { IWbw } from "../../types/wbw";
 import { getFactorsInDict } from "./utils";

+ 1 - 1
dashboard-v6/src/components/wbw/WbwFactorsEditor.tsx

@@ -3,7 +3,7 @@ import { Space } from "antd";
 import { LoadingOutlined, WarningOutlined } from "@ant-design/icons";
 
 import WbwFactors from "./WbwFactors";
-import type { IPreferenceResponse } from "../../api/Dict";
+import type { IPreferenceResponse } from "../../api/dict";
 import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
 
 interface IWidget {

+ 1 - 1
dashboard-v6/src/components/wbw/WbwLookup.tsx

@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
 import { useAppSelector } from "../../hooks";
 import { add, updateIndex, wordIndex } from "../../reducers/inline-dict";
 import { get } from "../../request";
-import type { IApiResponseDictList } from "../../api/Dict";
+import type { IApiResponseDictList } from "../../api/dict";
 import store from "../../store";
 
 interface IWidget {

+ 1 - 1
dashboard-v6/src/components/wbw/WbwParentEditor.tsx

@@ -1,7 +1,7 @@
 import { useState } from "react";
 import { LoadingOutlined, WarningOutlined } from "@ant-design/icons";
 
-import type { IPreferenceResponse } from "../../api/Dict";
+import type { IPreferenceResponse } from "../../api/dict";
 import { Space } from "antd";
 import WbwParent from "./WbwParent";
 import type { IWbw, TWbwDisplayMode } from "../../types/wbw";

+ 1 - 1
dashboard-v6/src/components/wbw/WbwSentCtl.tsx

@@ -35,7 +35,7 @@ import type { ISentenceWbwListResponse } from "../../api/sentence";
 import type { IStudio } from "../../api/Auth";
 import { useWbwStreamProcessor } from "../../hooks/useWbwStreamProcessor";
 import { GetUserSetting } from "../setting/default";
-import type { IDictRequest } from "../../api/Dict";
+import type { IDictRequest } from "../../api/dict";
 import { UserWbwPost } from "../dict/utils";
 import type { IDeleteResponse } from "../../api/Group";
 import Studio from "../auth/Studio";

+ 1 - 1
dashboard-v6/src/components/wbw/WbwWord.tsx

@@ -5,7 +5,7 @@ import { add, updateIndex, wordIndex } from "../../reducers/inline-dict";
 import { get } from "../../request";
 import store from "../../store";
 
-import type { IApiResponseDictList } from "../../api/Dict";
+import type { IApiResponseDictList } from "../../api/dict";
 import WbwCase from "./WbwCase";
 
 import WbwFactorMeaning from "./WbwFactorMeaning";

+ 1 - 1
dashboard-v6/src/components/wbw/utils.ts

@@ -1,6 +1,6 @@
 import type { IntlShape } from "react-intl";
 import type { MenuProps } from "antd";
-import type { IApiResponseDictData } from "../../api/Dict";
+import type { IApiResponseDictData } from "../../api/dict";
 import type { IWbw, TFieldName } from "../../types/wbw";
 
 export const caseInDict = (

+ 63 - 1
dashboard-v6/src/pages/workspace/article/show.tsx

@@ -1,9 +1,18 @@
 import { useNavigate, useParams, useSearchParams } from "react-router";
 import TypeArticle from "../../../components/article/TypeArticle";
-import SplitLayout from "../../../components/general/SplitLayout";
+import SplitLayout, {
+  type RightToolbarTab,
+} from "../../../components/general/SplitLayout";
 import type { ArticleMode } from "../../../api/Article";
 import AnthologyTocTree from "../../../components/anthology/AnthologyTocTree";
 
+import {
+  BugOutlined,
+  SearchOutlined,
+  CommentOutlined,
+} from "@ant-design/icons";
+import DictComponent from "../../../components/dict/DictComponent";
+
 const Widget = () => {
   const { articleId, anthologyId } = useParams();
   const [searchParams] = useSearchParams();
@@ -12,6 +21,58 @@ const Widget = () => {
   const channelId = searchParams.get("channel");
   const anthology = searchParams.get("anthology");
 
+  // ─────────────────────────────────────────────
+  // 右边栏 tabs 配置
+  // ─────────────────────────────────────────────
+
+  const rightTabs: RightToolbarTab[] = [
+    {
+      key: "dict",
+      icon: <SearchOutlined />,
+      label: "字典",
+      content: (
+        <div className="dict_component">
+          <DictComponent />
+        </div>
+      ),
+    },
+    {
+      key: "search",
+      icon: <CommentOutlined />,
+      label: "搜索",
+      content: (
+        <div style={{ padding: 16 }}>
+          <div style={{ marginTop: 12 }}></div>
+        </div>
+      ),
+    },
+    {
+      key: "debug",
+      icon: <BugOutlined />,
+      label: "调试",
+      content: (
+        <div style={{ padding: 16 }}>
+          <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>
+      ),
+    },
+  ];
+
   return (
     <SplitLayout
       key="mode-a"
@@ -36,6 +97,7 @@ const Widget = () => {
           <></>
         )
       }
+      rightTabs={rightTabs}
     >
       {({ expandButton }) => (
         <TypeArticle

+ 1 - 1
dashboard-v6/src/reducers/command.ts

@@ -4,7 +4,7 @@
 import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
-import type { IDict } from "../api/Dict";
+import type { IDict } from "../api/dict";
 import type { ITerm } from "../api/Term";
 
 export interface ICommand {

+ 1 - 1
dashboard-v6/src/reducers/inline-dict.ts

@@ -1,7 +1,7 @@
 import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
-import type { IApiResponseDictData } from "../api/Dict";
+import type { IApiResponseDictData } from "../api/dict";
 
 /**
  * 在查询字典后,将查询结果放入map

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

@@ -6,7 +6,13 @@ export type EApiStatus =
   | "success" // 初始未检测
   | "fail" // 检测中
   | "loading"; // 网络 + API 均正常
+export interface IApiStatus {
+  /** 当前状态 */
+  status: EApiStatus;
 
+  /** 可选描述信息(错误原因、HTTP 状态码等) */
+  message?: string;
+}
 /**
  * 网络状态枚举
  *
@@ -29,7 +35,7 @@ export type ENetStatus =
 export interface INetStatus {
   /** 当前状态 */
   status: ENetStatus;
-  api_status?: EApiStatus;
+
   /** 可选描述信息(错误原因、HTTP 状态码等) */
   message?: string;
   /** 上次成功检测时间(ISO string) */
@@ -42,12 +48,12 @@ export interface INetStatus {
 
 interface IState {
   status: INetStatus;
+  api_status?: IApiStatus;
 }
 
 const initialState: IState = {
   status: {
     status: "idle",
-    api_status: "pending",
   },
 };