visuddhinanda 1 月之前
父节点
当前提交
83775cf45c

+ 7 - 7
dashboard-v6/src/components/article/components/Navigate.tsx

@@ -42,14 +42,14 @@ const NavigateWidget = ({
 
   useEffect(() => {
     if (type && articleId) {
-      get<INavResponse>(`/v2/article-nav?type=${type}&id=${articleId}`).then(
-        (json) => {
-          if (json.ok) {
-            setPrev(json.data.prev);
-            setNext(json.data.next);
-          }
+      get<INavResponse>(
+        `/api/v2/article-nav?type=${type}&id=${articleId}`
+      ).then((json) => {
+        if (json.ok) {
+          setPrev(json.data.prev);
+          setNext(json.data.next);
         }
-      );
+      });
     }
   }, [articleId, type]);
 

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

@@ -0,0 +1,24 @@
+import { Tag } from "antd";
+
+interface IQaCtl {
+  value?: string;
+}
+const ConfidenceCtl = ({ value = "0%" }: IQaCtl) => {
+  const confidence = parseFloat(value.replace("%", "")); // 结果为 90
+  return <Tag color={confidence < 90 ? "red" : "green"}>{value}</Tag>;
+};
+
+interface IWidget {
+  props: string;
+}
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IQaCtl;
+  console.log(prop);
+  return (
+    <>
+      <ConfidenceCtl {...prop} />
+    </>
+  );
+};
+
+export default Widget;

+ 29 - 0
dashboard-v6/src/components/template/GrammarTermLookup.tsx

@@ -0,0 +1,29 @@
+import GrammarLookup from "../dict/GrammarLookup";
+import { TermCtl, type IWidgetTermCtl } from "../term/TermCtl";
+
+interface IGrammarTermLookupCtl {
+  word?: string;
+  term?: IWidgetTermCtl;
+}
+const GrammarTermLookupCtl = ({ word, term }: IGrammarTermLookupCtl) => {
+  return (
+    <GrammarLookup word={word}>
+      <TermCtl {...term} compact={true} />
+    </GrammarLookup>
+  );
+};
+
+interface IWidget {
+  props: string;
+}
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IGrammarTermLookupCtl;
+  console.debug("QuoteLink", prop);
+  return (
+    <>
+      <GrammarTermLookupCtl {...prop} />
+    </>
+  );
+};
+
+export default Widget;

+ 37 - 2
dashboard-v6/src/components/template/MdTpl.tsx

@@ -1,4 +1,16 @@
+import Confidence from "./Confidence";
+import GrammarTermLookup from "./GrammarTermLookup";
+import Mermaid from "./Mermaid";
+import Nissaya from "./Nissaya";
+import Note from "./Note";
+import ParaHandle from "./ParaHandle";
+import ParaShell from "./ParaShell";
+import Quote from "./Quote";
+import Reference from "./Reference";
 import SentEdit from "./SentEdit";
+import SentRead from "./SentRead";
+import Term from "./Term";
+import Toggle from "./Toggle";
 import Video from "./Video";
 import WbwSent from "./WbwSent";
 import Wd from "./Wd";
@@ -8,17 +20,40 @@ interface IWidgetMdTpl {
   props?: string;
   children?: React.ReactNode | React.ReactNode[];
 }
-const Widget = ({ tpl, props }: IWidgetMdTpl) => {
+const Widget = ({ tpl, props, children }: IWidgetMdTpl) => {
   switch (tpl) {
+    case "term":
+      return <Term props={props ? props : ""} />;
+    case "note":
+      return <Note props={props ? props : ""}>{children}</Note>;
+    case "sentread":
+      return <SentRead props={props ? props : ""} />;
     case "sentedit":
       return <SentEdit props={props ? props : ""} />;
     case "wbw_sent":
       return <WbwSent props={props ? props : ""} />;
     case "wd":
       return <Wd props={props ? props : ""} />;
-
+    case "quote":
+      return <Quote props={props ? props : ""} />;
+    case "nissaya":
+      return <Nissaya props={props ? props : ""}>{children}</Nissaya>;
+    case "toggle":
+      return <Toggle props={props ? props : undefined}>{children}</Toggle>;
+    case "para":
+      return <ParaHandle props={props ? props : ""} />;
+    case "mermaid":
+      return <Mermaid props={props ? props : ""} />;
+    case "para-shell":
+      return <ParaShell props={props ? props : ""}>{children}</ParaShell>;
     case "video":
       return <Video props={props ? props : ""} />;
+    case "grammar":
+      return <GrammarTermLookup props={props ? props : ""} />;
+    case "reference":
+      return <Reference props={props ? props : ""} />;
+    case "cf":
+      return <Confidence props={props ? props : ""} />;
     default:
       return <>未定义模版({tpl})</>;
   }

+ 22 - 0
dashboard-v6/src/components/template/Mermaid.tsx

@@ -0,0 +1,22 @@
+import Mermaid from "../general/Mermaid";
+
+interface IWidgetMermaidCtl {
+  text?: string;
+}
+const MermaidCtl = ({ text }: IWidgetMermaidCtl) => {
+  return <Mermaid text={text} />;
+};
+
+interface IWidget {
+  props: string;
+}
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IWidgetMermaidCtl;
+  return (
+    <>
+      <MermaidCtl {...prop} />
+    </>
+  );
+};
+
+export default Widget;

+ 62 - 0
dashboard-v6/src/components/template/Nissaya.tsx

@@ -0,0 +1,62 @@
+import { Popover } from "antd";
+import { useAppSelector } from "../../hooks";
+import { settingInfo } from "../../reducers/setting";
+
+import { MoreIcon } from "../../assets/icon";
+import { GetUserSetting } from "../setting/default";
+import NissayaMeaning from "../nissaya/NissayaMeaning";
+import PaliText from "../general/PaliText";
+
+interface IWidgetNissayaCtl {
+  original?: string;
+  pali?: string;
+  meaning?: string[];
+  lang?: string;
+  note?: string;
+  children?: React.ReactNode | React.ReactNode[];
+}
+export const NissayaCtl = ({ pali, meaning }: IWidgetNissayaCtl) => {
+  const settings = useAppSelector(settingInfo);
+  const layout = GetUserSetting("setting.nissaya.layout.read", settings);
+  console.debug("NissayaCtl layout", layout);
+  const ect = meaning
+    ?.slice(0, -1)
+    .map((item, id) => <NissayaMeaning key={id} text={item} />);
+  return (
+    <span
+      style={{
+        display: layout === "inline" ? "inline-block" : "block",
+        marginRight: 10,
+      }}
+    >
+      <PaliText
+        lookup={true}
+        text={pali}
+        code="my"
+        termToLocal={false}
+        style={{ fontWeight: 700 }}
+      />{" "}
+      {ect && ect?.length > 0 ? (
+        <Popover content={ect}>
+          <MoreIcon />{" "}
+        </Popover>
+      ) : (
+        <></>
+      )}
+      {meaning?.slice(-1).map((item, id) => (
+        <NissayaMeaning key={id} text={item} />
+      ))}
+    </span>
+  );
+};
+
+interface IWidget {
+  props: string;
+  children?: React.ReactNode | React.ReactNode[];
+}
+const Widget = ({ props, children }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IWidgetNissayaCtl;
+  return <NissayaCtl {...prop}>{children}</NissayaCtl>;
+};
+
+export default Widget;

+ 35 - 0
dashboard-v6/src/components/template/Note.tsx

@@ -0,0 +1,35 @@
+import { Popover } from "antd";
+import { InfoCircleOutlined } from "@ant-design/icons";
+import { Typography } from "antd";
+
+const { Link } = Typography;
+
+interface IWidgetNoteCtl {
+  trigger?: string; //界面上显示的文字
+  note?: string; //note内容
+  children?: React.ReactNode;
+}
+const NoteCtl = ({ trigger, note, children }: IWidgetNoteCtl) => {
+  const show = trigger ? trigger : <InfoCircleOutlined />;
+  return (
+    <>
+      <Popover
+        content={<div style={{ width: 500 }}>{note ?? children}</div>}
+        placement="bottom"
+      >
+        <Link>{show}</Link>
+      </Popover>
+    </>
+  );
+};
+
+interface IWidget {
+  props: string;
+  children?: React.ReactNode;
+}
+const Widget = ({ props, children }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IWidgetNoteCtl;
+  return <NoteCtl {...prop}>{children}</NoteCtl>;
+};
+
+export default Widget;

+ 213 - 0
dashboard-v6/src/components/template/ParaHandle.tsx

@@ -0,0 +1,213 @@
+import { Button, Dropdown, type MenuProps, message, notification } from "antd";
+import { useNavigate, useSearchParams } from "react-router";
+import { fullUrl, scrollToTop } from "../../utils";
+import { useIntl } from "react-intl";
+import store from "../../store";
+import { modeChange } from "../../reducers/article-mode";
+import { addToCart } from "../sentence-editor/utils";
+
+interface IWidgetParaHandleCtl {
+  book: number;
+  para: number;
+  mode?: string;
+  channels?: string[];
+  sentences: string[];
+  onTranslate?: () => void;
+}
+export const ParaHandleCtl = ({
+  book,
+  para,
+  sentences,
+  onTranslate,
+}: IWidgetParaHandleCtl) => {
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+  const intl = useIntl();
+
+  const items: MenuProps["items"] = [
+    {
+      key: "solo",
+      label: intl.formatMessage({
+        id: "labels.curr.paragraph.only",
+      }),
+    },
+    {
+      key: "solo-in-tab",
+      label: intl.formatMessage({
+        id: "labels.curr.paragraph.open",
+      }),
+    },
+    {
+      key: "ai-translate",
+      label: intl.formatMessage({
+        id: "buttons.ai.translate",
+      }),
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "mode",
+      label: intl.formatMessage({
+        id: "buttons.set.display.mode",
+      }),
+      children: [
+        {
+          key: "mode-translate",
+          label: intl.formatMessage({
+            id: "buttons.translate",
+          }),
+        },
+        {
+          key: "mode-wbw",
+          label: intl.formatMessage({
+            id: "buttons.wbw",
+          }),
+        },
+      ],
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "copy-sent",
+      label: intl.formatMessage({
+        id: "labels.curr.paragraph.copy.tpl",
+      }),
+    },
+    {
+      key: "cart-sent",
+      label: intl.formatMessage({
+        id: "labels.curr.paragraph.cart.tpl",
+      }),
+    },
+    {
+      key: "quote-link-tpl",
+      label: intl.formatMessage({
+        id: "labels.curr.paragraph.copy.quote.link.tpl",
+      }),
+      children: [
+        {
+          key: "quote-link-tpl-c",
+          label: intl.formatMessage({
+            id: "labels.page.number.type.c",
+          }),
+        },
+        {
+          key: "quote-link-tpl-m",
+          label: intl.formatMessage({
+            id: "labels.page.number.type.M",
+          }),
+        },
+        {
+          key: "quote-link-tpl-p",
+          label: intl.formatMessage({
+            id: "labels.page.number.type.P",
+          }),
+        },
+        {
+          key: "quote-link-tpl-t",
+          label: intl.formatMessage({
+            id: "labels.page.number.type.T",
+          }),
+        },
+      ],
+    },
+  ];
+  const copyToClipboard = (text: string) => {
+    navigator.clipboard.writeText(text).then(() => {
+      message.success("链接地址已经拷贝到剪贴板");
+    });
+  };
+  const onClick: MenuProps["onClick"] = (e) => {
+    /**
+     * TODO 临时的解决方案。以后应该从传参获取其他参数,然后reducer 通知更新。
+     * 因为如果是Article组件被嵌入其他页面。不能直接更新浏览器,而是应该更新Article组件内部
+     */
+    let url = `/article/para/${book}-${para}?book=${book}&par=${para}`;
+    const param: string[] = [];
+    searchParams.forEach((value: unknown, key: unknown) => {
+      if (key !== "book" && key !== "par") {
+        param.push(`${key}=${value}`);
+      }
+    });
+    if (param.length > 0) {
+      url += "&" + param.join("&");
+    }
+    switch (e.key) {
+      case "solo":
+        navigate(url);
+        scrollToTop();
+        break;
+      case "solo-in-tab":
+        window.open(fullUrl(url), "_blank");
+        break;
+      case "mode-translate":
+        store.dispatch(modeChange({ mode: "edit", id: `${book}-${para}` }));
+        break;
+      case "ai-translate":
+        if (typeof onTranslate !== "undefined") {
+          onTranslate();
+        }
+        break;
+      case "mode-wbw":
+        store.dispatch(modeChange({ mode: "wbw", id: `${book}-${para}` }));
+        break;
+      case "copy-sent":
+        copyToClipboard(sentences.map((item) => `{{${item}}}`).join(""));
+        break;
+      case "cart-sent": {
+        const cartData = sentences.map((item) => {
+          return { id: `{{${item}}}`, text: `{{${item}}}` };
+        });
+        addToCart(cartData);
+        notification.success({
+          message: cartData.length + "个句子已经添加到Cart",
+        });
+        break;
+      }
+      case "quote-link-tpl-c":
+        copyToClipboard(`{{ql|type=c|book=${book}|para=${para}}}`);
+        break;
+      case "quote-link-tpl-m":
+        copyToClipboard(`{{ql|type=m|book=${book}|para=${para}}}`);
+        break;
+      case "quote-link-tpl-p":
+        copyToClipboard(`{{ql|type=p|book=${book}|para=${para}}}`);
+        break;
+      case "quote-link-tpl-t":
+        copyToClipboard(`{{ql|type=t|book=${book}|para=${para}}}`);
+        break;
+
+      default:
+        break;
+    }
+  };
+  return (
+    <div>
+      <Dropdown
+        menu={{ items, onClick }}
+        placement="bottomLeft"
+        trigger={["click"]}
+      >
+        <Button size="small" type="text">
+          {para}
+        </Button>
+      </Dropdown>
+    </div>
+  );
+};
+
+interface IWidget {
+  props: string;
+}
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IWidgetParaHandleCtl;
+  return (
+    <>
+      <ParaHandleCtl {...prop} />
+    </>
+  );
+};
+
+export default Widget;

+ 94 - 0
dashboard-v6/src/components/template/ParaShell.tsx

@@ -0,0 +1,94 @@
+import { useMemo } from "react";
+import { useAppSelector } from "../../hooks";
+import { currFocus } from "../../reducers/focus";
+import { ParaHandleCtl } from "./ParaHandle";
+
+interface IWidgetParaShellCtl {
+  book: number;
+  para: number;
+  mode?: string;
+  channels?: string[];
+  sentences: string[];
+  children?: React.ReactNode | React.ReactNode[];
+}
+const ParaShellCtl = ({
+  book,
+  para,
+  mode = "read",
+  channels,
+  sentences,
+  children,
+}: IWidgetParaShellCtl) => {
+  const focus = useAppSelector(currFocus);
+
+  const isFocus = useMemo(() => {
+    if (focus) {
+      if (focus.focus?.type === "para") {
+        if (focus.focus.id) {
+          const arrId = focus.focus.id.split("-");
+          if (arrId.length > 1) {
+            const focusBook = parseInt(arrId[0]);
+            const focusPara = arrId[1].split(",").map((item) => parseInt(item));
+            if (focusBook === book && focusPara.includes(para)) {
+              return true;
+            }
+          }
+        } else {
+          return false;
+        }
+      }
+    } else {
+      return false;
+    }
+  }, [book, focus, para]);
+
+  const borderColor = isFocus ? "#e35f00bd " : "rgba(128, 128, 128, 0.3)";
+
+  const border = mode === "read" ? "" : "2px solid " + borderColor;
+
+  return (
+    <div
+      style={{
+        border: border,
+        borderRadius: 6,
+        marginTop: 20,
+        marginBottom: 28,
+        padding: 4,
+      }}
+    >
+      <div
+        style={{
+          position: "absolute",
+          marginTop: -31,
+          marginLeft: -6,
+          border: border,
+          borderRadius: "6px",
+        }}
+      >
+        <ParaHandleCtl
+          book={book}
+          para={para}
+          mode={mode}
+          channels={channels}
+          sentences={sentences}
+        />
+      </div>
+      {children}
+    </div>
+  );
+};
+
+interface IWidget {
+  props: string;
+  children?: React.ReactNode | React.ReactNode[];
+}
+const Widget = ({ props, children }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IWidgetParaShellCtl;
+  return (
+    <>
+      <ParaShellCtl {...prop}>{children}</ParaShellCtl>
+    </>
+  );
+};
+
+export default Widget;

+ 75 - 0
dashboard-v6/src/components/template/Quote.tsx

@@ -0,0 +1,75 @@
+import { Button, Popover } from "antd";
+import { Typography } from "antd";
+import { SearchOutlined, CopyOutlined } from "@ant-design/icons";
+import { ProCard } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+
+const { Text, Link } = Typography;
+
+interface IWidgetQuoteCtl {
+  paraId: string;
+  paliPath?: string[];
+  channel?: string;
+  pali?: string;
+  error?: boolean;
+  message?: string;
+}
+const QuoteCtl = ({ paraId, pali, error, message }: IWidgetQuoteCtl) => {
+  const intl = useIntl();
+  const show = pali ? pali : paraId;
+  let textShow = <></>;
+
+  if (typeof error !== "undefined") {
+    textShow = <Text type="danger">{show}</Text>;
+  } else {
+    textShow = <Link>{show}</Link>;
+  }
+
+  const userCard = (
+    <>
+      <ProCard
+        style={{ maxWidth: 500, minWidth: 300 }}
+        actions={[
+          <Button type="link" size="small" icon={<SearchOutlined />}>
+            分栏打开
+          </Button>,
+          <Button type="link" size="small" icon={<SearchOutlined />}>
+            {intl.formatMessage(
+              {
+                id: "buttons.open.in.new.tab",
+              },
+              { item: "" }
+            )}
+          </Button>,
+          <Button type="link" size="small" icon={<CopyOutlined />}>
+            复制引用
+          </Button>,
+        ]}
+      >
+        <div>{message ? message : ""}</div>
+      </ProCard>
+    </>
+  );
+  return (
+    <>
+      <Popover content={userCard} placement="bottom">
+        {textShow}
+      </Popover>
+    </>
+  );
+};
+
+interface IWidget {
+  props: string;
+}
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IWidgetQuoteCtl;
+  console.log(prop);
+  return (
+    <>
+      <QuoteCtl {...prop} />
+    </>
+  );
+};
+
+export default Widget;

+ 51 - 0
dashboard-v6/src/components/template/Reference.tsx

@@ -0,0 +1,51 @@
+import { Typography } from "antd";
+
+const { Paragraph } = Typography;
+
+const ucFirst = (input: string) => {
+  if (typeof input !== "string" || input.length === 0) {
+    return input; // 如果输入不是字符串或者字符串为空,则直接返回原值
+  }
+  return input.charAt(0).toUpperCase() + input.slice(1);
+};
+
+interface IReference {
+  sn: number;
+  title: string;
+  copyright: string;
+}
+
+interface IReferenceCtl {
+  pali?: IReference[];
+}
+const ReferenceCtl = ({ pali }: IReferenceCtl) => {
+  const Reference = (ref: IReference) => {
+    return (
+      <Paragraph>{`[${ref.sn}] ${ucFirst(ref.title)} ${
+        ref.copyright
+      }`}</Paragraph>
+    );
+  };
+  return (
+    <>
+      {pali?.map((item) => {
+        return Reference(item);
+      })}
+    </>
+  );
+};
+
+interface IWidget {
+  props: string;
+}
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IReferenceCtl;
+  console.log(prop);
+  return (
+    <>
+      <ReferenceCtl {...prop} />
+    </>
+  );
+};
+
+export default Widget;

+ 13 - 0
dashboard-v6/src/components/template/SentRead.tsx

@@ -0,0 +1,13 @@
+import type { IWidgetSentReadFrame } from "../sentence-editor/SentRead";
+import SentReadFrame from "../sentence-editor/SentRead";
+
+interface IWidget {
+  props: string;
+}
+
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IWidgetSentReadFrame;
+  return <SentReadFrame {...prop} />;
+};
+
+export default Widget;

+ 15 - 0
dashboard-v6/src/components/template/Term.tsx

@@ -0,0 +1,15 @@
+import { TermCtl, type IWidgetTermCtl } from "../term/TermCtl";
+
+interface IWidgetTerm {
+  props: string;
+}
+const Widget = ({ props }: IWidgetTerm) => {
+  const prop = JSON.parse(atob(props)) as IWidgetTermCtl;
+  return (
+    <>
+      <TermCtl {...prop} />
+    </>
+  );
+};
+
+export default Widget;

+ 43 - 0
dashboard-v6/src/components/template/Toggle.tsx

@@ -0,0 +1,43 @@
+import { Tree } from "antd";
+import { Children } from "react";
+
+interface IToggleCtlWidget {
+  children?: React.ReactNode | React.ReactNode[];
+}
+const ToggleCtl = ({ children }: IToggleCtlWidget) => {
+  const arrayChildren = Children.toArray(children);
+  if (arrayChildren.length === 0) {
+    return <></>;
+  } else {
+    return (
+      <Tree
+        treeData={[
+          {
+            title: arrayChildren[0],
+            key: "root",
+            children: Children.map(arrayChildren, (child, index) => {
+              if (index === 0) {
+                return undefined;
+              } else {
+                return {
+                  title: child as React.ReactElement,
+                  key: index,
+                };
+              }
+            }),
+          },
+        ]}
+      />
+    );
+  }
+};
+
+interface IWidget {
+  props?: string;
+  children?: React.ReactNode | React.ReactNode[];
+}
+const ToggleWidget = ({ children }: IWidget) => {
+  return <ToggleCtl>{children}</ToggleCtl>;
+};
+
+export default ToggleWidget;

+ 132 - 0
dashboard-v6/src/components/template/cs_para_map.ts

@@ -0,0 +1,132 @@
+export const csParaMap = [
+  { name: "pārā.", book: 213, para: 3 },
+  { name: "pāci.", book: 214, para: 3 },
+  { name: "mahāva.", book: 215, para: 3 },
+  { name: "cūḷava.", book: 216, para: 3 },
+  { name: "pari.", book: 217, para: 3 },
+  { name: "pārā.aṭṭha.", book: 138, para: 3 },
+  { name: "pārā.aṭṭha.1.", book: 138, para: 3 },
+  { name: "pārā.aṭṭha.2.", book: 138, para: 3 },
+  { name: "pāci.aṭṭha.", book: 139, para: 3 },
+  { name: "mahāva.aṭṭha.", book: 140, para: 3 },
+  { name: "cūḷava.aṭṭha.", book: 141, para: 3 },
+  { name: "pari.aṭṭha.", book: 142, para: 3 },
+
+  { name: "vi.saṅga.aṭṭha.", book: 208, para: 3 },
+  { name: "vajira.ṭī.", book: 209, para: 3 },
+  { name: "vi.vi.ṭī.", book: 201, para: 2 },
+  { name: "vi.vi.ṭī.cūḷavagga2", book: 201, para: 2 },
+  { name: "vi.vi.ṭī.mahāvagga2", book: 201, para: 2 },
+  { name: "vi.vi.ṭī.pācittiya2", book: 201, para: 2 },
+  { name: "vi.vi.ṭī.parivāra2", book: 201, para: 2 },
+  { name: "sārattha.ṭī.1.", book: 204, para: 3 },
+  { name: "sārattha.ṭī.2.", book: 205, para: 3 },
+  { name: "sārattha.ṭī.3.", book: 206, para: 3 },
+
+  { name: "dī.ni.1.", book: 93, para: 3 },
+  { name: "dī.ni.2.", book: 94, para: 3 },
+  { name: "dī.ni.3.", book: 95, para: 3 },
+  { name: "dī.ni.aṭṭha.1.", book: 103, para: 3 },
+  { name: "dī.ni.aṭṭha.2.", book: 104, para: 3 },
+  { name: "dī.ni.aṭṭha.3.", book: 105, para: 3 },
+  { name: "dī.ni.ṭī.1.", book: 185, para: 3 },
+  { name: "dī.ni.ṭī.2.", book: 186, para: 3 },
+  { name: "dī.ni.ṭī.3.", book: 187, para: 3 },
+
+  { name: "ma.ni.1.", book: 164, para: 3 },
+  { name: "ma.ni.2.", book: 165, para: 3 },
+  { name: "ma.ni.3.", book: 166, para: 3 },
+  { name: "ma.ni.aṭṭha.1.", book: 130, para: 3 },
+  { name: "ma.ni.aṭṭha.2.", book: 131, para: 3 },
+  { name: "ma.ni.aṭṭha.3.", book: 132, para: 3 },
+  { name: "ma.ni.ṭī.1.", book: 192, para: 3 },
+  { name: "ma.ni.ṭī.2.", book: 193, para: 3 },
+  { name: "ma.ni.ṭī.3.", book: 194, para: 3 },
+
+  { name: "saṃ.ni.1.", book: 167, para: 3 },
+  { name: "saṃ.ni.2.", book: 168, para: 3 },
+  { name: "saṃ.ni.3.", book: 169, para: 3 },
+  { name: "saṃ.ni.4.", book: 170, para: 3 },
+  { name: "saṃ.ni.5.", book: 171, para: 3 },
+  { name: "saṃ.ni.aṭṭha.1.", book: 133, para: 3 },
+  { name: "saṃ.ni.aṭṭha.2.", book: 134, para: 3 },
+  { name: "saṃ.ni.aṭṭha.3.", book: 135, para: 3 },
+  { name: "saṃ.ni.ṭī.1.", book: 195, para: 3 },
+  { name: "saṃ.ni.ṭī.2.", book: 196, para: 3 },
+  { name: "saṃ.ni.ṭī.3.", book: 197, para: 3 },
+
+  { name: "a.ni.1.", book: 84, para: 3 },
+  { name: "a.ni.2.", book: 85, para: 3 },
+  { name: "a.ni.3.", book: 86, para: 3 },
+  { name: "a.ni.4.", book: 87, para: 3 },
+  { name: "a.ni.5.", book: 88, para: 3 },
+  { name: "a.ni.6.", book: 89, para: 3 },
+  { name: "a.ni.7.", book: 90, para: 3 },
+  { name: "a.ni.8.", book: 91, para: 3 },
+  { name: "a.ni.9.", book: 92, para: 3 },
+  { name: "a.ni.10.", book: 82, para: 3 },
+  { name: "a.ni.11.", book: 83, para: 3 },
+
+  { name: "paṭi.ma.mātikā1.", book: 151, para: 5 },
+  { name: "paṭi.ma.1.", book: 151, para: 81, end: 1360 },
+  { name: "paṭi.ma.2.", book: 151, para: 1361 },
+  { name: "paṭi.ma.3.", book: 151, para: 1809 },
+
+  { name: "jā.1.1.", book: 148, para: 5 },
+  { name: "jā.1.2.", book: 148, para: 851 },
+  { name: "jā.1.3.", book: 148, para: 1742 },
+  { name: "jā.1.4.", book: 148, para: 2326 },
+  { name: "jā.1.5.", book: 148, para: 3065 },
+  { name: "jā.1.6.", book: 148, para: 3557 },
+  { name: "jā.1.7.", book: 148, para: 4041 },
+  { name: "jā.1.8.", book: 148, para: 4589 },
+  { name: "jā.1.9.", book: 148, para: 4902 },
+  { name: "jā.1.10.", book: 148, para: 5275 },
+  { name: "jā.1.11.", book: 148, para: 5871 },
+  { name: "jā.1.12.", book: 148, para: 6264 },
+  { name: "jā.1.13.", book: 148, para: 6692 },
+  { name: "jā.1.14.", book: 148, para: 7188 },
+  { name: "jā.1.15.", book: 148, para: 8123 },
+  { name: "jā.1.16.", book: 148, para: 9339 },
+  { name: "jā.2.17.", book: 147, para: 5 },
+  { name: "jā.2.18.", book: 147, para: 765 },
+  { name: "jā.2.19.", book: 147, para: 1333 },
+  { name: "jā.2.20.", book: 147, para: 1754 },
+  { name: "jā.2.21.", book: 147, para: 2326 },
+  { name: "jā.2.22.", book: 147, para: 3894 },
+
+  { name: "dha.sa.", book: 73, para: 3 },
+  { name: "vibha.", book: 74, para: 3 },
+  { name: "yama.1.mūlayamaka.", book: 78, para: 4 },
+  { name: "yama.1.khandhayamaka.", book: 78, para: 309 },
+  { name: "yama.1.khandhayamaka.", book: 78, para: 309 },
+  { name: "yama.1.āyatanayamaka.", book: 78, para: 1463 },
+  { name: "yama.1.dhāturamaka.", book: 78, para: 3544 },
+  { name: "yama.1.saccayamaka.", book: 78, para: 3809 },
+  { name: "yama.2.saṅkhārayamaka.", book: 79, para: 4 },
+  { name: "yama.2.anusayayamaka.", book: 79, para: 822 },
+  { name: "yama.2.cittayamaka.", book: 79, para: 3204 },
+  { name: "yama.3.dhammayamaka.", book: 80, para: 4 },
+  { name: "yama.3.indriyayamaka.", book: 80, para: 1122 },
+
+  { name: "dha.sa.aṭṭha.", book: 96, para: 4 },
+  { name: "vibha.aṭṭha.", book: 97, para: 4 },
+  { name: "yama.aṭṭha.", book: 98, para: 1623 },
+  { name: "yama.aṭṭha.āyatanayamaka.", book: 98, para: 1711 },
+
+  { name: "dha.sa.mūlaṭī.", book: 172, para: 3 },
+  { name: "vibha.mūlaṭī.", book: 173, para: 3 },
+  { name: "yama.mūlaṭī.", book: 174, para: 865 },
+
+  { name: "dha.sa.anuṭī.", book: 175, para: 3 },
+  { name: "vibha.anuṭī.", book: 173, para: 1155 },
+
+  { name: "visuddhi.1.", book: 64, para: 2 },
+  { name: "visuddhi.2.", book: 65, para: 2 },
+  { name: "visuddhi.mahāṭī.1.", book: 66, para: 2 },
+  { name: "visuddhi.mahāṭī.2.", book: 67, para: 2 },
+  { name: "visuddhi.ṭī.1.", book: 66, para: 2 },
+  { name: "visuddhi.ṭī.2.", book: 67, para: 2 },
+  { name: "visuddhi.mahā.1.", book: 66, para: 2 },
+  { name: "visuddhi.mahā.2.", book: 67, para: 2 },
+];

+ 314 - 0
dashboard-v6/src/components/term/TermCtl.tsx

@@ -0,0 +1,314 @@
+import { useEffect, useState, useMemo, useReducer } from "react";
+import { Link } from "react-router";
+import { Button, Popover, Skeleton, Space, Tag } from "antd";
+import { Typography } from "antd";
+import { SearchOutlined, EditOutlined } from "@ant-design/icons";
+
+import store from "../../store";
+import TermModal from "../term/TermModal";
+
+import type { ITerm, ITermDataResponse, ITermResponse } from "../../api/Term";
+import {
+  changedTerm,
+  refresh,
+  termCache,
+  upgrade,
+} from "../../reducers/term-change";
+import { useAppSelector } from "../../hooks";
+import { get } from "../../request";
+import { fullUrl } from "../../utils";
+import lodash from "lodash";
+import { order, push } from "../../reducers/term-order";
+import { click } from "../../reducers/term-click";
+
+const { Text, Title } = Typography;
+
+const dataMap = (input?: ITermDataResponse): ITerm => {
+  return {
+    id: input?.guid,
+    word: input?.word,
+    meaning: input?.meaning,
+    meaning2: input?.other_meaning?.split(","),
+    summary: input?.summary ?? "",
+    channelId: input?.channal,
+    studioId: input?.studio.id,
+    summary_is_community: input?.summary_is_community,
+  };
+};
+
+interface ITermExtra {
+  pali?: string;
+  meaning2?: string[];
+}
+const TermExtra = ({ pali, meaning2 }: ITermExtra) => (
+  <>
+    {" "}
+    {"("}
+    <Text italic>{pali}</Text>
+    {meaning2 ? <Text>{`,${meaning2}`}</Text> : undefined}
+    {")"}
+  </>
+);
+
+// 合并相关状态,避免多次 setState 触发多次渲染
+interface ITermState {
+  termData: ITerm;
+  isInit: boolean;
+  community: boolean | undefined;
+}
+
+type TermAction =
+  | { type: "UPDATE_TERM"; payload: ITermDataResponse }
+  | { type: "SET_TERM_DATA"; payload: ITerm };
+
+function termReducer(state: ITermState, action: TermAction): ITermState {
+  switch (action.type) {
+    case "UPDATE_TERM":
+      return {
+        termData: dataMap(action.payload),
+        isInit: false,
+        community: false,
+      };
+    case "SET_TERM_DATA":
+      return {
+        ...state,
+        termData: action.payload,
+        isInit: false,
+      };
+    default:
+      return state;
+  }
+}
+
+export interface IWidgetTermCtl {
+  id?: string;
+  word?: string;
+  meaning?: string;
+  meaning2?: string;
+  channel?: string;
+  parentChannelId?: string;
+  parentStudioId?: string;
+  summary?: string;
+  isCommunity?: boolean;
+  compact?: boolean;
+}
+
+export const TermCtl = ({
+  id,
+  word,
+  meaning,
+  meaning2,
+  channel,
+  parentChannelId,
+  parentStudioId,
+  summary,
+  isCommunity,
+  compact = false,
+}: IWidgetTermCtl) => {
+  const [openPopover, setOpenPopover] = useState(false);
+
+  const [{ termData, isInit, community }, dispatch] = useReducer(termReducer, {
+    termData: {
+      id,
+      word,
+      meaning,
+      meaning2: meaning2?.split(","),
+      summary,
+      channelId: channel,
+    },
+    isInit: true,
+    community: isCommunity,
+  });
+
+  const [loading, setLoading] = useState(false);
+
+  const newTerm: ITermDataResponse | undefined = useAppSelector(changedTerm);
+  const cache = useAppSelector(termCache);
+
+  const [uid] = useState<string>(
+    lodash.times(20, () => lodash.random(35).toString(36)).join("")
+  );
+  const termOrder = useAppSelector(order);
+
+  // 用 useMemo 派生 isFirst,避免 useEffect + setState
+  const isFirst = useMemo(() => {
+    if (!word || !parentChannelId) return false;
+    const index = termOrder?.findIndex(
+      (value) =>
+        value.word === word &&
+        value.channelId === parentChannelId &&
+        value.first === uid
+    );
+    return index !== -1;
+  }, [termOrder, word, parentChannelId, uid]);
+
+  useEffect(() => {
+    if (word && parentChannelId) {
+      store.dispatch(push({ word, channelId: parentChannelId, first: uid }));
+    }
+  }, [parentChannelId, uid, word]);
+
+  // ✅ 修改后:用 if (newTerm) 收窄类型
+  useEffect(() => {
+    if (
+      newTerm && // 收窄掉 undefined
+      newTerm.word === word &&
+      parentStudioId === newTerm.studio.id
+    ) {
+      console.debug("Term studio 匹配", newTerm);
+      dispatch({ type: "UPDATE_TERM", payload: newTerm }); // 此时 newTerm: ITermDataResponse ✅
+    }
+  }, [newTerm, parentStudioId, word]);
+
+  const onModalClose = () => {
+    if (document.getElementsByTagName("body")[0].hasAttribute("style")) {
+      document.getElementsByTagName("body")[0].removeAttribute("style");
+    }
+  };
+
+  const onPopoverOpen = (visible: boolean) => {
+    setOpenPopover(visible);
+    if (visible && isInit && typeof id !== "undefined") {
+      const term = cache?.find((value) => value.guid === id);
+      if (term) {
+        // term 已收窄为 ITermDataResponse,非 undefined ✅
+        dispatch({ type: "SET_TERM_DATA", payload: dataMap(term) });
+        return;
+      } else {
+        const url = `/v2/terms/${id}?community_summary=1`;
+        console.info("api request", url);
+        setLoading(true);
+        get<ITermResponse>(url)
+          .then((json) => {
+            if (json.ok) {
+              dispatch({ type: "UPDATE_TERM", payload: json.data });
+              store.dispatch(upgrade(json.data));
+            }
+          })
+          .finally(() => setLoading(false));
+      }
+    }
+  };
+
+  if (typeof termData?.id === "string") {
+    return (
+      <>
+        <span className="term"></span>
+        <Popover
+          title={
+            <Space style={{ justifyContent: "space-between", width: "100%" }}>
+              <span>
+                <Text strong>{termData.meaning}</Text>{" "}
+                {community ? <Tag>{"社区"}</Tag> : undefined}
+              </span>
+              <Space>
+                <Button
+                  onClick={() => {
+                    window.open(
+                      fullUrl(`/term/list/${termData.word}`),
+                      "_blank"
+                    );
+                  }}
+                  type="link"
+                  size="small"
+                  icon={<SearchOutlined />}
+                />
+                <TermModal
+                  onUpdate={(value: ITermDataResponse) => {
+                    onModalClose();
+                    sessionStorage.removeItem(`term/summary/${value.guid}`);
+                    store.dispatch(refresh(value));
+                  }}
+                  onClose={() => {
+                    onModalClose();
+                  }}
+                  trigger={
+                    <Button
+                      onClick={() => {
+                        setOpenPopover(false);
+                      }}
+                      type="link"
+                      size="small"
+                      icon={<EditOutlined />}
+                    />
+                  }
+                  id={termData.id}
+                  word={termData.word}
+                  channelId={termData.channelId}
+                  parentChannelId={parentChannelId}
+                  parentStudioId={parentStudioId}
+                  community={community}
+                />
+              </Space>
+            </Space>
+          }
+          open={openPopover}
+          onOpenChange={onPopoverOpen}
+          content={
+            <div style={{ maxWidth: 500, minWidth: 300 }}>
+              <Title level={5}>
+                <Link to={`/term/list/${termData.word}`} target="_blank">
+                  {word}
+                </Link>
+              </Title>
+              {loading ? (
+                <Skeleton
+                  title={{ width: 200 }}
+                  paragraph={{ rows: 4 }}
+                  active
+                />
+              ) : (
+                <>
+                  <div>{termData.summary}</div>
+                  <div style={{ textAlign: "right" }}>
+                    {termData.summary_is_community ? "社区解释" : ""}
+                  </div>
+                </>
+              )}
+            </div>
+          }
+          placement="bottom"
+        >
+          <Typography.Link
+            style={{
+              color: community ? "green" : undefined,
+              wordBreak: "keep-all",
+            }}
+            onClick={() => {
+              console.debug("term send redux");
+              store.dispatch(click(termData));
+            }}
+          >
+            {termData?.meaning ?? termData?.word ?? "unknown"}
+          </Typography.Link>
+        </Popover>
+        {isFirst && !compact ? (
+          <TermExtra pali={word} meaning2={termData?.meaning2} />
+        ) : undefined}
+      </>
+    );
+  } else {
+    return (
+      <TermModal
+        onUpdate={(value: ITermDataResponse) => {
+          onModalClose();
+          store.dispatch(refresh(value));
+        }}
+        onClose={() => {
+          onModalClose();
+        }}
+        trigger={
+          <Typography.Link>
+            <Text type="danger" style={{ wordBreak: "keep-all" }}>
+              {termData?.word}
+            </Text>
+          </Typography.Link>
+        }
+        word={termData?.word}
+        parentChannelId={parentChannelId}
+        parentStudioId={parentStudioId}
+        community={community}
+      />
+    );
+  }
+};

+ 15 - 21
dashboard-v6/src/routes/testRoutes.tsx

@@ -44,32 +44,26 @@ export const testRoutes: TestRouteObject[] = [
     label: "SentEditInnerDemo",
     Component: SentEditInnerDemo,
   },
-  {
-    path: "TermTest",
-    label: "TermTest",
-    Component: TermTest,
-  },
+
   {
     path: "EditableTreeTest",
     label: "EditableTreeTest",
     Component: EditableTreeTest,
   },
   {
-    path: "TypePaliTest",
-    label: "TypePaliTest",
-    Component: TypePaliTest,
+    path: "editor",
+    label: "Editor",
+    children: [
+      {
+        path: "TermTest",
+        label: "TermTest",
+        Component: TermTest,
+      },
+      {
+        path: "TypePaliTest",
+        label: "TypePaliTest",
+        Component: TypePaliTest,
+      },
+    ],
   },
-
-  // 示例:嵌套结构
-  // {
-  //   path: "button",
-  //   label: "按钮",
-  //   children: [
-  //     {
-  //       path: "basic",
-  //       label: "基础按钮",
-  //       Component: TestButtonDemo,
-  //     },
-  //   ],
-  // },
 ];

+ 4 - 0
dashboard-v6/src/utils.ts

@@ -100,3 +100,7 @@ export const numToHex = (arg: number) => {
     console.warn("数字转16进制出错:", e);
   }
 };
+
+export const scrollToTop = () => {
+  document.getElementById("article-root")?.scrollIntoView();
+};