Bläddra i källkod

Merge pull request #1886 from visuddhinanda/agile

发布查询语法书消息到语法手册
visuddhinanda 2 år sedan
förälder
incheckning
fb18b5428f
38 ändrade filer med 1075 tillägg och 271 borttagningar
  1. 21 0
      dashboard/src/assets/icon/index.tsx
  2. 6 0
      dashboard/src/components/api/Comment.ts
  3. 3 1
      dashboard/src/components/article/ArticleEdit.tsx
  4. 12 0
      dashboard/src/components/article/RightPanel.tsx
  5. 3 0
      dashboard/src/components/article/TypeArticle.tsx
  6. 11 8
      dashboard/src/components/dict/Dictionary.tsx
  7. 4 1
      dashboard/src/components/dict/SearchVocabulary.tsx
  8. 7 6
      dashboard/src/components/discussion/AnchorCard.tsx
  9. 48 18
      dashboard/src/components/discussion/Discussion.tsx
  10. 28 4
      dashboard/src/components/discussion/DiscussionAnchor.tsx
  11. 10 65
      dashboard/src/components/discussion/DiscussionBox.tsx
  12. 10 0
      dashboard/src/components/discussion/DiscussionCreate.tsx
  13. 12 0
      dashboard/src/components/discussion/DiscussionItem.tsx
  14. 11 3
      dashboard/src/components/discussion/DiscussionListCard.tsx
  15. 139 65
      dashboard/src/components/discussion/DiscussionShow.tsx
  16. 13 1
      dashboard/src/components/discussion/DiscussionTopic.tsx
  17. 6 6
      dashboard/src/components/discussion/DiscussionTopicChildren.tsx
  18. 14 2
      dashboard/src/components/discussion/DiscussionTopicInfo.tsx
  19. 116 0
      dashboard/src/components/discussion/InteractiveArea.tsx
  20. 75 0
      dashboard/src/components/discussion/QaList.tsx
  21. 32 14
      dashboard/src/components/notification/NotificationIcon.tsx
  22. 22 2
      dashboard/src/components/notification/NotificationList.tsx
  23. 2 0
      dashboard/src/components/template/SentEdit/SentTabCopy.tsx
  24. 0 5
      dashboard/src/components/template/SentEdit/SuggestionToolbar.tsx
  25. 15 55
      dashboard/src/components/template/Wbw/WbwDetailBasic.tsx
  26. 80 0
      dashboard/src/components/template/Wbw/WbwDetailBasicRelation.tsx
  27. 35 4
      dashboard/src/components/template/Wbw/WbwDetailRelation.tsx
  28. 16 2
      dashboard/src/components/template/Wbw/WbwPali.tsx
  29. 1 0
      dashboard/src/components/template/Wbw/WbwWord.tsx
  30. 22 3
      dashboard/src/components/template/WbwSent.tsx
  31. 204 0
      dashboard/src/components/term/GrammarBook.tsx
  32. 65 0
      dashboard/src/components/term/GrammarRecent.tsx
  33. 5 4
      dashboard/src/load.ts
  34. 1 0
      dashboard/src/locales/en-US/buttons.ts
  35. 1 0
      dashboard/src/locales/zh-Hans/buttons.ts
  36. 1 0
      dashboard/src/pages/library/discussion/topic.tsx
  37. 15 0
      dashboard/src/reducers/command.ts
  38. 9 2
      dashboard/src/reducers/term-vocabulary.ts

+ 21 - 0
dashboard/src/assets/icon/index.tsx

@@ -590,6 +590,23 @@ const NotificationOutlined = () => (
     ></path>
   </svg>
 );
+
+const HandBookOutlined = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="4305"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M704 192v384H257.6c-24 0-46.4 6.4-65.6 17.6V256.8c0-36 28.8-64.8 64.8-64.8H704m64-64H256.8C185.6 128 128 185.6 128 256.8v510.4c0 71.2 57.6 128.8 128.8 128.8H896V192h-64v640H257.6c-36 0-65.6-29.6-65.6-65.6v-60.8c0-36 29.6-65.6 65.6-65.6H768V128z m0 576H256v64h512v-64z"
+      fill="currentColor"
+      p-id="4306"
+    ></path>
+  </svg>
+);
 export const DictIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={DictSvg} {...props} />
 );
@@ -703,3 +720,7 @@ export const DocIcon = (props: Partial<CustomIconComponentProps>) => (
 export const NotificationIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={NotificationOutlined} {...props} />
 );
+
+export const HandBookIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={HandBookOutlined} {...props} />
+);

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

@@ -1,4 +1,5 @@
 import { IUser } from "../auth/User";
+import { TDiscussionType } from "../discussion/Discussion";
 import { TContentType } from "../discussion/DiscussionCreate";
 import { TResType } from "../discussion/DiscussionListCard";
 
@@ -6,10 +7,12 @@ export interface ICommentRequest {
   id?: string;
   res_id?: string;
   res_type?: string;
+  type?: TDiscussionType;
   title?: string;
   content?: string;
   content_type?: TContentType;
   parent?: string;
+  topicId?: string;
   tpl_id?: string;
   status?: "active" | "close";
   editor?: IUser;
@@ -21,6 +24,7 @@ export interface ICommentApiData {
   id: string;
   res_id: string;
   res_type: TResType;
+  type: TDiscussionType;
   title?: string;
   content?: string;
   content_type?: TContentType;
@@ -49,6 +53,8 @@ export interface ICommentListResponse {
     count: number;
     active: number;
     close: number;
+    can_create: boolean;
+    can_reply: boolean;
   };
 }
 export interface ICommentAnchorResponse {

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

@@ -115,7 +115,9 @@ const ArticleEditWidget = ({
             });
         }}
         request={async () => {
-          const res = await get<IArticleResponse>(`/v2/article/${articleId}`);
+          const url = `/v2/article/${articleId}`;
+          console.info("url", url);
+          const res = await get<IArticleResponse>(url);
           console.log("article", res);
           let mTitle: string,
             mReadonly = false;

+ 12 - 0
dashboard/src/components/article/RightPanel.tsx

@@ -15,6 +15,7 @@ import { show } from "../../reducers/discussion";
 import { useIntl } from "react-intl";
 import SuggestionBox from "../template/SentEdit/SuggestionBox";
 import ChannelMy from "../channel/ChannelMy";
+import GrammarBook from "../term/GrammarBook";
 
 export type TPanelName =
   | "dict"
@@ -205,6 +206,17 @@ const RightPanelWidget = ({
                 </div>
               ),
             },
+            {
+              label: intl.formatMessage({
+                id: "columns.library.palihandbook.title",
+              }),
+              key: "grammar",
+              children: (
+                <div style={tabInnerStyle}>
+                  <GrammarBook />
+                </div>
+              ),
+            },
           ]}
         />
       </div>

+ 3 - 0
dashboard/src/components/article/TypeArticle.tsx

@@ -18,6 +18,7 @@ import ArticleSkeleton from "./ArticleSkeleton";
 import ErrorResult from "../general/ErrorResult";
 import AnthologiesAtArticle from "./AnthologiesAtArticle";
 import NavigateButton from "./NavigateButton";
+import InteractiveArea from "../discussion/InteractiveArea";
 
 interface IWidget {
   type?: ArticleType;
@@ -231,6 +232,8 @@ const TypeArticleWidget = ({
               }
             }}
           />
+
+          <InteractiveArea resType={"article"} resId={articleId} />
         </>
       )}
     </div>

+ 11 - 8
dashboard/src/components/dict/Dictionary.tsx

@@ -87,14 +87,17 @@ const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
           <Row style={{ paddingTop: "0.5em", paddingBottom: "0.5em" }}>
             {compact ? <></> : <Col flex="auto"></Col>}
             <Col flex="560px">
-              <SearchVocabulary
-                value={wordInput?.toLowerCase()}
-                onSearch={dictSearch}
-                onSplit={(word: string | undefined) => {
-                  console.log("onSplit", word);
-                  setSplit(word);
-                }}
-              />
+              <div style={{ display: "flex" }}>
+                <SearchVocabulary
+                  compact={compact}
+                  value={wordInput?.toLowerCase()}
+                  onSearch={dictSearch}
+                  onSplit={(word: string | undefined) => {
+                    console.log("onSplit", word);
+                    setSplit(word);
+                  }}
+                />
+              </div>
             </Col>
             {compact ? <></> : <Col flex="auto"></Col>}
           </Row>

+ 4 - 1
dashboard/src/components/dict/SearchVocabulary.tsx

@@ -14,12 +14,14 @@ interface ValueType {
 interface IWidget {
   value?: string;
   api?: string;
+  compact?: boolean;
   onSearch?: Function;
   onSplit?: Function;
 }
 const SearchVocabularyWidget = ({
   value,
   api = "vocabulary",
+  compact = false,
   onSplit,
   onSearch,
 }: IWidget) => {
@@ -140,7 +142,8 @@ const SearchVocabularyWidget = ({
         }}
       >
         <Input.Search
-          size="large"
+          style={{ width: "100%" }}
+          size={compact ? undefined : "large"}
           placeholder="search here"
           onSearch={(value: string) => {
             console.log("on search", value);

+ 7 - 6
dashboard/src/components/discussion/AnchorCard.tsx

@@ -7,10 +7,15 @@ import { modeChange } from "../../reducers/article-mode";
 import { ArticleMode } from "../article/Article";
 
 interface IWidgetArticleCard {
+  title?: React.ReactNode;
   children?: React.ReactNode;
   onModeChange?: Function;
 }
-const AnchorCardWidget = ({ children, onModeChange }: IWidgetArticleCard) => {
+const AnchorCardWidget = ({
+  title,
+  children,
+  onModeChange,
+}: IWidgetArticleCard) => {
   const intl = useIntl();
   const [mode, setMode] = useState<string>("read");
 
@@ -43,11 +48,7 @@ const AnchorCardWidget = ({ children, onModeChange }: IWidgetArticleCard) => {
   );
 
   return (
-    <Card
-      size="small"
-      title={<Space>{"title"}</Space>}
-      extra={<Space>{modeSwitch}</Space>}
-    >
+    <Card size="small" title={title} extra={<Space>{modeSwitch}</Space>}>
       {children}
     </Card>
   );

+ 48 - 18
dashboard/src/components/discussion/Discussion.tsx

@@ -4,36 +4,53 @@ import { ArrowLeftOutlined } from "@ant-design/icons";
 import DiscussionTopic from "./DiscussionTopic";
 import DiscussionListCard, { TResType } from "./DiscussionListCard";
 import { IComment } from "./DiscussionItem";
-import { useAppSelector } from "../../hooks";
-import {
-  countChange,
-  IShowDiscussion,
-  message,
-  show,
-  showAnchor,
-} from "../../reducers/discussion";
-import { Button } from "antd";
+
+import { countChange } from "../../reducers/discussion";
+import { Button, Space, Typography } from "antd";
 import store from "../../store";
 
+const { Text } = Typography;
+
 export interface IAnswerCount {
   id: string;
   count: number;
 }
+export type TDiscussionType = "qa" | "discussion" | "help" | "comment";
 
 interface IWidget {
   resId?: string;
-  resType: TResType;
+  resType?: TResType;
+  showTopicId?: string;
   focus?: string;
+  type?: TDiscussionType;
+  onTopicReady?: Function;
 }
 
-const DiscussionWidget = ({ resId, resType, focus }: IWidget) => {
+const DiscussionWidget = ({
+  resId,
+  resType,
+  showTopicId,
+  focus,
+  type = "discussion",
+  onTopicReady,
+}: IWidget) => {
   const [childrenDrawer, setChildrenDrawer] = useState(false);
   const [topicId, setTopicId] = useState<string>();
   const [topic, setTopic] = useState<IComment>();
   const [answerCount, setAnswerCount] = useState<IAnswerCount>();
-  const [currTopic, setCurrTopic] = useState<IComment>();
+  const [topicTitle, setTopicTitle] = useState<string>();
+
+  useEffect(() => {
+    if (showTopicId) {
+      setChildrenDrawer(true);
+      setTopicId(showTopicId);
+    } else {
+      setChildrenDrawer(false);
+    }
+  }, [showTopicId]);
 
   const showChildrenDrawer = (comment: IComment) => {
+    console.debug("discussion comment", comment);
     setChildrenDrawer(true);
     if (comment.id) {
       setTopicId(comment.id);
@@ -48,31 +65,44 @@ const DiscussionWidget = ({ resId, resType, focus }: IWidget) => {
     <>
       {childrenDrawer ? (
         <div>
-          <Button
-            shape="circle"
-            icon={<ArrowLeftOutlined />}
-            onClick={() => setChildrenDrawer(false)}
-          />
+          <Space>
+            <Button
+              shape="circle"
+              icon={<ArrowLeftOutlined />}
+              onClick={() => setChildrenDrawer(false)}
+            />
+            <Text strong style={{ fontSize: 16 }}>
+              {topic ? topic.title : topicTitle}
+            </Text>
+          </Space>
           <DiscussionTopic
             resType={resType}
             topicId={topicId}
             topic={topic}
             focus={focus}
+            hideTitle
             onItemCountChange={(count: number, parent: string) => {
               setAnswerCount({ id: parent, count: count });
             }}
             onTopicReady={(value: IComment) => {
-              setCurrTopic(value);
+              setTopicTitle(value.title);
+              if (typeof onTopicReady !== "undefined") {
+                onTopicReady(value);
+              }
             }}
             onTopicDelete={() => {
               setChildrenDrawer(false);
             }}
+            onConvert={(value: TDiscussionType) => {
+              setChildrenDrawer(false);
+            }}
           />
         </div>
       ) : (
         <DiscussionListCard
           resId={resId}
           resType={resType}
+          type={type}
           onSelect={(
             e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
             comment: IComment

+ 28 - 4
dashboard/src/components/discussion/DiscussionAnchor.tsx

@@ -7,6 +7,7 @@ import { ISentenceData, ISentenceResponse } from "../api/Corpus";
 import MdView from "../template/MdView";
 import AnchorCard from "./AnchorCard";
 import { TResType } from "./DiscussionListCard";
+import { Link } from "react-router-dom";
 
 export interface IAnchor {
   type: TResType;
@@ -25,8 +26,10 @@ const DiscussionAnchorWidget = ({
   topicId,
   onLoad,
 }: IWidget) => {
+  const [title, setTitle] = useState<React.ReactNode>();
   const [content, setContent] = useState<string>();
-  const [loading, setLoading] = useState(true);
+  const [loading, setLoading] = useState(false);
+
   useEffect(() => {
     if (typeof topicId === "string") {
       get<ICommentAnchorResponse>(`/v2/discussion-anchor/${topicId}`).then(
@@ -41,10 +44,12 @@ const DiscussionAnchorWidget = ({
   }, [topicId]);
 
   useEffect(() => {
+    let url: string;
     switch (resType) {
       case "sentence":
-        const url = `/v2/sentence/${resId}`;
+        url = `/v2/sentence/${resId}`;
         console.log("url", url);
+        setLoading(true);
         get<ISentenceResponse>(url)
           .then((json) => {
             if (json.ok) {
@@ -64,16 +69,35 @@ const DiscussionAnchorWidget = ({
           })
           .finally(() => setLoading(false));
         break;
+      case "article":
+        url = `/v2/article/${resId}`;
+        console.info("url", url);
+        setLoading(true);
+
+        get<IArticleResponse>(url)
+          .then((json) => {
+            if (json.ok) {
+              setTitle(
+                <Link to={`/article/article/${resId}`}>{json.data.title}</Link>
+              );
+              setContent(json.data.content?.substring(0, 200));
+            }
+          })
+          .finally(() => setLoading(false));
+        break;
       default:
         break;
     }
   }, [resId, resType]);
+
   return (
-    <AnchorCard>
+    <AnchorCard title={title}>
       {loading ? (
         <Skeleton title={{ width: 200 }} paragraph={{ rows: 4 }} active />
       ) : (
-        <MdView html={content} />
+        <div>
+          <MdView html={content} />
+        </div>
       )}
     </AnchorCard>
   );

+ 10 - 65
dashboard/src/components/discussion/DiscussionBox.tsx

@@ -1,12 +1,8 @@
 import { useEffect, useState } from "react";
-import { ArrowLeftOutlined } from "@ant-design/icons";
 
-import DiscussionTopic from "./DiscussionTopic";
-import DiscussionListCard from "./DiscussionListCard";
 import { IComment } from "./DiscussionItem";
 import { useAppSelector } from "../../hooks";
 import {
-  countChange,
   IShowDiscussion,
   message,
   show,
@@ -14,6 +10,7 @@ import {
 } from "../../reducers/discussion";
 import { Button } from "antd";
 import store from "../../store";
+import Discussion from "./Discussion";
 
 export interface IAnswerCount {
   id: string;
@@ -25,10 +22,7 @@ interface IWidget {
 }
 
 const DiscussionBoxWidget = ({ onTopicChange }: IWidget) => {
-  const [childrenDrawer, setChildrenDrawer] = useState(false);
   const [topicId, setTopicId] = useState<string>();
-  const [topic, setTopic] = useState<IComment>();
-  const [answerCount, setAnswerCount] = useState<IAnswerCount>();
   const [currTopic, setCurrTopic] = useState<IComment>();
 
   const discussionMessage = useAppSelector(message);
@@ -36,25 +30,12 @@ const DiscussionBoxWidget = ({ onTopicChange }: IWidget) => {
   useEffect(() => {
     if (discussionMessage) {
       if (discussionMessage.topic) {
-        setChildrenDrawer(true);
         setTopicId(discussionMessage.topic);
       } else {
-        setChildrenDrawer(false);
       }
     }
   }, [discussionMessage]);
 
-  const showChildrenDrawer = (comment: IComment) => {
-    setChildrenDrawer(true);
-    if (comment.id) {
-      setTopicId(comment.id);
-      setTopic(undefined);
-    } else {
-      setTopicId(undefined);
-      setTopic(comment);
-    }
-  };
-
   return (
     <>
       <Button
@@ -73,51 +54,15 @@ const DiscussionBoxWidget = ({ onTopicChange }: IWidget) => {
       >
         显示译文
       </Button>
-      {childrenDrawer ? (
-        <div>
-          <Button
-            shape="circle"
-            icon={<ArrowLeftOutlined />}
-            onClick={() => setChildrenDrawer(false)}
-          />
-          <DiscussionTopic
-            resType={discussionMessage?.resType}
-            topicId={topicId}
-            topic={topic}
-            focus={discussionMessage?.comment}
-            onItemCountChange={(count: number, parent: string) => {
-              setAnswerCount({ id: parent, count: count });
-            }}
-            onTopicReady={(value: IComment) => {
-              setCurrTopic(value);
-            }}
-            onTopicDelete={() => {
-              setChildrenDrawer(false);
-            }}
-          />
-        </div>
-      ) : (
-        <DiscussionListCard
-          resId={discussionMessage?.resId}
-          resType={discussionMessage?.resType}
-          onSelect={(
-            e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
-            comment: IComment
-          ) => showChildrenDrawer(comment)}
-          onReply={(comment: IComment) => showChildrenDrawer(comment)}
-          onReady={() => {}}
-          changedAnswerCount={answerCount}
-          onItemCountChange={(count: number) => {
-            store.dispatch(
-              countChange({
-                count: count,
-                resId: discussionMessage?.resId,
-                resType: discussionMessage?.resType,
-              })
-            );
-          }}
-        />
-      )}
+      <Discussion
+        resId={discussionMessage?.resId}
+        resType={discussionMessage?.resType}
+        focus={discussionMessage?.comment}
+        showTopicId={topicId}
+        onTopicReady={(value: IComment) => {
+          setCurrTopic(value);
+        }}
+      />
     </>
   );
 };

+ 10 - 0
dashboard/src/components/discussion/DiscussionCreate.tsx

@@ -20,6 +20,7 @@ import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 import { useEffect, useRef, useState } from "react";
 import MDEditor from "@uiw/react-md-editor";
+import { TDiscussionType } from "./Discussion";
 
 export type TContentType = "text" | "markdown" | "html" | "json";
 
@@ -28,6 +29,7 @@ export const toIComment = (value: ICommentApiData): IComment => {
     id: value.id,
     resId: value.res_id,
     resType: value.res_type,
+    type: value.type,
     user: value.editor,
     title: value.title,
     parent: value.parent,
@@ -41,6 +43,8 @@ interface IWidget {
   resId?: string;
   resType?: string;
   parent?: string;
+  topicId?: string;
+  type?: TDiscussionType;
   topic?: IComment;
   contentType?: TContentType;
   onCreated?: Function;
@@ -51,7 +55,9 @@ const DiscussionCreateWidget = ({
   resType,
   contentType = "html",
   parent,
+  topicId,
   topic,
+  type = "discussion",
   onCreated,
   onTopicCreated,
 }: IWidget) => {
@@ -85,6 +91,7 @@ const DiscussionCreateWidget = ({
                     tpl_id: topic.tplId,
                     content: topic.content,
                     content_type: "markdown",
+                    type: topic.type,
                   };
                   console.log("create topic", topicData);
                   const newTopic = await post<
@@ -104,13 +111,16 @@ const DiscussionCreateWidget = ({
                 }
               }
               console.log("parent", currParent);
+
               post<ICommentRequest, ICommentResponse>(`/v2/discussion`, {
                 res_id: resId,
                 res_type: resType,
                 parent: newParent ? newParent : currParent,
+                topicId: topicId,
                 title: values.title,
                 content: values.content,
                 content_type: contentType,
+                type: topic ? topic.type : type,
               })
                 .then((json) => {
                   console.log("new discussion", json);

+ 12 - 0
dashboard/src/components/discussion/DiscussionItem.tsx

@@ -4,11 +4,13 @@ import { IUser } from "../auth/User";
 import DiscussionShow from "./DiscussionShow";
 import DiscussionEdit from "./DiscussionEdit";
 import { TResType } from "./DiscussionListCard";
+import { TDiscussionType } from "./Discussion";
 
 export interface IComment {
   id?: string; //id未提供为新建
   resId?: string;
   resType?: TResType;
+  type: TDiscussionType;
   tplId?: string;
   user: IUser;
   parent?: string | null;
@@ -26,20 +28,24 @@ export interface IComment {
 interface IWidget {
   data: IComment;
   isFocus?: boolean;
+  hideTitle?: boolean;
   onSelect?: Function;
   onCreated?: Function;
   onDelete?: Function;
   onReply?: Function;
   onClose?: Function;
+  onConvert?: Function;
 }
 const DiscussionItemWidget = ({
   data,
   isFocus = false,
+  hideTitle = false,
   onSelect,
   onCreated,
   onDelete,
   onReply,
   onClose,
+  onConvert,
 }: IWidget) => {
   const [edit, setEdit] = useState(false);
   const [currData, setCurrData] = useState<IComment>(data);
@@ -78,6 +84,7 @@ const DiscussionItemWidget = ({
         ) : (
           <DiscussionShow
             data={currData}
+            hideTitle={hideTitle}
             onEdit={() => {
               setEdit(true);
             }}
@@ -101,6 +108,11 @@ const DiscussionItemWidget = ({
                 onClose(value);
               }
             }}
+            onConvert={(value: TDiscussionType) => {
+              if (typeof onConvert !== "undefined") {
+                onConvert(value);
+              }
+            }}
           />
         )}
       </div>

+ 11 - 3
dashboard/src/components/discussion/DiscussionListCard.tsx

@@ -1,6 +1,5 @@
 import { useEffect, useRef, useState } from "react";
 import { Button, Space, Typography } from "antd";
-import { CommentOutlined } from "@ant-design/icons";
 
 import { get } from "../../request";
 import { ICommentListResponse } from "../api/Comment";
@@ -15,6 +14,7 @@ import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 import { CommentOutlinedIcon, TemplateOutlinedIcon } from "../../assets/icon";
 import { ISentenceResponse } from "../api/Corpus";
+import { TDiscussionType } from "./Discussion";
 
 export type TResType =
   | "article"
@@ -29,6 +29,7 @@ interface IWidget {
   resType?: TResType;
   topicId?: string;
   changedAnswerCount?: IAnswerCount;
+  type?: TDiscussionType;
   onSelect?: Function;
   onItemCountChange?: Function;
   onReply?: Function;
@@ -40,6 +41,7 @@ const DiscussionListCardWidget = ({
   topicId,
   onSelect,
   changedAnswerCount,
+  type = "discussion",
   onItemCountChange,
   onReply,
   onReady,
@@ -49,6 +51,8 @@ const DiscussionListCardWidget = ({
   const [activeNumber, setActiveNumber] = useState<number>(0);
   const [closeNumber, setCloseNumber] = useState<number>(0);
   const [count, setCount] = useState<number>(0);
+  const [canCreate, setCanCreate] = useState(false);
+
   const user = useAppSelector(_currentUser);
 
   useEffect(() => {
@@ -126,7 +130,7 @@ const DiscussionListCardWidget = ({
           },
         }}
         request={async (params = {}, sorter, filter) => {
-          let url: string = "/v2/discussion?";
+          let url: string = `/v2/discussion?type=${type}&res_type=${resType}&`;
           if (typeof topicId !== "undefined") {
             url += `view=question-by-topic&id=${topicId}`;
           } else if (typeof resId !== "undefined") {
@@ -146,11 +150,13 @@ const DiscussionListCardWidget = ({
           console.log("url", url);
           const res = await get<ICommentListResponse>(url);
           setCount(res.data.active);
+          setCanCreate(res.data.can_create);
           const items: IComment[] = res.data.rows.map((item, id) => {
             return {
               id: item.id,
               resId: item.res_id,
               resType: item.res_type,
+              type: item.type,
               user: item.editor,
               title: item.title,
               parent: item.parent,
@@ -190,6 +196,7 @@ const DiscussionListCardWidget = ({
                     tplId: item.uid,
                     resId: resId,
                     resType: resType,
+                    type: "discussion",
                     user: item.editor
                       ? item.editor
                       : { id: "", userName: "", nickName: "" },
@@ -260,11 +267,12 @@ const DiscussionListCardWidget = ({
         }}
       />
 
-      {resId && resType ? (
+      {canCreate && resId && resType ? (
         <DiscussionCreate
           contentType="markdown"
           resId={resId}
           resType={resType}
+          type={type}
           onCreated={(e: IComment) => {
             if (typeof onItemCountChange !== "undefined") {
               onItemCountChange(count + 1);

+ 139 - 65
dashboard/src/components/discussion/DiscussionShow.tsx

@@ -5,6 +5,7 @@ import {
   Dropdown,
   message,
   Modal,
+  notification,
   Space,
   Tag,
   Typography,
@@ -18,6 +19,7 @@ import {
   MessageOutlined,
   ExclamationCircleOutlined,
   CloseOutlined,
+  SyncOutlined,
 } from "@ant-design/icons";
 import type { MenuProps } from "antd";
 
@@ -30,24 +32,29 @@ import { fullUrl } from "../../utils";
 import { ICommentRequest, ICommentResponse } from "../api/Comment";
 import { useState } from "react";
 import MdView from "../template/MdView";
+import { TDiscussionType } from "./Discussion";
 
 const { Text } = Typography;
 
 interface IWidget {
   data: IComment;
+  hideTitle?: boolean;
   onEdit?: Function;
   onSelect?: Function;
   onDelete?: Function;
   onReply?: Function;
   onClose?: Function;
+  onConvert?: Function;
 }
 const DiscussionShowWidget = ({
   data,
+  hideTitle = false,
   onEdit,
   onSelect,
   onDelete,
   onReply,
   onClose,
+  onConvert,
 }: IWidget) => {
   const intl = useIntl();
   const [closed, setClosed] = useState(data.status);
@@ -104,6 +111,23 @@ const DiscussionShowWidget = ({
     });
   };
 
+  const convert = (newType: TDiscussionType) => {
+    put<ICommentRequest, ICommentResponse>(`/v2/discussion/${data.id}`, {
+      title: data.title,
+      content: data.content,
+      status: data.status,
+      type: newType,
+    }).then((json) => {
+      console.log(json);
+      if (json.ok) {
+        notification.info({ message: "转换成功" });
+        if (typeof onConvert !== "undefined") {
+          onConvert(newType);
+        }
+      }
+    });
+  };
+
   const onClick: MenuProps["onClick"] = (e) => {
     console.log("click ", e);
     switch (e.key) {
@@ -130,12 +154,18 @@ const DiscussionShowWidget = ({
         break;
       case "close":
         close(true);
-
         break;
-
       case "reopen":
         close(false);
-
+        break;
+      case "convert_qa":
+        convert("qa");
+        break;
+      case "convert_help":
+        convert("help");
+        break;
+      case "convert_discussion":
+        convert("discussion");
         break;
       case "delete":
         if (data.id) {
@@ -181,6 +211,29 @@ const DiscussionShowWidget = ({
       icon: <CheckOutlined />,
       disabled: closed === "active",
     },
+    {
+      type: "divider",
+    },
+    {
+      key: "convert",
+      label: intl.formatMessage({
+        id: "buttons.convert",
+      }),
+      icon: <SyncOutlined />,
+      disabled: data.parent ? true : false,
+      children: [
+        { key: "convert_qa", label: "qa", disabled: data.type === "qa" },
+        { key: "convert_help", label: "help", disabled: data.type === "help" },
+        {
+          key: "convert_discussion",
+          label: "discussion",
+          disabled: data.type === "discussion",
+        },
+      ],
+    },
+    {
+      type: "divider",
+    },
     {
       key: "delete",
       label: intl.formatMessage({
@@ -191,76 +244,97 @@ const DiscussionShowWidget = ({
       disabled: data.childrenCount && data.childrenCount > 0 ? true : false,
     },
   ];
-  return (
-    <Card
-      size="small"
-      title={
-        <Space direction="vertical" size={"small"}>
-          {data.title ? (
-            <Text
-              style={{ fontSize: 16 }}
-              strong
-              onClick={(e) => {
-                if (typeof onSelect !== "undefined") {
-                  onSelect(e);
-                }
-              }}
-            >
-              {data.title}
-            </Text>
-          ) : undefined}
-          <Text type="secondary" style={{ fontSize: "80%" }}>
-            <Space>
-              {closed === "close" ? (
-                <Tag style={{ backgroundColor: "#8250df", color: "white" }}>
-                  {"closed"}
-                </Tag>
-              ) : undefined}
-              {data.user.nickName}
-              <TimeShow
-                type="secondary"
-                updatedAt={data.updatedAt}
-                createdAt={data.createdAt}
-              />
-            </Space>
-          </Text>
-        </Space>
-      }
-      extra={
-        <Space>
-          <span
-            style={{
-              display: data.childrenCount === 0 ? "none" : "inline",
-              cursor: "pointer",
-            }}
+
+  const editInfo = () => {
+    return (
+      <Space direction="vertical" size={"small"}>
+        {data.title && !hideTitle ? (
+          <Text
+            style={{ fontSize: 16 }}
+            strong
             onClick={(e) => {
               if (typeof onSelect !== "undefined") {
-                onSelect(e, data);
+                onSelect(e);
               }
             }}
           >
-            {data.childrenCount ? (
-              <>
-                <MessageOutlined /> {data.childrenCount}
-              </>
+            {data.title}
+          </Text>
+        ) : undefined}
+        <Text type="secondary" style={{ fontSize: "80%" }}>
+          <Space>
+            {!data.parent && closed === "close" ? (
+              <Tag style={{ backgroundColor: "#8250df", color: "white" }}>
+                {"closed"}
+              </Tag>
             ) : undefined}
-          </span>
-          <Dropdown
-            menu={{ items, onClick }}
-            placement="bottomRight"
-            trigger={["click"]}
-          >
-            <Button
-              shape="circle"
-              size="small"
-              icon={<MoreOutlined />}
-            ></Button>
-          </Dropdown>
-        </Space>
-      }
+            {data.user.nickName}
+            <TimeShow
+              type="secondary"
+              updatedAt={data.updatedAt}
+              createdAt={data.createdAt}
+            />
+          </Space>
+        </Text>
+      </Space>
+    );
+  };
+
+  const editMenu = () => {
+    return (
+      <Space>
+        <span
+          style={{
+            display: data.childrenCount === 0 ? "none" : "inline",
+            cursor: "pointer",
+          }}
+          onClick={(e) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect(e, data);
+            }
+          }}
+        >
+          {data.childrenCount ? (
+            <>
+              <MessageOutlined /> {data.childrenCount}
+            </>
+          ) : undefined}
+        </span>
+        <Dropdown
+          menu={{ items, onClick }}
+          placement="bottomRight"
+          trigger={["click"]}
+        >
+          <Button shape="circle" size="small" icon={<MoreOutlined />}></Button>
+        </Dropdown>
+      </Space>
+    );
+  };
+  return (
+    <Card
+      size="small"
+      title={data.type === "qa" && data.parent ? undefined : editInfo()}
+      extra={data.type === "qa" && data.parent ? undefined : editMenu()}
       style={{ width: "100%" }}
     >
-      {data.html ? <MdView html={data.html} /> : <Marked text={data.content} />}
+      <div>
+        {data.html ? (
+          <MdView html={data.html} />
+        ) : (
+          <Marked text={data.content} />
+        )}
+      </div>
+      {data.type === "qa" && data.parent ? (
+        <div style={{ display: "flex", justifyContent: "space-between" }}>
+          <div></div>
+          <div>
+            {editInfo()}
+            {editMenu()}
+          </div>
+        </div>
+      ) : (
+        <></>
+      )}
     </Card>
   );
 };

+ 13 - 1
dashboard/src/components/discussion/DiscussionTopic.tsx

@@ -4,24 +4,29 @@ import DiscussionTopicInfo from "./DiscussionTopicInfo";
 import DiscussionTopicChildren from "./DiscussionTopicChildren";
 import { IComment } from "./DiscussionItem";
 import { TResType } from "./DiscussionListCard";
+import { TDiscussionType } from "./Discussion";
 
 interface IWidget {
   resType?: TResType;
   topicId?: string;
   topic?: IComment;
   focus?: string;
+  hideTitle?: boolean;
   onItemCountChange?: Function;
   onTopicReady?: Function;
   onTopicDelete?: Function;
+  onConvert?: Function;
 }
 const DiscussionTopicWidget = ({
   resType,
   topicId,
   topic,
   focus,
+  hideTitle = false,
   onTopicReady,
   onItemCountChange,
   onTopicDelete,
+  onConvert,
 }: IWidget) => {
   const [count, setCount] = useState<number>();
   const [currResId, setCurrResId] = useState<string>();
@@ -30,16 +35,18 @@ const DiscussionTopicWidget = ({
   useEffect(() => {
     setCurrTopic(topic);
   }, [topic]);
+
   return (
     <>
       <DiscussionTopicInfo
         topicId={currTopicId}
         topic={currTopic}
+        hideTitle={hideTitle}
         childrenCount={count}
         onReady={(value: IComment) => {
           setCurrResId(value.resId);
           setCurrTopic(value);
-          console.log("onReady", value);
+          console.log("discussion onReady", value);
           if (typeof onTopicReady !== "undefined") {
             onTopicReady(value);
           }
@@ -49,6 +56,11 @@ const DiscussionTopicWidget = ({
             onTopicDelete();
           }
         }}
+        onConvert={(value: TDiscussionType) => {
+          if (typeof onConvert !== "undefined") {
+            onConvert(value);
+          }
+        }}
       />
       <DiscussionTopicChildren
         topic={currTopic}

+ 6 - 6
dashboard/src/components/discussion/DiscussionTopicChildren.tsx

@@ -45,9 +45,6 @@ const DiscussionTopicChildrenWidget = ({
   const [loading, setLoading] = useState(false);
   const [history, setHistory] = useState<ISentHistoryData[]>([]);
   const [items, setItems] = useState<IItem[]>();
-  const [currTopic, setCurrTopic] = useState(topic);
-
-  useEffect(() => setCurrTopic(topic), [topic]);
 
   useEffect(() => {
     if (loading === false) {
@@ -70,7 +67,7 @@ const DiscussionTopicChildrenWidget = ({
       };
     });
     const topicTime = new Date(
-      currTopic?.createdAt ? currTopic?.createdAt : ""
+      topic?.createdAt ? topic?.createdAt : ""
     ).getTime();
     let firstHis = history.findIndex(
       (value) =>
@@ -129,7 +126,7 @@ const DiscussionTopicChildrenWidget = ({
       });
     }
     setItems(newMixItems);
-  }, [data, history, currTopic?.createdAt]);
+  }, [data, history, topic?.createdAt]);
 
   useEffect(() => {
     if (resType === "sentence" && resId) {
@@ -159,6 +156,7 @@ const DiscussionTopicChildrenWidget = ({
               id: item.id,
               resId: item.res_id,
               resType: item.res_type,
+              type: item.type,
               user: item.editor,
               parent: item.parent,
               title: item.title,
@@ -188,6 +186,7 @@ const DiscussionTopicChildrenWidget = ({
       ) : (
         <List
           pagination={false}
+          size="small"
           itemLayout="horizontal"
           dataSource={items}
           renderItem={(item) => {
@@ -229,7 +228,8 @@ const DiscussionTopicChildrenWidget = ({
         resType={resType}
         contentType="markdown"
         parent={topicId}
-        topic={currTopic}
+        topicId={topicId}
+        topic={topic}
         onCreated={(value: IComment) => {
           const newData = JSON.parse(JSON.stringify(value));
           setData([...data, newData]);

+ 14 - 2
dashboard/src/components/discussion/DiscussionTopicInfo.tsx

@@ -4,24 +4,29 @@ import { useEffect, useState } from "react";
 import { get } from "../../request";
 import { ICommentResponse } from "../api/Comment";
 import DiscussionItem, { IComment } from "./DiscussionItem";
+import { TDiscussionType } from "./Discussion";
 
 interface IWidget {
   topicId?: string;
   topic?: IComment;
   childrenCount?: number;
+  hideTitle?: boolean;
   onDelete?: Function;
   onReply?: Function;
   onClose?: Function;
   onReady?: Function;
+  onConvert?: Function;
 }
 const DiscussionTopicInfoWidget = ({
   topicId,
   topic,
   childrenCount,
+  hideTitle = false,
   onReady,
   onDelete,
   onReply,
   onClose,
+  onConvert,
 }: IWidget) => {
   const [data, setData] = useState<IComment | undefined>(topic);
   useEffect(() => {
@@ -41,7 +46,7 @@ const DiscussionTopicInfoWidget = ({
       return;
     }
     const url = `/v2/discussion/${topicId}`;
-    console.log("url", url);
+    console.log("discussion url", url);
     get<ICommentResponse>(url)
       .then((json) => {
         if (json.ok) {
@@ -51,6 +56,7 @@ const DiscussionTopicInfoWidget = ({
             id: item.id,
             resId: item.res_id,
             resType: item.res_type,
+            type: item.type,
             parent: item.parent,
             user: item.editor,
             title: item.title,
@@ -63,7 +69,7 @@ const DiscussionTopicInfoWidget = ({
           };
           setData(discussion);
           if (typeof onReady !== "undefined") {
-            console.log("on ready");
+            console.log("discussion on ready");
             onReady(discussion);
           }
         } else {
@@ -80,6 +86,7 @@ const DiscussionTopicInfoWidget = ({
       {data ? (
         <DiscussionItem
           data={data}
+          hideTitle={hideTitle}
           onDelete={() => {
             if (typeof onDelete !== "undefined") {
               onDelete(data.id);
@@ -95,6 +102,11 @@ const DiscussionTopicInfoWidget = ({
               onClose(data);
             }
           }}
+          onConvert={(value: TDiscussionType) => {
+            if (typeof onConvert !== "undefined") {
+              onConvert(value);
+            }
+          }}
         />
       ) : (
         <></>

+ 116 - 0
dashboard/src/components/discussion/InteractiveArea.tsx

@@ -0,0 +1,116 @@
+import { Tabs } from "antd";
+import { TResType } from "./DiscussionListCard";
+import Discussion from "./Discussion";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import QaList from "./QaList";
+
+interface IInteractive {
+  ok: boolean;
+  data: ITypeData;
+  message: string;
+}
+
+interface ITypeData {
+  qa: IPower;
+  help: IPower;
+  discussion: IPower;
+}
+
+interface IPower {
+  can_create: boolean;
+  can_reply: boolean;
+  count: number;
+}
+
+interface IWidget {
+  resId?: string;
+  resType?: TResType;
+}
+const InteractiveAreaWidget = ({ resId, resType }: IWidget) => {
+  const [showQa, setShowQa] = useState(false);
+  const [qaCanEdit, setQaCanEdit] = useState(false);
+  const [showHelp, setShowHelp] = useState(false);
+  const [showDiscussion, setShowDiscussion] = useState(false);
+
+  useEffect(() => {
+    get<IInteractive>(`/v2/interactive/${resId}?res_type=${resType}`).then(
+      (json) => {
+        if (json.ok) {
+          console.debug("interactive", json);
+          if (json.data.qa.can_create || json.data.qa.can_reply) {
+            setShowQa(true);
+            setQaCanEdit(true);
+          } else if (json.data.qa.count > 0) {
+            setShowQa(true);
+          } else {
+            setShowQa(false);
+          }
+
+          if (json.data.help.can_create) {
+            setShowHelp(true);
+          } else if (json.data.help.can_reply) {
+            if (json.data.help.count > 0) {
+              setShowHelp(true);
+            }
+          } else {
+            setShowHelp(false);
+          }
+
+          if (
+            json.data.discussion.can_create ||
+            json.data.discussion.can_reply ||
+            json.data.discussion.count > 0
+          ) {
+            setShowDiscussion(true);
+          } else {
+            setShowDiscussion(false);
+          }
+        }
+      }
+    );
+  }, [resId, resType]);
+
+  return showQa || showHelp || showDiscussion ? (
+    <Tabs
+      size="small"
+      items={[
+        {
+          label: `问答`,
+          key: "qa",
+          children: qaCanEdit ? (
+            <Discussion resId={resId} resType={resType} type="qa" />
+          ) : (
+            <QaList resId={resId} resType={resType} />
+          ),
+        },
+        {
+          label: `求助`,
+          key: "help",
+          children: <Discussion resId={resId} resType={resType} type="help" />,
+        },
+        {
+          label: `讨论`,
+          key: "discussion",
+          children: (
+            <Discussion resId={resId} resType={resType} type="discussion" />
+          ),
+        },
+      ].filter((value) => {
+        if (value.key === "qa") {
+          return showQa;
+        } else if (value.key === "help") {
+          return showHelp;
+        } else if (value.key === "discussion") {
+          return showDiscussion;
+        } else {
+          return false;
+        }
+      })}
+    />
+  ) : (
+    <></>
+  );
+};
+
+export default InteractiveAreaWidget;

+ 75 - 0
dashboard/src/components/discussion/QaList.tsx

@@ -0,0 +1,75 @@
+import { useEffect, useState } from "react";
+import { TResType } from "./DiscussionListCard";
+import { get } from "../../request";
+import { ICommentListResponse } from "../api/Comment";
+import DiscussionItem, { IComment } from "./DiscussionItem";
+
+interface IWidget {
+  resId?: string;
+  resType?: TResType;
+}
+const QaListWidget = ({ resId, resType }: IWidget) => {
+  const [data, setData] = useState<IComment[]>();
+
+  useEffect(() => {
+    if (!resType || !resType) {
+      return;
+    }
+    let url: string = `/v2/discussion?res_type=${resType}&view=res_id&id=${resId}`;
+    url += "&dir=asc&type=qa&status=close";
+    console.log("url", url);
+    get<ICommentListResponse>(url).then((json) => {
+      if (json.ok) {
+        console.debug("discussion fetch qa", json);
+        const items: IComment[] = json.data.rows.map((item, id) => {
+          return {
+            id: item.id,
+            resId: item.res_id,
+            resType: item.res_type,
+            type: item.type,
+            user: item.editor,
+            title: item.title,
+            parent: item.parent,
+            tplId: item.tpl_id,
+            content: item.content,
+            summary: item.summary,
+            status: item.status,
+            childrenCount: item.children_count,
+            createdAt: item.created_at,
+            updatedAt: item.updated_at,
+          };
+        });
+
+        setData(items);
+      }
+    });
+  }, []);
+  return (
+    <>
+      {data
+        ?.filter((value) => !value.parent)
+        .map((question, index) => {
+          return (
+            <div key={`div_${index}`}>
+              <DiscussionItem key={index} data={question} />
+              <div
+                style={{
+                  marginLeft: 16,
+                  borderLeft: "2px solid gray",
+                  padding: 4,
+                }}
+              >
+                {data
+                  ?.filter((value) => value.parent === question.id)
+                  .map((item, id) => {
+                    return <DiscussionItem key={id} data={item} />;
+                  })}
+              </div>
+            </div>
+          );
+        })}
+    </>
+  );
+};
+
+export default QaListWidget;

+ 32 - 14
dashboard/src/components/notification/NotificationIcon.tsx

@@ -11,12 +11,19 @@ import { IUser } from "../auth/User";
 const NotificationIconWidget = () => {
   const [count, setCount] = useState<number>();
   const currUser = useAppSelector(currentUser);
+  const [mute, setMute] = useState(false);
 
   const queryNotification = (user?: IUser) => {
     if (!user) {
       console.debug("未登录 不查询 notification");
       return;
     }
+    const isMute = localStorage.getItem("notification/mute");
+    if (isMute && isMute === "true") {
+      setMute(true);
+    } else {
+      setMute(false);
+    }
     const now = new Date();
     const notificationUpdatedAt = localStorage.getItem(
       "notification/updatedAt"
@@ -36,6 +43,7 @@ const NotificationIconWidget = () => {
     console.info("notification url", url);
     get<INotificationListResponse>(url).then((json) => {
       if (json.ok) {
+        console.debug("notification fetch ok ", json.data.unread);
         localStorage.setItem(
           "notification/updatedAt",
           now.getTime().toString()
@@ -47,26 +55,36 @@ const NotificationIconWidget = () => {
           const lastTime = localStorage.getItem("notification/new");
           if (lastTime === null || lastTime !== newMessageTime) {
             localStorage.setItem("notification/new", newMessageTime);
-            if (window.Notification && Notification.permission !== "denied") {
-              Notification.requestPermission(function (status) {
-                const notification = new Notification(
-                  json.data.rows[0].res_type,
-                  {
-                    body: json.data.rows[0].content,
+
+            const title = json.data.rows[0].res_type;
+            const content = json.data.rows[0].content;
+            localStorage.setItem(
+              "notification/message",
+              JSON.stringify({ title: title, content: content })
+            );
+            //发送通知
+            console.debug("notification isMute", isMute, mute);
+            if (!isMute || isMute !== "true") {
+              if (window.Notification && Notification.permission !== "denied") {
+                Notification.requestPermission(function (status) {
+                  const notification = new Notification(title, {
+                    body: content,
                     icon:
                       process.env.REACT_APP_API_HOST +
                       "/assets/images/wikipali_logo.png",
                     tag: json.data.rows[0].id,
-                  }
-                );
-                notification.onclick = (event) => {
-                  event.preventDefault(); // 阻止浏览器聚焦于 Notification 的标签页
-                  window.open(json.data.rows[0].url, "_blank");
-                };
-              });
+                  });
+                  notification.onclick = (event) => {
+                    event.preventDefault(); // 阻止浏览器聚焦于 Notification 的标签页
+                    window.open(json.data.rows[0].url, "_blank");
+                  };
+                });
+              }
             }
           }
         }
+      } else {
+        console.error(json.message);
       }
     });
   };
@@ -93,7 +111,7 @@ const NotificationIconWidget = () => {
           }
           trigger="click"
         >
-          <Badge count={count} size="small">
+          <Badge count={count} size="small" dot={mute}>
             <span style={{ color: "white", cursor: "pointer" }}>
               <NotificationIcon />
             </span>

+ 22 - 2
dashboard/src/components/notification/NotificationList.tsx

@@ -9,7 +9,7 @@ import {
 } from "../api/notification";
 import { IUser } from "../auth/User";
 import TimeShow from "../general/TimeShow";
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 import Marked from "../general/Marked";
 import { IChannel } from "../channel/Channel";
 
@@ -39,7 +39,16 @@ interface IWidget {
 const NotificationListWidget = ({ onChange }: IWidget) => {
   const ref = useRef<ActionType>();
   const [activeKey, setActiveKey] = useState<React.Key | undefined>("inbox");
+  const [mute, setMute] = useState(false);
 
+  useEffect(() => {
+    const mute = localStorage.getItem("notification/mute");
+    if (mute && mute === "true") {
+      setMute(true);
+    } else {
+      setMute(false);
+    }
+  }, []);
   const putStatus = (id: string, status: string) => {
     const url = `/v2/notification/${id}`;
     put<INotificationRequest, INotificationPutResponse>(url, {
@@ -72,7 +81,18 @@ const NotificationListWidget = ({ onChange }: IWidget) => {
         return [
           <>
             {"免打扰"}
-            <Switch size="small" />
+            <Switch
+              size="small"
+              checked={mute}
+              onChange={(checked: boolean) => {
+                setMute(checked);
+                if (checked) {
+                  localStorage.setItem("notification/mute", "true");
+                } else {
+                  localStorage.setItem("notification/mute", "false");
+                }
+              }}
+            />
           </>,
           <Button
             key="4"

+ 2 - 0
dashboard/src/components/template/SentEdit/SentTabCopy.tsx

@@ -3,6 +3,7 @@ import {
   CopyOutlined,
   ShoppingCartOutlined,
   CheckOutlined,
+  DownOutlined,
 } from "@ant-design/icons";
 import { useEffect, useState } from "react";
 import { IWbw } from "../Wbw/WbwWord";
@@ -69,6 +70,7 @@ const SentTabCopyWidget = ({ text, wbwData }: IWidget) => {
     <Dropdown.Button
       size="small"
       type="link"
+      icon={<DownOutlined />}
       menu={{
         items: [
           {

+ 0 - 5
dashboard/src/components/template/SentEdit/SuggestionToolbar.tsx

@@ -150,11 +150,6 @@ const SuggestionToolbarWidget = ({
               setCommentCount(count);
             }}
           />
-
-          {compact ? undefined : <Divider type="vertical" />}
-          {compact ? undefined : (
-            <Text copyable={{ text: data.content ? data.content : "" }}></Text>
-          )}
         </Space>
       )}
     </Paragraph>

+ 15 - 55
dashboard/src/components/template/Wbw/WbwDetailBasic.tsx

@@ -21,10 +21,8 @@ import { inlineDict as _inlineDict } from "../../../reducers/inline-dict";
 import { IApiResponseDictData } from "../../api/Dict";
 import WbwDetailFm from "./WbwDetailFm";
 import WbwDetailParent2 from "./WbwDetailParent2";
-import WbwDetailRelation from "./WbwDetailRelation";
 import WbwDetailFactor from "./WbwDetailFactor";
-import store from "../../../store";
-import { lookup } from "../../../reducers/command";
+import WbwDetailBasicRelation from "./WbwDetailBasicRelation";
 
 const { Panel } = Collapse;
 
@@ -114,10 +112,6 @@ const WbwDetailBasicWidget = ({
     setParentOptions(parentOptions);
   }, [inlineDict, data]);
 
-  const relationCount = data.relation?.value
-    ? JSON.parse(data.relation.value).length
-    : 0;
-
   return (
     <>
       <Form
@@ -265,55 +259,21 @@ const WbwDetailBasicWidget = ({
               }}
             />
           </Panel>
-          <Panel
-            header={
-              <div style={{ display: "flex", justifyContent: "space-between" }}>
-                <Space>
-                  {intl.formatMessage({ id: "buttons.relate" })}
-                  <Badge color="geekblue" count={relationCount} />
-                </Space>
-                <Tooltip
-                  title={intl.formatMessage({
-                    id: "columns.library.palihandbook.title",
-                  })}
-                >
-                  <Button
-                    disabled
-                    type="link"
-                    onClick={() => {
-                      if (data.case && data.case?.value) {
-                        const caseParts = data.case?.value
-                          .split("$")
-                          .map((item) => item.replaceAll(".", ""));
-                        const endCase = caseParts[caseParts.length - 1];
-                        store.dispatch(
-                          lookup(`type:term word:${endCase}.relations`)
-                        );
-                      }
-                    }}
-                    icon={<QuestionCircleOutlined />}
-                  />
-                </Tooltip>
-              </div>
-            }
-            key="relation"
-            style={{ display: showRelation ? "block" : "none" }}
-          >
-            <WbwDetailRelation
-              data={data}
-              onChange={(e: IWbwField) => {
-                if (typeof onChange !== "undefined") {
-                  onChange(e);
-                }
-              }}
-              onAdd={() => {
-                if (typeof onRelationAdd !== "undefined") {
-                  onRelationAdd();
-                }
-              }}
-            />
-          </Panel>
         </Collapse>
+        <WbwDetailBasicRelation
+          data={data}
+          showRelation={showRelation}
+          onChange={(e: IWbwField) => {
+            if (typeof onChange !== "undefined") {
+              onChange(e);
+            }
+          }}
+          onRelationAdd={() => {
+            if (typeof onRelationAdd !== "undefined") {
+              onRelationAdd();
+            }
+          }}
+        />
       </Form>
     </>
   );

+ 80 - 0
dashboard/src/components/template/Wbw/WbwDetailBasicRelation.tsx

@@ -0,0 +1,80 @@
+import { Badge, Button, Collapse, Space, Tooltip } from "antd";
+import { QuestionCircleOutlined } from "@ant-design/icons";
+import WbwDetailRelation from "./WbwDetailRelation";
+import store from "../../../store";
+import { grammar } from "../../../reducers/command";
+import { IWbw, IWbwField } from "./WbwWord";
+import { useIntl } from "react-intl";
+import { useState } from "react";
+
+interface IWidget {
+  data: IWbw;
+  showRelation?: boolean;
+  onChange?: Function;
+  onRelationAdd?: Function;
+}
+const WbwDetailBasicRelationWidget = ({
+  data,
+  showRelation,
+  onChange,
+  onRelationAdd,
+}: IWidget) => {
+  const intl = useIntl();
+  const [fromList, setFromList] = useState<string[]>();
+
+  const relationCount = data.relation?.value
+    ? JSON.parse(data.relation.value).length
+    : 0;
+  return (
+    <Collapse bordered={false} collapsible={"icon"}>
+      <Collapse.Panel
+        header={
+          <div style={{ display: "flex", justifyContent: "space-between" }}>
+            <Space>
+              {intl.formatMessage({ id: "buttons.relate" })}
+              <Badge color="geekblue" count={relationCount} />
+            </Space>
+            <Tooltip
+              title={intl.formatMessage({
+                id: "columns.library.palihandbook.title",
+              })}
+            >
+              <Button
+                type="link"
+                onClick={() => {
+                  if (fromList) {
+                    const endCase = fromList
+                      .map((item) => item + ".relations")
+                      .join(",");
+                    console.debug("from", fromList, endCase);
+                    store.dispatch(grammar(endCase));
+                  }
+                }}
+                icon={<QuestionCircleOutlined />}
+              />
+            </Tooltip>
+          </div>
+        }
+        key="relation"
+        style={{ display: showRelation ? "block" : "none" }}
+      >
+        <WbwDetailRelation
+          data={data}
+          onChange={(e: IWbwField) => {
+            if (typeof onChange !== "undefined") {
+              onChange(e);
+            }
+          }}
+          onAdd={() => {
+            if (typeof onRelationAdd !== "undefined") {
+              onRelationAdd();
+            }
+          }}
+          onFromList={(value: string[]) => setFromList(value)}
+        />
+      </Collapse.Panel>
+    </Collapse>
+  );
+};
+
+export default WbwDetailBasicRelationWidget;

+ 35 - 4
dashboard/src/components/template/Wbw/WbwDetailRelation.tsx

@@ -32,8 +32,14 @@ interface IWidget {
   data: IWbw;
   onChange?: Function;
   onAdd?: Function;
+  onFromList?: Function;
 }
-const WbwDetailRelationWidget = ({ data, onChange, onAdd }: IWidget) => {
+const WbwDetailRelationWidget = ({
+  data,
+  onChange,
+  onAdd,
+  onFromList,
+}: IWidget) => {
   const getSourId = () => `${data.book}-${data.para}-` + data.sn.join("-");
 
   const intl = useIntl();
@@ -41,6 +47,7 @@ const WbwDetailRelationWidget = ({ data, onChange, onAdd }: IWidget) => {
   const [currRelation, setCurrRelation] = useState<IRelation[]>();
   const [relationOptions, setRelationOptions] = useState<IRelation[]>();
   const [newRelationName, setNewRelationName] = useState<string>();
+  const [fromList, setFromList] = useState<string[]>();
 
   const [options, setOptions] = useState<IOptions[]>();
   const terms = useAppSelector(getTerm);
@@ -56,6 +63,12 @@ const WbwDetailRelationWidget = ({ data, onChange, onAdd }: IWidget) => {
     relation: undefined,
     is_new: true,
   };
+
+  useEffect(() => {
+    if (typeof onFromList !== "undefined") {
+      onFromList(fromList);
+    }
+  }, [fromList]);
   useEffect(() => {
     if (
       addParam?.command === "apply" &&
@@ -100,17 +113,18 @@ const WbwDetailRelationWidget = ({ data, onChange, onAdd }: IWidget) => {
       .split("$");
     if (data.grammar2?.value) {
       if (grammar) {
-        grammar = [data.grammar2?.value, ...grammar];
+        grammar = [data.grammar2?.value.replaceAll(".", ""), ...grammar];
       } else {
-        grammar = [data.grammar2?.value];
+        grammar = [data.grammar2?.value.replaceAll(".", "")];
       }
     }
-    console.log("grammar", grammar);
+    console.log("relation match grammar", grammar);
     if (typeof grammar === "undefined") {
       return;
     }
 
     //找出符合条件的relation
+
     const filteredRelation = relations?.filter((value) => {
       let caseMatch = true;
       let spellMatch = true;
@@ -140,9 +154,21 @@ const WbwDetailRelationWidget = ({ data, onChange, onAdd }: IWidget) => {
     setCurrRelation(filteredRelation);
     setRelationOptions(filteredRelation);
     let relationName = new Map<string, string>();
+    let relationFrom: string[] = [];
     filteredRelation?.forEach((value) => {
       relationName.set(value.name, value.name);
+      let from: string[] = [];
+      if (value.from?.spell) {
+        from.push(value.from.spell);
+      }
+      if (value.from?.case) {
+        from = [...from, ...value.from.case];
+      }
+      if (!relationFrom.includes(from.join("."))) {
+        relationFrom.push(from.join("."));
+      }
     });
+
     const mRelation = Array.from(relationName.keys()).map((item) => {
       const localName = terms?.find((term) => term.word === item)?.meaning;
       return {
@@ -156,6 +182,11 @@ const WbwDetailRelationWidget = ({ data, onChange, onAdd }: IWidget) => {
       };
     });
     setOptions(mRelation);
+
+    if (typeof onFromList !== "undefined") {
+      console.debug("relationFrom", relationFrom);
+      onFromList(relationFrom);
+    }
   }, [
     data.case?.value,
     data.grammar2?.value,

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

@@ -5,6 +5,7 @@ import {
   InfoCircleOutlined,
   ApartmentOutlined,
   EditOutlined,
+  QuestionCircleOutlined,
 } from "@ant-design/icons";
 
 import "./wbw.css";
@@ -15,12 +16,12 @@ import WbwVideoButton from "./WbwVideoButton";
 import CommentBox from "../../discussion/DiscussionDrawer";
 import PaliText from "./PaliText";
 import store from "../../../store";
-import { lookup } from "../../../reducers/command";
+import { grammarId, lookup } from "../../../reducers/command";
 import { useAppSelector } from "../../../hooks";
 import { add, relationAddParam } from "../../../reducers/relation-add";
 import { ArticleMode } from "../../article/Article";
 import { anchor, showWbw } from "../../../reducers/wbw";
-import { CommentOutlinedIcon } from "../../../assets/icon";
+import { CommentOutlinedIcon, HandBookIcon } from "../../../assets/icon";
 import { ParaLinkCtl } from "../ParaLink";
 
 const { Paragraph } = Typography;
@@ -304,6 +305,19 @@ const WbwPaliWidget = ({ data, channelId, mode, display, onSave }: IWidget) => {
     return (
       <div className="pali_shell" ref={divShell}>
         <span className="pali_shell_spell">
+          {data.grammarId ? (
+            <span
+              onClick={() => {
+                store.dispatch(grammarId(data.grammarId));
+              }}
+            >
+              <QuestionCircleOutlined
+                style={{ color: "blue", cursor: "pointer" }}
+              />
+            </span>
+          ) : (
+            <></>
+          )}
           {mode === "edit" ? paliWord : ""}
           <Popover
             content={wbwDetail}

+ 1 - 0
dashboard/src/components/template/Wbw/WbwWord.tsx

@@ -88,6 +88,7 @@ export interface IWbw {
   confidence: number;
   attachments?: IWbwAttachment[];
   hasComment?: boolean;
+  grammarId?: string;
 }
 export interface IWbwFields {
   real?: boolean;

+ 22 - 3
dashboard/src/components/template/WbwSent.tsx

@@ -20,6 +20,7 @@ import { add } from "../../reducers/sent-word";
 import store from "../../store";
 import { settingInfo } from "../../reducers/setting";
 import { GetUserSetting } from "../auth/setting/default";
+import { getGrammar } from "../../reducers/term-vocabulary";
 
 interface IMagicDictRequest {
   book: number;
@@ -113,6 +114,9 @@ export const WbwSentCtl = ({
   const [magic, setMagic] = useState<string>();
   const [loading, setLoading] = useState(false);
   const settings = useAppSelector(settingInfo);
+  const sysGrammar = useAppSelector(getGrammar)?.filter(
+    (value) => value.tag === ":collocation:"
+  );
 
   useEffect(() => {
     setMagic(magicDict);
@@ -571,9 +575,24 @@ export const WbwSentCtl = ({
         />
       </Dropdown>
       {layoutDirection === "h" ? (
-        wordData.map((item, id) => {
-          return wbwRender(item, id);
-        })
+        wordData
+          .map((item, index) => {
+            let newItem = item;
+            const spell = item.real.value;
+            if (spell) {
+              const matched = sysGrammar?.find((value) =>
+                value.word.split("...").includes(spell)
+              );
+              if (matched) {
+                console.debug("wbw sent grammar matched", matched);
+                newItem.grammarId = matched.guid;
+              }
+            }
+            return newItem;
+          })
+          .map((item, id) => {
+            return wbwRender(item, id);
+          })
       ) : (
         <Tree
           selectable={true}

+ 204 - 0
dashboard/src/components/term/GrammarBook.tsx

@@ -0,0 +1,204 @@
+import { Button, Dropdown, Input, List } from "antd";
+import { useEffect, useState } from "react";
+import {
+  ArrowLeftOutlined,
+  FieldTimeOutlined,
+  MoreOutlined,
+} from "@ant-design/icons";
+
+import { ITerm, getGrammar } from "../../reducers/term-vocabulary";
+import { useAppSelector } from "../../hooks";
+import TermSearch from "./TermSearch";
+import {
+  grammar,
+  grammarId,
+  grammarWord,
+  grammarWordId,
+} from "../../reducers/command";
+import store from "../../store";
+import GrammarRecent, {
+  IGrammarRecent,
+  popRecent,
+  pushRecent,
+} from "./GrammarRecent";
+
+const { Search } = Input;
+
+interface IGrammarList {
+  term: ITerm;
+  weight: number;
+}
+const GrammarBookWidget = () => {
+  const [result, setResult] = useState<IGrammarList[]>();
+  const [termId, setTermId] = useState<string>();
+  const [termSearch, setTermSearch] = useState<string>();
+  const [showRecent, setShowRecent] = useState(false);
+  const sysGrammar = useAppSelector(getGrammar);
+  const searchWord = useAppSelector(grammarWord);
+  const searchWordId = useAppSelector(grammarWordId);
+
+  useEffect(() => {
+    console.debug("grammar book", searchWord);
+    if (searchWord && searchWord.length > 0) {
+      setTermId(undefined);
+      setTermSearch(searchWord);
+      pushRecent({
+        title: searchWord,
+        description: searchWord,
+        word: searchWord,
+      });
+
+      store.dispatch(grammar(""));
+    }
+  }, [searchWord]);
+
+  useEffect(() => {
+    console.debug("grammar book", searchWordId);
+    if (searchWordId && searchWordId.length > 0) {
+      setTermId(searchWordId);
+      setTermSearch(undefined);
+      pushRecent({
+        title: searchWordId,
+        description: searchWordId,
+        wordId: searchWordId,
+      });
+
+      store.dispatch(grammarId(""));
+    }
+  }, [searchWordId]);
+  return (
+    <div>
+      <div style={{ display: "flex" }}>
+        <Button
+          icon={<ArrowLeftOutlined />}
+          type="text"
+          onClick={() => {
+            const top = popRecent();
+            if (top) {
+              setTermId(top.wordId);
+              setTermSearch(top.word);
+            }
+          }}
+        />
+        <Search
+          placeholder="input search text"
+          onSearch={(value: string) => {}}
+          onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+            console.debug("on change", event.target.value);
+            setTermId(undefined);
+            setTermSearch(undefined);
+            const keyWord = event.target.value;
+            if (keyWord.trim().length === 0) {
+              setShowRecent(true);
+            } else {
+              setShowRecent(false);
+            }
+            /**
+             * 权重算法
+             * 约靠近头,分数约高
+             * 剩余尾巴约短,分数越高
+             */
+            const search = sysGrammar
+              ?.map((item) => {
+                let weight = 0;
+                const wordBegin = item.word
+                  .toLocaleLowerCase()
+                  .indexOf(keyWord);
+                if (wordBegin >= 0) {
+                  weight += (1 / (wordBegin + 1)) * 1000;
+                  const wordRemain =
+                    item.word.length - keyWord.length - wordBegin;
+                  weight += (1 / (wordRemain + 1)) * 100;
+                }
+                const meaningBegin = item.meaning
+                  .toLocaleLowerCase()
+                  .indexOf(keyWord);
+                if (meaningBegin >= 0) {
+                  weight += (1 / (meaningBegin + 1)) * 1000;
+                  const meaningRemain =
+                    item.meaning.length - keyWord.length - wordBegin;
+                  weight += (1 / (meaningRemain + 1)) * 100;
+                }
+                return { term: item, weight: weight };
+              })
+              .filter((value) => value.weight > 0)
+              .sort((a, b) => b.weight - a.weight);
+
+            setResult(search);
+          }}
+          style={{ width: "100%" }}
+        />
+        <Dropdown
+          trigger={["click"]}
+          menu={{
+            items: [
+              {
+                key: "recent",
+                label: "最近查询",
+                icon: <FieldTimeOutlined />,
+              },
+            ],
+            onClick: (e) => {
+              switch (e.key) {
+                case "recent":
+                  setShowRecent(true);
+                  break;
+              }
+            },
+          }}
+        >
+          <Button type="text" icon={<MoreOutlined />} />
+        </Dropdown>
+      </div>
+      <div>
+        {showRecent ? (
+          <GrammarRecent
+            onClick={(value: IGrammarRecent) => {
+              console.debug("grammar book recent click", value);
+              setTermId(value.wordId);
+              setTermSearch(value.word);
+              setShowRecent(false);
+            }}
+          />
+        ) : termId || termSearch ? (
+          <TermSearch
+            wordId={termId}
+            word={termSearch}
+            onIdChange={(value: string) => {
+              setTermId(value);
+              setTermSearch(undefined);
+              pushRecent({ title: value, description: value, wordId: value });
+            }}
+          />
+        ) : (
+          <List
+            size="small"
+            dataSource={result}
+            renderItem={(item) => (
+              <List.Item
+                key={item.term.guid}
+                style={{ cursor: "pointer" }}
+                onClick={() => {
+                  setTermId(item.term.guid);
+                  setTermSearch(undefined);
+                  pushRecent({
+                    title: item.term.word,
+                    description: item.term.meaning,
+                    wordId: item.term.guid,
+                  });
+                }}
+              >
+                <List.Item.Meta
+                  title={item.term.word}
+                  description={item.term.meaning}
+                />
+              </List.Item>
+            )}
+          />
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default GrammarBookWidget;

+ 65 - 0
dashboard/src/components/term/GrammarRecent.tsx

@@ -0,0 +1,65 @@
+import { List } from "antd";
+
+const maxRecent = 10;
+const storeKey = "grammar-handbook/recent";
+export interface IGrammarRecent {
+  title: string;
+  description?: string;
+  word?: string;
+  wordId?: string;
+}
+
+export const popRecent = (): IGrammarRecent | null => {
+  const old = localStorage.getItem(storeKey);
+  if (old) {
+    const recentList = JSON.parse(old);
+    const top = recentList.shift();
+    localStorage.setItem(storeKey, JSON.stringify(recentList));
+    return top;
+  } else {
+    return null;
+  }
+};
+
+export const pushRecent = (value: IGrammarRecent) => {
+  const old = localStorage.getItem(storeKey);
+  if (old) {
+    const newRecent = [value, ...JSON.parse(old)].slice(0, maxRecent - 1);
+    localStorage.setItem(storeKey, JSON.stringify(newRecent));
+  } else {
+    localStorage.setItem(storeKey, JSON.stringify([value]));
+  }
+};
+
+interface IWidget {
+  onClick?: Function;
+}
+const GrammarRecentWidget = ({ onClick }: IWidget) => {
+  const data = localStorage.getItem(storeKey);
+  let items: IGrammarRecent[] = [];
+  if (data) {
+    items = JSON.parse(data);
+  }
+  return (
+    <List
+      header={"最近搜索"}
+      size="small"
+      dataSource={items}
+      renderItem={(item, index) => (
+        <List.Item
+          key={index}
+          style={{ cursor: "pointer" }}
+          onClick={() => {
+            if (typeof onClick !== "undefined") {
+              onClick(item);
+            }
+          }}
+        >
+          <List.Item.Meta title={item.title} description={item.description} />
+        </List.Item>
+      )}
+    />
+  );
+};
+
+export default GrammarRecentWidget;

+ 5 - 4
dashboard/src/load.ts

@@ -10,7 +10,7 @@ import { get, IErrorResponse } from "./request";
 import { get as getLang } from "./locales";
 
 import store from "./store";
-import { ITerm, update } from "./reducers/term-vocabulary";
+import { grammar, ITerm, update } from "./reducers/term-vocabulary";
 import { push as nissayaEndingPush } from "./reducers/nissaya-ending-vocabulary";
 import { IRelation, IRelationListResponse } from "./pages/admin/relation/list";
 import { pushRelation } from "./reducers/relation";
@@ -97,15 +97,16 @@ const init = () => {
     const json: ISettingItem[] = JSON.parse(setting);
     store.dispatch(refreshSetting(json));
   }
-  //获取术语表
+  //获取语法术语表
   get<ITermResponse>(`/v2/term-vocabulary?view=grammar&lang=` + getLang()).then(
     (json) => {
       if (json.ok) {
-        store.dispatch(update(json.data.rows));
+        console.debug("grammar dispatch", json.data.rows);
+        store.dispatch(grammar(json.data.rows));
       }
     }
   );
-
+  //获取术语表
   get<ITermResponse>(
     `/v2/term-vocabulary?view=community&lang=` + getLang()
   ).then((json) => {

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

@@ -79,6 +79,7 @@ const items = {
   "buttons.got.it": "I got it",
   "buttons.statistic": "statistic",
   "buttons.relate": "relate",
+  "buttons.convert": "convert",
 };
 
 export default items;

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

@@ -79,6 +79,7 @@ const items = {
   "buttons.got.it": "知道了",
   "buttons.statistic": "统计",
   "buttons.relate": "关联",
+  "buttons.convert": "转换",
 };
 
 export default items;

+ 1 - 0
dashboard/src/pages/library/discussion/topic.tsx

@@ -42,6 +42,7 @@ const Widget = () => {
         const topicInfo: IComment = {
           resId: resId,
           resType: resType as TResType,
+          type: "discussion",
           user: value.editor,
           title: value.title,
           tplId: id,

+ 15 - 0
dashboard/src/reducers/command.ts

@@ -17,6 +17,8 @@ interface IState {
   message?: ICommand;
   command?: "term" | "dict";
   lookup?: string;
+  grammar?: string;
+  grammarId?: string;
 }
 
 const initialState: IState = {};
@@ -32,16 +34,29 @@ export const slice = createSlice({
     lookup: (state, action: PayloadAction<string | undefined>) => {
       state.lookup = action.payload;
     },
+    grammar: (state, action: PayloadAction<string | undefined>) => {
+      state.grammar = action.payload;
+    },
+    grammarId: (state, action: PayloadAction<string | undefined>) => {
+      state.grammarId = action.payload;
+    },
   },
 });
 
 export const { command } = slice.actions;
 export const { lookup } = slice.actions;
+export const { grammar } = slice.actions;
+export const { grammarId } = slice.actions;
+
 export const commandParam = (state: RootState): IState => state.command;
 
 export const message = (state: RootState): ICommand | undefined =>
   state.command.message;
 export const lookupWord = (state: RootState): string | undefined =>
   state.command.lookup;
+export const grammarWord = (state: RootState): string | undefined =>
+  state.command.grammar;
+export const grammarWordId = (state: RootState): string | undefined =>
+  state.command.grammarId;
 
 export default slice.reducer;

+ 9 - 2
dashboard/src/reducers/term-vocabulary.ts

@@ -6,12 +6,15 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
 import type { RootState } from "../store";
 
 export interface ITerm {
+  guid?: string;
   word: string;
+  tag?: string;
   meaning: string;
 }
 
 interface IState {
   term?: ITerm[];
+  grammar?: ITerm[];
 }
 
 const initialState: IState = {};
@@ -23,6 +26,9 @@ export const slice = createSlice({
     update: (state, action: PayloadAction<ITerm[]>) => {
       state.term = action.payload;
     },
+    grammar: (state, action: PayloadAction<ITerm[]>) => {
+      state.grammar = action.payload;
+    },
     push: (state, action: PayloadAction<ITerm>) => {
       if (state.term) {
         if (
@@ -38,9 +44,10 @@ export const slice = createSlice({
   },
 });
 
-export const { update, push } = slice.actions;
+export const { update, grammar, push } = slice.actions;
 
 export const getTerm = (state: RootState): ITerm[] | undefined =>
   state.termVocabulary.term;
-
+export const getGrammar = (state: RootState): ITerm[] | undefined =>
+  state.termVocabulary.grammar;
 export default slice.reducer;