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

Merge branch 'agile' of https://github.com/iapt-platform/mint into agile

bhikkhu-kosalla-china 1 год назад
Родитель
Сommit
32856d397c
33 измененных файлов с 511 добавлено и 120 удалено
  1. 2 1
      dashboard/src/components/api/Channel.ts
  2. 6 0
      dashboard/src/components/api/Corpus.ts
  3. 7 2
      dashboard/src/components/article/Article.tsx
  4. 3 1
      dashboard/src/components/article/ArticleView.tsx
  5. 14 3
      dashboard/src/components/article/TypeCSPara.tsx
  6. 14 3
      dashboard/src/components/article/TypeCourse.tsx
  7. 13 3
      dashboard/src/components/article/TypePage.tsx
  8. 7 2
      dashboard/src/components/article/TypePali.tsx
  9. 4 0
      dashboard/src/components/channel/ChannelTypeSelect.tsx
  10. 2 1
      dashboard/src/components/discussion/DiscussionAnchor.tsx
  11. 32 11
      dashboard/src/components/discussion/DiscussionButton.tsx
  12. 2 1
      dashboard/src/components/discussion/DiscussionListCard.tsx
  13. 7 6
      dashboard/src/components/template/ParaShell.tsx
  14. 3 0
      dashboard/src/components/template/SentEdit.tsx
  15. 112 0
      dashboard/src/components/template/SentEdit/InteractiveButton.tsx
  16. 1 1
      dashboard/src/components/template/SentEdit/SentCanRead.tsx
  17. 1 1
      dashboard/src/components/template/SentEdit/SentCell.tsx
  18. 8 1
      dashboard/src/components/template/SentEdit/SentContent.tsx
  19. 12 0
      dashboard/src/components/template/SentEdit/SentSim.tsx
  20. 3 1
      dashboard/src/components/template/SentEdit/SentTab.tsx
  21. 61 0
      dashboard/src/components/template/SentEdit/SuggestionButton.tsx
  22. 4 44
      dashboard/src/components/template/SentEdit/SuggestionToolbar.tsx
  23. 152 20
      dashboard/src/components/template/SentRead.tsx
  24. 1 5
      dashboard/src/components/template/Wbw/WbwDetail.tsx
  25. 1 5
      dashboard/src/components/template/Wbw/WbwDetailBasic.tsx
  26. 1 5
      dashboard/src/components/template/Wbw/WbwDetailBasicRelation.tsx
  27. 2 0
      dashboard/src/components/template/Wbw/WbwDetailRelation.tsx
  28. 7 2
      dashboard/src/components/template/Wbw/WbwPali.tsx
  29. 15 0
      dashboard/src/components/template/style.css
  30. 1 0
      dashboard/src/locales/en-US/channel/index.ts
  31. 1 0
      dashboard/src/locales/zh-Hans/channel/index.ts
  32. 7 1
      dashboard/src/pages/library/article/show.tsx
  33. 5 0
      deploy/roles/mint-v2/tasks/dashboard.yml

+ 2 - 1
dashboard/src/components/api/Channel.ts

@@ -5,7 +5,8 @@ export type TChannelType =
   | "nissaya"
   | "original"
   | "wbw"
-  | "commentary";
+  | "commentary"
+  | "similar";
 export interface IChannelApiData {
   id: string;
   name: string;

+ 6 - 0
dashboard/src/components/api/Corpus.ts

@@ -289,3 +289,9 @@ export interface ISentenceWbwListResponse {
   message: string;
   data: { rows: IWidgetSentEditInner[]; count: number };
 }
+
+export interface IEditableSentence {
+  ok: boolean;
+  message: string;
+  data: IWidgetSentEditInner;
+}

+ 7 - 2
dashboard/src/components/article/Article.tsx

@@ -60,7 +60,12 @@ interface IWidget {
   hideInteractive?: boolean;
   hideTitle?: boolean;
   isSubWindow?: boolean;
-  onArticleChange?: Function;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
   onLoad?: Function;
   onAnthologySelect?: Function;
   onTitle?: Function;
@@ -166,7 +171,7 @@ const ArticleWidget = ({
             type: ArticleType,
             id: string,
             target: string,
-            param: ISearchParams[]
+            param?: ISearchParams[]
           ) => {
             if (typeof onArticleChange !== "undefined") {
               onArticleChange(type, id, target, param);

+ 3 - 1
dashboard/src/components/article/ArticleView.tsx

@@ -3,7 +3,7 @@ import { Typography, Divider, Skeleton, Space } from "antd";
 import MdView from "../template/MdView";
 import TocPath, { ITocPathNode } from "../corpus/TocPath";
 import PaliChapterChannelList from "../corpus/PaliChapterChannelList";
-import { ArticleType } from "./Article";
+import { ArticleMode, ArticleType } from "./Article";
 import VisibleObserver from "../general/VisibleObserver";
 import { IStudio } from "../auth/Studio";
 
@@ -21,6 +21,7 @@ export interface IWidgetArticleData {
   content?: string;
   html?: string[];
   path?: ITocPathNode[];
+  mode?: ArticleMode | null;
   created_at?: string;
   updated_at?: string;
   owner?: IStudio;
@@ -50,6 +51,7 @@ const ArticleViewWidget = ({
   articleId,
   anthology,
   hideTitle,
+  mode = "read",
   onEnd,
   remains,
   onPathChange,

+ 14 - 3
dashboard/src/components/article/TypeCSPara.tsx

@@ -10,6 +10,7 @@ import NavigateButton from "./NavigateButton";
 import ArticleSkeleton from "./ArticleSkeleton";
 import ErrorResult from "../general/ErrorResult";
 import "./article.css";
+import { ISearchParams } from "../../pages/library/article/show";
 
 interface IParam {
   articleId?: string;
@@ -22,7 +23,12 @@ interface IWidget {
   articleId?: string;
   mode?: ArticleMode | null;
   channelId?: string | null;
-  onArticleChange?: Function;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
   onFinal?: Function;
   onLoad?: Function;
 }
@@ -107,9 +113,14 @@ const TypeCSParaWidget = ({
             type={"para"}
             hideNav
             {...paramPali}
-            onArticleChange={(type: ArticleType, id: string) => {
+            onArticleChange={(
+              type: ArticleType,
+              id: string,
+              target: string,
+              param?: ISearchParams[] | undefined
+            ) => {
               if (typeof onArticleChange !== "undefined") {
-                onArticleChange(type, id);
+                onArticleChange(type, id, target, param);
               }
             }}
           />

+ 14 - 3
dashboard/src/components/article/TypeCourse.tsx

@@ -26,6 +26,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
 import SelectChannel from "../course/SelectChannel";
 import { Space, Tag, Typography } from "antd";
 import { useIntl } from "react-intl";
+import { ISearchParams } from "../../pages/library/article/show";
 
 const { Text } = Typography;
 
@@ -53,7 +54,12 @@ interface IWidget {
   exerciseId?: string;
   userName?: string;
   active?: boolean;
-  onArticleChange?: Function;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
   onFinal?: Function;
   onLoad?: Function;
   onLoading?: Function;
@@ -224,9 +230,14 @@ const TypeCourseWidget = ({
         anthologyId={anthologyId}
         active={true}
         onArticleChange={(type: ArticleType, id: string, target: string) => {
-          if (type === "article") {
+          if (type === "article" && courseId && channelId) {
             if (typeof onArticleChange !== "undefined") {
-              onArticleChange(type, id, target);
+              let param: ISearchParams[] = [
+                { key: "course", value: courseId },
+                { key: "channel", value: channelId },
+              ];
+
+              onArticleChange("textbook", id, target, param);
             }
           } else {
             navigate(`/course/show/${courseId}`);

+ 13 - 3
dashboard/src/components/article/TypePage.tsx

@@ -12,6 +12,7 @@ import ArticleSkeleton from "./ArticleSkeleton";
 import ErrorResult from "../general/ErrorResult";
 import "./article.css";
 import { fullUrl } from "../../utils";
+import { ISearchParams } from "../../pages/library/article/show";
 
 interface IParam {
   articleId?: string;
@@ -25,7 +26,12 @@ interface IWidget {
   mode?: ArticleMode | null;
   channelId?: string | null;
   focus?: string | null;
-  onArticleChange?: Function;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
   onFinal?: Function;
   onLoad?: Function;
 }
@@ -159,9 +165,13 @@ const TypePageWidget = ({
             hideNav
             {...paramPali}
             focus={focus}
-            onArticleChange={(type: ArticleType, id: string) => {
+            onArticleChange={(
+              type: ArticleType,
+              id: string,
+              target: string
+            ) => {
               if (typeof onArticleChange !== "undefined") {
-                onArticleChange(type, id);
+                onArticleChange(type, id, target);
               }
             }}
           />

+ 7 - 2
dashboard/src/components/article/TypePali.tsx

@@ -31,7 +31,12 @@ interface IWidget {
   active?: boolean;
   focus?: string | null;
   hideNav?: boolean;
-  onArticleChange?: Function;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
   onFinal?: Function;
   onLoad?: Function;
   onTitle?: Function;
@@ -352,7 +357,7 @@ const TypePaliWidget = ({
                   }
                 }
                 if (typeof onArticleChange !== "undefined") {
-                  onArticleChange(type, newId, target, param);
+                  onArticleChange(type as ArticleType, newId, target, param);
                 }
               }}
             />

+ 4 - 0
dashboard/src/components/channel/ChannelTypeSelect.tsx

@@ -25,6 +25,10 @@ const ChannelTypeSelectWidget = ({ readonly }: IWidget) => {
       value: "original",
       label: intl.formatMessage({ id: "channel.type.original.label" }),
     },
+    {
+      value: "similar",
+      label: intl.formatMessage({ id: "channel.type.similar.label" }),
+    },
   ];
   return (
     <ProFormSelect

+ 2 - 1
dashboard/src/components/discussion/DiscussionAnchor.tsx

@@ -48,10 +48,11 @@ const DiscussionAnchorWidget = ({
     switch (resType) {
       case "sentence":
         url = `/v2/sentence/${resId}`;
-        console.log("url", url);
+        console.info("api request", url);
         setLoading(true);
         get<ISentenceResponse>(url)
           .then((json) => {
+            console.info("api response", json);
             if (json.ok) {
               const id = `${json.data.book}-${json.data.paragraph}-${json.data.word_start}-${json.data.word_end}`;
               const channel = json.data.channel.id;

+ 32 - 11
dashboard/src/components/discussion/DiscussionButton.tsx

@@ -1,6 +1,6 @@
 import { Space, Tooltip } from "antd";
 import store from "../../store";
-import { IShowDiscussion, show } from "../../reducers/discussion";
+import { IShowDiscussion, count, show } from "../../reducers/discussion";
 import { openPanel } from "../../reducers/right-panel";
 import { CommentFillIcon, CommentOutlinedIcon } from "../../assets/icon";
 import { TResType } from "./DiscussionListCard";
@@ -8,6 +8,19 @@ import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 import { discussionList } from "../../reducers/discussion-count";
 import { IDiscussionCountData, IDiscussionCountWbw } from "../api/Comment";
+import { useEffect, useState } from "react";
+
+export const openDiscussion = (resId: string, withStudent: boolean) => {
+  const data: IShowDiscussion = {
+    type: "discussion",
+    resId: resId,
+    resType: "sentence",
+    withStudent: withStudent,
+  };
+  console.debug("discussion show", data);
+  store.dispatch(show(data));
+  store.dispatch(openPanel("discussion"));
+};
 
 interface IWidget {
   initCount?: number;
@@ -27,8 +40,22 @@ const DiscussionButton = ({
   onlyMe = false,
   wbw,
 }: IWidget) => {
+  const [CommentCount, setCommentCount] = useState<number | undefined>(
+    initCount
+  );
+
   const user = useAppSelector(currentUser);
   const discussions = useAppSelector(discussionList);
+  const discussionCount = useAppSelector(count);
+
+  useEffect(() => {
+    if (
+      discussionCount?.resType === "sentence" &&
+      discussionCount.resId === resId
+    ) {
+      setCommentCount(discussionCount.count);
+    }
+  }, [resId, discussionCount]);
 
   const all = discussions?.filter((value) => value.res_id === resId);
   const my = all?.filter((value) => value.editor_uid === user?.id);
@@ -45,7 +72,7 @@ const DiscussionButton = ({
 
   console.debug("DiscussionButton", discussions, wbw, withStudent);
 
-  let currCount = initCount;
+  let currCount = CommentCount;
   if (onlyMe) {
     if (my) {
       currCount = my.length;
@@ -79,15 +106,9 @@ const DiscussionButton = ({
           color: currCount && currCount > 0 ? "#1890ff" : "unset",
         }}
         onClick={(event) => {
-          const data: IShowDiscussion = {
-            type: "discussion",
-            resId: resId,
-            resType: resType,
-            withStudent: wbw ? true : false,
-          };
-          console.debug("discussion show", data);
-          store.dispatch(show(data));
-          store.dispatch(openPanel("discussion"));
+          if (resId) {
+            openDiscussion(resId, wbw ? true : false);
+          }
         }}
       >
         {myCount ? <CommentFillIcon /> : <CommentOutlinedIcon />}

+ 2 - 1
dashboard/src/components/discussion/DiscussionListCard.tsx

@@ -221,8 +221,9 @@ const DiscussionListCardWidget = ({
             switch (resType) {
               case "sentence":
                 const url = `/v2/sentence/${resId}`;
-                console.log("url", url);
+                console.info("api request", url);
                 const sentInfo = await get<ISentenceResponse>(url);
+                console.info("api response", sentInfo);
                 studioName = sentInfo.data.studio.realName;
                 break;
             }

+ 7 - 6
dashboard/src/components/template/ParaShell.tsx

@@ -41,12 +41,15 @@ const ParaShellCtl = ({
       setIsFocus(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: isFocus
-          ? "2px solid #e35f00bd "
-          : "2px solid rgba(128, 128, 128, 0.3)",
+        border: border,
         borderRadius: 6,
         marginTop: 20,
         marginBottom: 28,
@@ -58,9 +61,7 @@ const ParaShellCtl = ({
           position: "absolute",
           marginTop: -31,
           marginLeft: -6,
-          border: isFocus
-            ? "2px solid #e35f00bd "
-            : "2px solid rgba(128, 128, 128, 0.3)",
+          border: border,
           borderRadius: "6px",
         }}
       >

+ 3 - 0
dashboard/src/components/template/SentEdit.tsx

@@ -97,6 +97,7 @@ export interface IWidgetSentEditInner {
   readonly?: boolean;
   wbwProgress?: number;
   wbwScore?: number;
+  onTranslationChange?: (data: ISentence) => void;
 }
 export const SentEditInner = ({
   id,
@@ -119,6 +120,7 @@ export const SentEditInner = ({
   mode,
   showWbwProgress = false,
   readonly = false,
+  onTranslationChange,
 }: IWidgetSentEditInner) => {
   const [wbwData, setWbwData] = useState<IWbw[]>();
   const [magicDict, setMagicDict] = useState<string>();
@@ -203,6 +205,7 @@ export const SentEditInner = ({
         setMagicDictLoading(false);
         setMagicDict(undefined);
       }}
+      onTranslationChange={onTranslationChange}
     />
   );
 

+ 112 - 0
dashboard/src/components/template/SentEdit/InteractiveButton.tsx

@@ -0,0 +1,112 @@
+import { Divider, Space } from "antd";
+import SuggestionButton from "./SuggestionButton";
+import DiscussionButton from "../../discussion/DiscussionButton";
+import { ISentence } from "../SentEdit";
+import { MouseEventHandler, useEffect, useState } from "react";
+
+interface IWidget {
+  data: ISentence;
+  compact?: boolean;
+  float?: boolean;
+  hideCount?: boolean;
+  hideInZero?: boolean;
+  onMouseEnter?: MouseEventHandler | undefined;
+  onMouseLeave?: MouseEventHandler | undefined;
+}
+
+const InteractiveButton = ({
+  data,
+  compact = false,
+  float = false,
+  hideCount = false,
+  hideInZero = false,
+  onMouseEnter,
+  onMouseLeave,
+}: IWidget) => {
+  const [left, setLeft] = useState(0);
+  const [width, setWidth] = useState(0);
+
+  useEffect(() => {
+    // 获取目标元素
+    const targetNode = document.getElementsByClassName("article_shell")[0];
+    if (!targetNode) {
+      return;
+    }
+    const rect = targetNode.getBoundingClientRect();
+    setLeft(rect.left);
+    setWidth(rect.width);
+    // 创建ResizeObserver实例并传入回调函数
+    const resizeObserver = new ResizeObserver((entries) => {
+      for (let entry of entries) {
+        const { width, height } = entry.contentRect;
+        //console.log(`Element size: ${width}px x ${height}px`);
+        setWidth((origin) => width);
+      }
+    });
+
+    // 观察目标节点
+    resizeObserver.observe(targetNode);
+
+    // 定义一个函数来处理窗口大小变化
+    function handleResize() {
+      // 获取窗口宽度
+      const windowWidth = window.innerWidth;
+      // 在控制台中输出新的窗口宽度
+      //console.log(`新的窗口宽度是:${windowWidth}px`);
+
+      // 假设你有一个div元素,它的id是"myDiv"
+      const myDiv = document.getElementsByClassName("article_shell")[0];
+      if (!myDiv) {
+        return;
+      }
+      // 使用getBoundingClientRect()方法获取元素的位置和大小
+      const rect = myDiv.getBoundingClientRect();
+
+      // 从返回的对象中获取left属性
+      const leftPosition = rect.left;
+
+      // 输出left位置
+      //console.log(`div的left位置是:${leftPosition}px`);
+      setLeft(leftPosition);
+
+      // 在这里,你可以根据窗口宽度来调整页面布局或样式
+    }
+
+    // 为resize事件添加监听器
+    window.addEventListener("resize", handleResize);
+  }, []);
+
+  const ButtonInner = (
+    <Space
+      size={"small"}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+    >
+      <SuggestionButton
+        data={data}
+        hideCount={hideCount}
+        hideInZero={hideInZero}
+      />
+      {compact ? undefined : <Divider type="vertical" />}
+      <DiscussionButton
+        hideCount={hideCount}
+        hideInZero={hideInZero}
+        initCount={data.suggestionCount?.discussion}
+        resId={data.id}
+      />
+    </Space>
+  );
+
+  return float ? (
+    <span
+      className="sent_read_interactive_button"
+      style={{ position: "absolute", left: left + width }}
+    >
+      {ButtonInner}
+    </span>
+  ) : (
+    ButtonInner
+  );
+};
+
+export default InteractiveButton;

+ 1 - 1
dashboard/src/components/template/SentEdit/SentCanRead.tsx

@@ -63,7 +63,7 @@ const SentCanReadWidget = ({
     const sentId = `${book}-${para}-${wordStart}-${wordEnd}`;
     let url = `/v2/sentence?view=sent-can-read&sentence=${sentId}&type=${type}&mode=edit&html=true`;
     url += channelsId ? `&excludes=${channelsId.join()}` : "";
-    if (type === "commentary") {
+    if (type === "commentary" || type === "similar") {
       url += channelsId ? `&channels=${channelsId.join()}` : "";
     }
     console.log("url", url);

+ 1 - 1
dashboard/src/components/template/SentEdit/SentCell.tsx

@@ -49,7 +49,7 @@ interface IWidget {
   compact?: boolean;
   showDiff?: boolean;
   diffText?: string | null;
-  onChange?: Function;
+  onChange?: (data: ISentence) => void;
   onDelete?: Function;
 }
 const SentCellWidget = ({

+ 8 - 1
dashboard/src/components/template/SentEdit/SentContent.tsx

@@ -33,6 +33,7 @@ interface IWidgetSentContent {
   wbwProgress?: boolean;
   readonly?: boolean;
   onWbwChange?: Function;
+  onTranslationChange?: (data: ISentence) => void;
   onMagicDictDone?: Function;
 }
 const SentContentWidget = ({
@@ -51,6 +52,7 @@ const SentContentWidget = ({
   wbwProgress = false,
   readonly = false,
   onWbwChange,
+  onTranslationChange,
   onMagicDictDone,
 }: IWidgetSentContent) => {
   const [layoutDirection, setLayoutDirection] = useState<TDirection>(layout);
@@ -194,7 +196,12 @@ const SentContentWidget = ({
               end={item.wordEnd}
               channelId={item.channel.id}
             >
-              <SentCell key={id} initValue={item} compact={compact} />
+              <SentCell
+                key={id}
+                initValue={item}
+                compact={compact}
+                onChange={onTranslationChange}
+              />
             </SuggestionFocus>
           );
         })}

+ 12 - 0
dashboard/src/components/template/SentEdit/SentSim.tsx

@@ -5,6 +5,7 @@ import { ReloadOutlined } from "@ant-design/icons";
 import { get } from "../../../request";
 import { ISentenceSimListResponse, ISimSent } from "../../api/Corpus";
 import MdView from "../MdView";
+import SentCanRead from "./SentCanRead";
 
 interface IWidget {
   book: number;
@@ -15,6 +16,7 @@ interface IWidget {
   limit?: number;
   reload?: boolean;
   onReload?: Function;
+  onCreate?: Function;
 }
 const SentSimWidget = ({
   book,
@@ -25,6 +27,7 @@ const SentSimWidget = ({
   channelsId,
   reload = false,
   onReload,
+  onCreate,
 }: IWidget) => {
   const [initLoading, setInitLoading] = useState(true);
   const [loading, setLoading] = useState(false);
@@ -66,6 +69,15 @@ const SentSimWidget = ({
 
   return (
     <>
+      <SentCanRead
+        book={book}
+        para={para}
+        wordStart={wordStart}
+        wordEnd={wordEnd}
+        type="similar"
+        channelsId={channelsId}
+        onCreate={onCreate}
+      />
       <List
         loading={initLoading}
         header={

+ 3 - 1
dashboard/src/components/template/SentEdit/SentTab.tsx

@@ -77,6 +77,7 @@ const SentTabWidget = ({
   const [currTranNum, setCurrTranNum] = useState(tranNum);
   const [currNissayaNum, setCurrNissayaNum] = useState(nissayaNum);
   const [currCommNum, setCurrCommNum] = useState(commNum);
+  const [currSimilarNum, setCurrSimilarNum] = useState(simNum);
   const [showWbwProgress, setShowWbwProgress] = useState(false);
 
   console.log("SentTabWidget render");
@@ -333,7 +334,7 @@ const SentTabWidget = ({
               icon={<BlockOutlined />}
               type="original"
               sentId={id}
-              count={simNum}
+              count={currSimilarNum}
               title={intl.formatMessage({
                 id: "buttons.sim",
               })}
@@ -348,6 +349,7 @@ const SentTabWidget = ({
               wordEnd={parseInt(sId[3])}
               channelsId={channelsId}
               limit={5}
+              onCreate={() => setCurrSimilarNum((origin) => origin + 1)}
             />
           ),
         },

+ 61 - 0
dashboard/src/components/template/SentEdit/SuggestionButton.tsx

@@ -0,0 +1,61 @@
+import { Space, Tooltip } from "antd";
+import { LikeOutlined, DeleteOutlined } from "@ant-design/icons";
+
+import { ISentence } from "../SentEdit";
+import { HandOutlinedIcon } from "../../../assets/icon";
+import SuggestionPopover from "./SuggestionPopover";
+import store from "../../../store";
+import { openPanel } from "../../../reducers/right-panel";
+import { show } from "../../../reducers/discussion";
+
+export const prOpen = (data: ISentence) => {
+  store.dispatch(
+    show({
+      type: "pr",
+      sent: data,
+    })
+  );
+  store.dispatch(openPanel("suggestion"));
+};
+
+interface IWidget {
+  data: ISentence;
+  hideCount?: boolean;
+  hideInZero?: boolean;
+}
+
+const SuggestionButton = ({
+  data,
+  hideCount = false,
+  hideInZero = false,
+}: IWidget) => {
+  const prNumber = data.suggestionCount?.suggestion;
+
+  return hideInZero && prNumber === 0 ? (
+    <></>
+  ) : (
+    <Space
+      style={{
+        cursor: "pointer",
+        color: prNumber && prNumber > 0 ? "#1890ff" : "unset",
+      }}
+      onClick={(event) => {
+        prOpen(data);
+      }}
+    >
+      <Tooltip title="修改建议">
+        <HandOutlinedIcon />
+      </Tooltip>
+      <SuggestionPopover
+        book={data.book}
+        para={data.para}
+        start={data.wordStart}
+        end={data.wordEnd}
+        channelId={data.channel.id}
+      />
+      {hideCount ? <></> : prNumber}
+    </Space>
+  );
+};
+
+export default SuggestionButton;

+ 4 - 44
dashboard/src/components/template/SentEdit/SuggestionToolbar.tsx

@@ -1,5 +1,6 @@
 import { Divider, Popconfirm, Space, Tooltip, Typography } from "antd";
 import { LikeOutlined, DeleteOutlined } from "@ant-design/icons";
+
 import { ISentence } from "../SentEdit";
 import { useEffect, useState } from "react";
 import PrAcceptButton from "./PrAcceptButton";
@@ -11,6 +12,8 @@ import { openPanel } from "../../../reducers/right-panel";
 import { useIntl } from "react-intl";
 import SuggestionPopover from "./SuggestionPopover";
 import DiscussionButton from "../../discussion/DiscussionButton";
+import SuggestionButton from "./SuggestionButton";
+import InteractiveButton from "./InteractiveButton";
 
 const { Paragraph } = Typography;
 
@@ -34,21 +37,8 @@ const SuggestionToolbarWidget = ({
   onPrClose,
   onDelete,
 }: IWidget) => {
-  const [CommentCount, setCommentCount] = useState<number | undefined>(
-    data.suggestionCount?.discussion
-  );
-  const discussionCount = useAppSelector(count);
   const intl = useIntl();
 
-  useEffect(() => {
-    if (
-      discussionCount?.resType === "sentence" &&
-      discussionCount.resId === data.id
-    ) {
-      setCommentCount(discussionCount.count);
-    }
-  }, [data.id, discussionCount]);
-  const prNumber = data.suggestionCount?.suggestion;
   return (
     <Paragraph type="secondary" style={style}>
       {isPr ? (
@@ -91,37 +81,7 @@ const SuggestionToolbarWidget = ({
           </Popconfirm>
         </Space>
       ) : (
-        <Space size={"small"}>
-          <Space
-            style={{
-              cursor: "pointer",
-              color: prNumber && prNumber > 0 ? "#1890ff" : "unset",
-            }}
-            onClick={(event) => {
-              store.dispatch(
-                show({
-                  type: "pr",
-                  sent: data,
-                })
-              );
-              store.dispatch(openPanel("suggestion"));
-            }}
-          >
-            <Tooltip title="修改建议">
-              <HandOutlinedIcon />
-            </Tooltip>
-            <SuggestionPopover
-              book={data.book}
-              para={data.para}
-              start={data.wordStart}
-              end={data.wordEnd}
-              channelId={data.channel.id}
-            />
-            {prNumber}
-          </Space>
-          {compact ? undefined : <Divider type="vertical" />}
-          <DiscussionButton initCount={CommentCount} resId={data.id} />
-        </Space>
+        <InteractiveButton data={data} compact={compact} />
       )}
     </Paragraph>
   );

+ 152 - 20
dashboard/src/components/template/SentRead.tsx

@@ -1,5 +1,6 @@
 import { useEffect, useRef, useState } from "react";
-import { Typography } from "antd";
+import { Button, Dropdown, MenuProps, Typography } from "antd";
+import { LoadingOutlined, CloseOutlined } from "@ant-design/icons";
 
 import { useAppSelector } from "../../hooks";
 import {
@@ -9,12 +10,38 @@ import {
 } from "../../reducers/setting";
 import { GetUserSetting } from "../auth/setting/default";
 import { TCodeConvertor } from "./utilities";
-import { ISentence } from "./SentEdit";
+import { ISentence, IWidgetSentEditInner, SentEditInner } from "./SentEdit";
 import MdView from "./MdView";
 import store from "../../store";
 import { push } from "../../reducers/sentence";
+import "./style.css";
+import InteractiveButton from "./SentEdit/InteractiveButton";
+import { prOpen } from "./SentEdit/SuggestionButton";
+import { openDiscussion } from "../discussion/DiscussionButton";
+import { IEditableSentence } from "../api/Corpus";
+import { get } from "../../request";
+
 const { Text } = Typography;
 
+const items: MenuProps["items"] = [
+  {
+    label: "编辑",
+    key: "edit",
+  },
+  {
+    label: "讨论",
+    key: "discussion",
+  },
+  {
+    label: "修改建议",
+    key: "pr",
+  },
+  {
+    label: "标签",
+    key: "tag",
+  },
+];
+
 interface IWidgetSentReadFrame {
   origin?: ISentence[];
   translation?: ISentence[];
@@ -38,6 +65,14 @@ const SentReadFrame = ({
   error,
 }: IWidgetSentReadFrame) => {
   const [paliCode1, setPaliCode1] = useState<TCodeConvertor>("roman");
+  const [active, setActive] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const [sentData, setSentData] = useState<IWidgetSentEditInner>();
+  const [showEdit, SetShowEdit] = useState(false);
+  const [translationData, setTranslationData] = useState<
+    ISentence[] | undefined
+  >(translation);
+
   const key = useAppSelector(onChangeKey);
   const value = useAppSelector(onChangeValue);
   const settings = useAppSelector(settingInfo);
@@ -61,10 +96,14 @@ const SentReadFrame = ({
     );
     if (typeof displayOriginal === "boolean") {
       if (boxOrg.current) {
-        if (displayOriginal === true) {
-          boxOrg.current.style.display = "block";
-        } else {
+        if (
+          displayOriginal === false &&
+          translation &&
+          translation.length > 0
+        ) {
           boxOrg.current.style.display = "none";
+        } else {
+          boxOrg.current.style.display = "block";
         }
       }
     }
@@ -83,20 +122,18 @@ const SentReadFrame = ({
       setPaliCode1(_paliCode1.toString() as TCodeConvertor);
     }
   }, [key, value, settings]);
+
   return (
-    <div
-      style={{ display: "flex", flexDirection: layout, marginBottom: 10 }}
-      ref={boxSent}
-    >
+    <span ref={boxSent} className="sent_read_shell">
       <Text type="danger" mark>
         {error}
       </Text>
-      <div
+      <span
         dangerouslySetInnerHTML={{
-          __html: `<div class="pcd_sent" id="sent_${book}-${para}-${wordStart}-${wordEnd}"></div>`,
+          __html: `<span class="pcd_sent" id="sent_${book}-${para}-${wordStart}-${wordEnd}"></span>`,
         }}
       />
-      <div style={{ flex: "5", color: "#9f3a01" }} ref={boxOrg}>
+      <span style={{ flex: "5", color: "#9f3a01" }} ref={boxOrg}>
         {origin?.map((item, id) => {
           return (
             <Text key={id}>
@@ -109,17 +146,112 @@ const SentReadFrame = ({
             </Text>
           );
         })}
-      </div>
-      <div style={{ flex: "5" }}>
-        {translation?.map((item, id) => {
+      </span>
+      <span className="sent_read" style={{ flex: "5" }}>
+        {translationData?.map((item, id) => {
           return (
-            <Text key={id}>
-              <MdView html={item.html} />
-            </Text>
+            <span key={id}>
+              {loading ? <LoadingOutlined /> : <></>}
+              <Dropdown
+                menu={{
+                  items,
+                  onClick: (e) => {
+                    console.log("click ", e);
+                    switch (e.key) {
+                      case "edit":
+                        const url = `/v2/editable-sentence/${item.id}`;
+                        console.info("api request", url);
+                        setLoading(true);
+                        get<IEditableSentence>(url)
+                          .then((json) => {
+                            console.info("api response", json);
+                            if (json.ok) {
+                              setSentData(json.data);
+                              SetShowEdit(true);
+                            }
+                          })
+                          .finally(() => setLoading(false));
+                        break;
+                      case "discussion":
+                        if (item.id) {
+                          openDiscussion(item.id, false);
+                        }
+                        break;
+                      case "pr":
+                        prOpen(item);
+                        break;
+                      default:
+                        break;
+                    }
+                  },
+                }}
+                trigger={["contextMenu"]}
+              >
+                <Text
+                  key={id}
+                  className="sent_read_translation"
+                  style={{ display: showEdit ? "none" : "inline" }}
+                >
+                  <MdView
+                    html={item.html}
+                    style={{ backgroundColor: active ? "beige" : "unset" }}
+                  />
+                </Text>
+              </Dropdown>
+              <div style={{ display: showEdit ? "block" : "none" }}>
+                <div style={{ textAlign: "right" }}>
+                  <Button
+                    size="small"
+                    icon={<CloseOutlined />}
+                    onClick={() => {
+                      SetShowEdit(false);
+                    }}
+                  >
+                    返回审阅模式
+                  </Button>
+                </div>
+
+                {sentData ? (
+                  <SentEditInner
+                    mode="edit"
+                    {...sentData}
+                    onTranslationChange={(data: ISentence) => {
+                      console.log("onTranslationChange", data);
+                      if (translationData) {
+                        let newData = [...translationData];
+                        newData.forEach(
+                          (
+                            value: ISentence,
+                            index: number,
+                            array: ISentence[]
+                          ) => {
+                            if (index === id) {
+                              array[index] = data;
+                            }
+                          }
+                        );
+                        setTranslationData(newData);
+                      }
+                    }}
+                  />
+                ) : (
+                  "无数据"
+                )}
+              </div>
+              <InteractiveButton
+                data={item}
+                compact={true}
+                float={true}
+                hideCount
+                hideInZero
+                onMouseEnter={() => setActive(true)}
+                onMouseLeave={() => setActive(false)}
+              />
+            </span>
           );
         })}
-      </div>
-    </div>
+      </span>
+    </span>
   );
 };
 

+ 1 - 5
dashboard/src/components/template/Wbw/WbwDetail.tsx

@@ -211,11 +211,7 @@ const WbwDetailWidget = ({
                   console.debug("WbwDetailBasic onchange", e);
                   fieldChanged(e.field, e.value);
                 }}
-                onRelationAdd={() => {
-                  if (typeof onClose !== "undefined") {
-                    onClose();
-                  }
-                }}
+                onRelationAdd={onClose}
               />
             ),
           },

+ 1 - 5
dashboard/src/components/template/Wbw/WbwDetailBasic.tsx

@@ -267,11 +267,7 @@ const WbwDetailBasicWidget = ({
               onChange(e);
             }
           }}
-          onRelationAdd={() => {
-            if (typeof onRelationAdd !== "undefined") {
-              onRelationAdd();
-            }
-          }}
+          onRelationAdd={onRelationAdd}
         />
       </Form>
     </>

+ 1 - 5
dashboard/src/components/template/Wbw/WbwDetailBasicRelation.tsx

@@ -67,11 +67,7 @@ const WbwDetailBasicRelationWidget = ({
               onChange(e);
             }
           }}
-          onAdd={() => {
-            if (typeof onRelationAdd !== "undefined") {
-              onRelationAdd();
-            }
-          }}
+          onAdd={onRelationAdd}
           onFromList={(value: string[]) => setFromList(value)}
         />
       </Collapse.Panel>

+ 2 - 0
dashboard/src/components/template/Wbw/WbwDetailRelation.tsx

@@ -112,6 +112,7 @@ const WbwDetailRelationWidget = ({
   useEffect(() => {
     let grammar = data.case?.value
       ?.replace("#", "$")
+      .replace(":", "$")
       .replaceAll(".", "")
       .split("$");
     if (data.grammar2?.value) {
@@ -268,6 +269,7 @@ const WbwDetailRelationWidget = ({
                 const currSelect = relationOptions?.filter(
                   (rl) => rl.name === value
                 );
+                console.log("filteredRelation", currSelect);
                 setCurrRelation(currSelect);
                 console.log(`selected ${value}`);
                 setRelation((origin) => {

+ 7 - 2
dashboard/src/components/template/Wbw/WbwPali.tsx

@@ -108,8 +108,11 @@ const WbwPaliWidget = ({
    * 高亮可能的单词
    */
   useEffect(() => {
+    console.debug("relation match data=", data);
+
     let grammar = data.case?.value
       ?.replace("#", "$")
+      .replace(":", "$")
       .replaceAll(".", "")
       .split("$");
     if (data.grammar2?.value) {
@@ -141,11 +144,13 @@ const WbwPaliWidget = ({
           caseMatch = false;
         }
       }
-      if (value.from?.spell) {
-        if (data.real.value !== value.from?.spell) {
+      if (value.to?.spell) {
+        if (data.real.value !== value.to?.spell) {
           spellMatch = false;
         }
       }
+      console.debug("relation match", value, caseMatch, spellMatch);
+
       return caseMatch && spellMatch;
     });
     if (match && match.length > 0) {

+ 15 - 0
dashboard/src/components/template/style.css

@@ -6,3 +6,18 @@
 .pcd_word:hover {
   text-decoration: underline dotted;
 }
+.sent_read p {
+  display: inline;
+}
+
+.sent_read_translation:hover {
+  background-color: beige;
+}
+
+.sent_read_interactive_button:hover ~ .sent_read_translation {
+  background-color: beige;
+}
+
+.sent_read_translation:hover ~ .sent_read_interactive_button {
+  border: 1px solid black;
+}

+ 1 - 0
dashboard/src/locales/en-US/channel/index.ts

@@ -13,6 +13,7 @@ const items = {
   "channel.fields.lang.label": "语言",
   "channel.fields.type.label": "类型",
   "channel.fields.name.label": "名称",
+  "channel.type.similar.label": "Similar",
 };
 
 export default items;

+ 1 - 0
dashboard/src/locales/zh-Hans/channel/index.ts

@@ -13,6 +13,7 @@ const items = {
   "channel.fields.lang.label": "语言",
   "channel.fields.type.label": "类型",
   "channel.fields.name.label": "名称",
+  "channel.type.similar.label": "相似句",
 };
 
 export default items;

+ 7 - 1
dashboard/src/pages/library/article/show.tsx

@@ -337,8 +337,14 @@ const Widget = () => {
           style={{ width: `calc(100% - ${rightBarWidth})`, display: "flex" }}
         >
           <div
+            className="article_shell"
             key="Article"
-            style={{ marginLeft: "auto", marginRight: "auto", width: 1100 }}
+            style={{
+              marginLeft: "auto",
+              marginRight: "auto",
+              width: 1100,
+              maxWidth: currMode === "read" ? 750 : "unset",
+            }}
           >
             <LoginAlertModal mode={currMode} />
             {type !== "textbook" ? (

+ 5 - 0
deploy/roles/mint-v2/tasks/dashboard.yml

@@ -1,3 +1,8 @@
+- name: remove current dashboard folder
+  ansible.builtin.file:
+    path: "{{ app_deploy_root }}/dashboard/"
+    state: absent
+
 - name: upload dashboard
   ansible.builtin.copy:
     src: "{{ playbook_dir }}/../dashboard/build/"