Browse Source

Merge pull request #1044 from visuddhinanda/agile

:sparkles: 评论
visuddhinanda 3 years ago
parent
commit
713afb79ad
59 changed files with 1924 additions and 365 deletions
  1. 18 0
      dashboard/src/components/api/Attachments.ts
  2. 6 0
      dashboard/src/components/api/Auth.ts
  3. 38 0
      dashboard/src/components/api/Comment.ts
  4. 7 3
      dashboard/src/components/api/Dict.ts
  5. 1 1
      dashboard/src/components/article/AnthologyCard.tsx
  6. 10 3
      dashboard/src/components/article/Article.tsx
  7. 10 5
      dashboard/src/components/article/ArticleCard.tsx
  8. 0 0
      dashboard/src/components/article/EditableTree.tsx
  9. 2 2
      dashboard/src/components/article/PaliTextToc.tsx
  10. 65 25
      dashboard/src/components/article/TocTree.tsx
  11. 2 2
      dashboard/src/components/auth/setting/SettingArticle.tsx
  12. 17 6
      dashboard/src/components/auth/setting/SettingItem.tsx
  13. 110 153
      dashboard/src/components/auth/setting/default.ts
  14. 4 4
      dashboard/src/components/code/my.ts
  15. 2 2
      dashboard/src/components/code/si.ts
  16. 4 2
      dashboard/src/components/code/tai-tham.ts
  17. 4 2
      dashboard/src/components/code/thai.ts
  18. 88 0
      dashboard/src/components/comment/CommentBox.tsx
  19. 125 0
      dashboard/src/components/comment/CommentCreate.tsx
  20. 96 0
      dashboard/src/components/comment/CommentEdit.tsx
  21. 56 0
      dashboard/src/components/comment/CommentItem.tsx
  22. 56 0
      dashboard/src/components/comment/CommentList.tsx
  23. 113 0
      dashboard/src/components/comment/CommentListCard.tsx
  24. 5 0
      dashboard/src/components/comment/CommentListItem.tsx
  25. 89 0
      dashboard/src/components/comment/CommentShow.tsx
  26. 91 0
      dashboard/src/components/comment/CommentTopic.tsx
  27. 30 0
      dashboard/src/components/comment/CommentTopicHead.tsx
  28. 30 0
      dashboard/src/components/comment/CommentTopicList.tsx
  29. 4 2
      dashboard/src/components/corpus/TocPath.tsx
  30. 81 61
      dashboard/src/components/general/TimeShow.tsx
  31. 45 0
      dashboard/src/components/general/Video.tsx
  32. 45 0
      dashboard/src/components/general/VideoModal.tsx
  33. 55 0
      dashboard/src/components/general/VideoPlayer.tsx
  34. 20 0
      dashboard/src/components/nut/Home.tsx
  35. 38 0
      dashboard/src/components/nut/TreeTest.tsx
  36. 29 0
      dashboard/src/components/template/Exercise.tsx
  37. 7 3
      dashboard/src/components/template/MdTpl.tsx
  38. 2 0
      dashboard/src/components/template/SentEdit/SentCellEditable.tsx
  39. 1 1
      dashboard/src/components/template/SentRead.tsx
  40. 49 0
      dashboard/src/components/template/Wbw/PaliText.tsx
  41. 49 12
      dashboard/src/components/template/Wbw/WbwCase.tsx
  42. 15 0
      dashboard/src/components/template/Wbw/WbwDetail.tsx
  43. 12 2
      dashboard/src/components/template/Wbw/WbwDetailAdvance.tsx
  44. 36 19
      dashboard/src/components/template/Wbw/WbwDetailUpload.tsx
  45. 121 21
      dashboard/src/components/template/Wbw/WbwMeaningSelect.tsx
  46. 104 19
      dashboard/src/components/template/Wbw/WbwPali.tsx
  47. 28 0
      dashboard/src/components/template/Wbw/WbwVideoButton.tsx
  48. 7 2
      dashboard/src/components/template/Wbw/WbwWord.tsx
  49. 2 2
      dashboard/src/components/template/utilities.ts
  50. 10 0
      dashboard/src/locales/zh-Hans/dict/index.ts
  51. 1 0
      dashboard/src/locales/zh-Hans/forms.ts
  52. 4 4
      dashboard/src/locales/zh-Hans/setting/index.ts
  53. 2 1
      dashboard/src/pages/library/article/show.tsx
  54. 2 2
      dashboard/src/pages/studio/anthology/edit.tsx
  55. 4 4
      dashboard/src/reducers/inline-dict.ts
  56. 2 0
      openapi/public/assets/protocol/main.yaml
  57. 3 0
      openapi/public/assets/protocol/resources/discussion/discussion.yaml
  58. 28 0
      openapi/public/assets/protocol/resources/discussion/index.yaml
  59. 39 0
      openapi/public/assets/protocol/resources/discussion/store.yaml

+ 18 - 0
dashboard/src/components/api/Attachments.ts

@@ -0,0 +1,18 @@
+/*
+            'name' => $filename,
+            'size' => $file->getSize(),
+            'type' => $file->getMimeType(),
+            'url' => $filename,
+*/
+export interface IAttachmentRequest {
+  uid: string;
+  name?: string;
+  size?: number;
+  type?: string;
+  url?: string;
+}
+export interface IAttachmentResponse {
+  ok: boolean;
+  message: string;
+  data: IAttachmentRequest;
+}

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

@@ -1,5 +1,11 @@
 export type Role = "owner" | "manager" | "editor" | "member";
 
+export interface IUserRequest {
+  id?: string;
+  userName?: string;
+  nickName?: string;
+  avatar?: string;
+}
 export interface IUserApiResponse {
   id: string;
   userName: string;

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

@@ -0,0 +1,38 @@
+import { IUserRequest } from "./Auth";
+
+export interface ICommentRequest {
+  id?: string;
+  res_id?: string;
+  res_type?: string;
+  title?: string;
+  content?: string;
+  parent?: string;
+  editor?: IUserRequest;
+  created_at?: string;
+  updated_at?: string;
+}
+
+export interface ICommentApiData {
+  id: string;
+  res_id: string;
+  res_type: string;
+  title?: string;
+  content?: string;
+  parent?: string;
+  children_count: number;
+  editor: IUserRequest;
+  created_at?: string;
+  updated_at?: string;
+}
+
+export interface ICommentResponse {
+  ok: boolean;
+  message: string;
+  data: ICommentApiData;
+}
+
+export interface ICommentListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ICommentApiData[]; count: number };
+}

+ 7 - 3
dashboard/src/components/api/Dict.ts

@@ -8,10 +8,12 @@ export interface IDictDataRequest {
   note: string;
   factors: string;
   factormean: string;
+  dictId?: string;
+  dictName?: string;
   language: string;
   confidence: number;
 }
-export interface IApiResponseDictlData {
+export interface IApiResponseDictData {
   id: number;
   word: string;
   type: string;
@@ -22,6 +24,8 @@ export interface IApiResponseDictlData {
   factors: string;
   factormean: string;
   language: string;
+  dict_id: string;
+  dict_name?: string;
   confidence: number;
   creator_id: number;
   updated_at: string;
@@ -29,13 +33,13 @@ export interface IApiResponseDictlData {
 export interface IApiResponseDict {
   ok: boolean;
   message: string;
-  data: IApiResponseDictlData;
+  data: IApiResponseDictData;
 }
 export interface IApiResponseDictList {
   ok: boolean;
   message: string;
   data: {
-    rows: IApiResponseDictlData[];
+    rows: IApiResponseDictData[];
     count: number;
   };
 }

+ 1 - 1
dashboard/src/components/article/AnthologyCard.tsx

@@ -5,7 +5,7 @@ import { Typography } from "antd";
 
 import StudioName from "../auth/StudioName";
 import type { IStudio } from "../auth/StudioName";
-import type { ListNodeData } from "../studio/EditableTree";
+import type { ListNodeData } from "./EditableTree";
 
 const { Title, Text } = Typography;
 

+ 10 - 3
dashboard/src/components/article/Article.tsx

@@ -1,7 +1,9 @@
 import { message } from "antd";
 import { useEffect, useState } from "react";
+import { modeChange } from "../../reducers/article-mode";
 
 import { get } from "../../request";
+import store from "../../store";
 import { IArticleDataResponse, IArticleResponse } from "../api/Article";
 import ArticleView from "./ArticleView";
 
@@ -28,6 +30,7 @@ const Widget = ({
 }: IWidgetArticle) => {
   const [articleData, setArticleData] = useState<IArticleDataResponse>();
   const [articleMode, setArticleMode] = useState<ArticleMode>(mode);
+
   let channels: string[] = [];
   if (typeof articleId !== "undefined") {
     const aId = articleId.split("_");
@@ -37,15 +40,19 @@ const Widget = ({
   }
 
   useEffect(() => {
+    console.log("mode", mode, articleMode);
     if (!active) {
       return;
     }
-    if (mode !== "read" && articleMode !== "read") {
-      setArticleMode(mode);
+    setArticleMode(mode);
+    //发布mode变更
+    store.dispatch(modeChange(mode));
+
+    if (mode !== articleMode && mode !== "read" && articleMode !== "read") {
       console.log("set mode", mode, articleMode);
       return;
     }
-    setArticleMode(mode);
+
     if (typeof type !== "undefined" && typeof articleId !== "undefined") {
       get<IArticleResponse>(`/v2/${type}/${articleId}/${mode}`).then((json) => {
         if (json.ok) {

+ 10 - 5
dashboard/src/components/article/ArticleCard.tsx

@@ -9,6 +9,7 @@ import { modeChange } from "../../reducers/article-mode";
 import { IWidgetArticleData } from "./ArticleView";
 import ArticleCardMainMenu from "./ArticleCardMainMenu";
 import { ArticleMode } from "./Article";
+import { useNavigate } from "react-router-dom";
 
 interface IWidgetArticleCard {
   type?: string;
@@ -29,6 +30,7 @@ const Widget = ({
 }: IWidgetArticleCard) => {
   const intl = useIntl();
   const [mode, setMode] = useState<string>("read");
+  const navigate = useNavigate();
 
   const onClick: MenuProps["onClick"] = (e) => {
     console.log("click ", e);
@@ -59,7 +61,7 @@ const Widget = ({
           value: "read",
         },
         {
-          label: intl.formatMessage({ id: "buttons.edit" }),
+          label: intl.formatMessage({ id: "buttons.translate" }),
           value: "edit",
         },
         {
@@ -69,14 +71,17 @@ const Widget = ({
       ]}
       value={mode}
       onChange={(value) => {
+        const newMode = value.toString();
         if (typeof onModeChange !== "undefined") {
-          if (mode === "read" || value.toString() === "read") {
-            onModeChange(value.toString());
+          if (mode === "read" || newMode === "read") {
+            onModeChange(newMode);
           }
         }
-        setMode(value.toString());
+        setMode(newMode);
         //发布mode变更
-        store.dispatch(modeChange(value.toString() as ArticleMode));
+        store.dispatch(modeChange(newMode as ArticleMode));
+        //修改url
+        navigate(`/article/${type}/${articleId}/${newMode}`);
       }}
     />
   );

+ 0 - 0
dashboard/src/components/studio/EditableTree.tsx → dashboard/src/components/article/EditableTree.tsx


+ 2 - 2
dashboard/src/components/article/PaliTextToc.tsx

@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
 
 import { get } from "../../request";
 import { IPaliTocListResponse } from "../api/Corpus";
-import { ListNodeData } from "../studio/EditableTree";
+import { ListNodeData } from "./EditableTree";
 import TocTree from "./TocTree";
 
 interface IWidget {
@@ -26,7 +26,7 @@ const Widget = ({ book, para, channel }: IWidget) => {
       setTocList(toc);
     });
   }, [book, para]);
-  return <TocTree treeData={tocList} />;
+  return <TocTree treeData={tocList} expandedKey={[`${book}-${para}`]} />;
 };
 
 export default Widget;

+ 65 - 25
dashboard/src/components/article/TocTree.tsx

@@ -1,17 +1,22 @@
 import { Tree } from "antd";
 
-import type { TreeProps } from "antd/es/tree";
-import type { ListNodeData } from "../studio/EditableTree";
+import type { DataNode, TreeProps } from "antd/es/tree";
+import { useEffect, useState } from "react";
+import type { ListNodeData } from "./EditableTree";
+import PaliText from "../template/Wbw/PaliText";
 
 type TreeNodeData = {
   key: string;
   title: string;
-  children: TreeNodeData[];
+  children?: TreeNodeData[];
   level: number;
 };
 
-function tocGetTreeData(listData: ListNodeData[], active = "") {
-  let treeData = [];
+function tocGetTreeData(
+  listData: ListNodeData[],
+  active = ""
+): TreeNodeData[] | undefined {
+  let treeData: TreeNodeData[] = [];
   let tocActivePath: TreeNodeData[] = [];
   let treeParents = [];
   let rootNode: TreeNodeData = {
@@ -30,21 +35,25 @@ function tocGetTreeData(listData: ListNodeData[], active = "") {
     let newNode: TreeNodeData = {
       key: element.key,
       title: element.title,
-      children: [],
       level: element.level,
     };
-    /*
-		if (active == element.article) {
-			newNode["extraClasses"] = "active";
-		}
-*/
+
     if (newNode.level > iCurrLevel) {
       //新的层级比较大,为上一个的子目录
       treeParents.push(lastInsNode);
+      if (typeof lastInsNode.children === "undefined") {
+        lastInsNode.children = [];
+      }
       lastInsNode.children.push(newNode);
     } else if (newNode.level === iCurrLevel) {
       //目录层级相同,为平级
-      treeParents[treeParents.length - 1].children.push(newNode);
+      const parentNode = treeParents[treeParents.length - 1];
+      if (typeof parentNode !== "undefined") {
+        if (typeof parentNode.children === "undefined") {
+          parentNode.children = [];
+        }
+        parentNode.children.push(newNode);
+      }
     } else {
       // 小于 挂在上一个层级
       while (treeParents.length > 1) {
@@ -53,7 +62,13 @@ function tocGetTreeData(listData: ListNodeData[], active = "") {
           break;
         }
       }
-      treeParents[treeParents.length - 1].children.push(newNode);
+      const parentNode = treeParents[treeParents.length - 1];
+      if (typeof parentNode !== "undefined") {
+        if (typeof parentNode.children === "undefined") {
+          parentNode.children = [];
+        }
+        parentNode.children.push(newNode);
+      }
     }
     lastInsNode = newNode;
     iCurrLevel = newNode.level;
@@ -66,25 +81,50 @@ function tocGetTreeData(listData: ListNodeData[], active = "") {
       }
     }
   }
+
   return treeData[0].children;
 }
 
-type IWidgetTocTree = {
+interface IWidgetTocTree {
   treeData: ListNodeData[];
-};
-const onSelect: TreeProps["onSelect"] = (selectedKeys, info) => {
-  //let aaa: NewTree = info.node;
-  console.log("selected", selectedKeys);
-};
-const Widget = ({ treeData }: IWidgetTocTree) => {
-  const data = tocGetTreeData(treeData);
+  expandedKey?: string[];
+  onSelect?: Function;
+}
 
-  //const [expandedKeys] = useState(["0-0", "0-0-0", "0-0-0-0"]);
+const Widget = ({ treeData, expandedKey, onSelect }: IWidgetTocTree) => {
+  const [tree, setTree] = useState<TreeNodeData[]>();
+  const [expanded, setExpanded] = useState(expandedKey);
+
+  useEffect(() => {
+    if (treeData.length > 0) {
+      const data = tocGetTreeData(treeData);
+      setTree(data);
+      setExpanded(expandedKey);
+      console.log("create tree", treeData.length, expandedKey);
+    }
+  }, [treeData, expandedKey]);
+  const onNodeSelect: TreeProps["onSelect"] = (selectedKeys, info) => {
+    console.log("selected", selectedKeys);
+    if (typeof onSelect !== "undefined") {
+      onSelect(selectedKeys);
+    }
+  };
 
   return (
-    <>
-      <Tree onSelect={onSelect} treeData={data} />
-    </>
+    <Tree
+      onSelect={onNodeSelect}
+      treeData={tree}
+      defaultExpandedKeys={expanded}
+      defaultSelectedKeys={expanded}
+      blockNode
+      titleRender={(node: DataNode) => {
+        if (typeof node.title === "string") {
+          return <PaliText text={node.title} />;
+        } else {
+          return <></>;
+        }
+      }}
+    />
   );
 };
 

+ 2 - 2
dashboard/src/components/auth/setting/SettingArticle.tsx

@@ -10,8 +10,8 @@ const Widget = () => {
       <SettingItem data={SettingFind("setting.display.original")} />
       <SettingItem data={SettingFind("setting.layout.direction")} />
       <SettingItem data={SettingFind("setting.layout.paragraph")} />
-      <SettingItem data={SettingFind("setting.pali.script1")} />
-      <SettingItem data={SettingFind("setting.pali.script2")} />
+      <SettingItem data={SettingFind("setting.pali.script.primary")} />
+      <SettingItem data={SettingFind("setting.pali.script.secondary")} />
       <Divider>翻译</Divider>
 
       <Divider>逐词解析</Divider>

+ 17 - 6
dashboard/src/components/auth/setting/SettingItem.tsx

@@ -1,3 +1,4 @@
+import { useIntl } from "react-intl";
 import { useEffect, useState } from "react";
 import { Switch, Typography, Radio, RadioChangeEvent, Select } from "antd";
 
@@ -17,9 +18,14 @@ interface IWidgetSettingItem {
   onChange?: Function;
 }
 const Widget = ({ data, onChange }: IWidgetSettingItem) => {
+  const intl = useIntl();
   const settings: ISettingItem[] | undefined = useAppSelector(settingInfo);
   const [value, setValue] = useState(data?.defaultValue);
-  const title = <Title level={5}>{data?.label}</Title>;
+  const title = (
+    <Title level={5}>
+      {data?.label ? intl.formatMessage({ id: data.label }) : ""}
+    </Title>
+  );
   console.log(data);
   useEffect(() => {
     const currSetting = settings?.find((element) => element.key === data?.key);
@@ -42,7 +48,7 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
                 <>
                   {title}
                   <div>
-                    <Text>{data.description}</Text>
+                    <Text>{intl.formatMessage({ id: data.description })}</Text>
                   </div>
                   <Radio.Group
                     value={value}
@@ -60,7 +66,7 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
                     {data.options.map((item, id) => {
                       return (
                         <Radio.Button key={id} value={item.value}>
-                          {item.label}
+                          {intl.formatMessage({ id: item.label })}
                         </Radio.Button>
                       );
                     })}
@@ -86,7 +92,12 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
                         })
                       );
                     }}
-                    options={data.options}
+                    options={data.options.map((item) => {
+                      return {
+                        value: item.value,
+                        label: intl.formatMessage({ id: item.label }),
+                      };
+                    })}
                   />
                 </div>
               );
@@ -110,7 +121,7 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
                 );
               }}
             />
-            <Text>{data.description}</Text>
+            <Text>{intl.formatMessage({ id: data.description })}</Text>
           </div>
         );
         break;
@@ -120,7 +131,7 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
 
     return (
       <div>
-        <Title level={5}>{data.label}</Title>
+        <Title level={5}>{intl.formatMessage({ id: data.label })}</Title>
         {content}
       </div>
     );

+ 110 - 153
dashboard/src/components/auth/setting/default.ts

@@ -1,5 +1,3 @@
-import { useIntl } from "react-intl";
-
 import { ISettingItem } from "../../../reducers/setting";
 
 export interface ISettingItemOption {
@@ -36,156 +34,115 @@ export const GetUserSetting = (
 };
 
 export const SettingFind = (key: string): ISetting | undefined => {
-  return Settings().find((element) => element.key === key);
+  return defaultSetting.find((element) => element.key === key);
 };
-export const Settings = (): ISetting[] => {
-  const intl = useIntl();
-
-  const defaultSetting: ISetting[] = [
-    {
-      /**
-       * 是否显示巴利原文
-       */
-      key: "setting.display.original",
-      label: intl.formatMessage({ id: "setting.display.original.label" }),
-      description: intl.formatMessage({
-        id: "setting.display.original.description",
-      }),
-      defaultValue: true,
-    },
-    {
-      /**
-       * 排版方向
-       */
-      key: "setting.layout.direction",
-      label: intl.formatMessage({ id: "setting.layout.direction.label" }),
-      description: intl.formatMessage({
-        id: "setting.layout.direction.description",
-      }),
-      defaultValue: "column",
-      options: [
-        {
-          value: "column",
-          label: intl.formatMessage({
-            id: "setting.layout.direction.col.label",
-          }),
-        },
-        {
-          value: "row",
-          label: intl.formatMessage({
-            id: "setting.layout.direction.row.label",
-          }),
-        },
-      ],
-      widget: "radio-button",
-    },
-    {
-      /**
-       * 段落或者逐句对读
-       */
-      key: "setting.layout.paragraph",
-      label: intl.formatMessage({ id: "setting.layout.paragraph.label" }),
-      description: intl.formatMessage({
-        id: "setting.layout.paragraph.description",
-      }),
-      defaultValue: "sentence",
-      options: [
-        {
-          value: "sentence",
-          label: intl.formatMessage({
-            id: "setting.layout.paragraph.sentence.label",
-          }),
-        },
-        {
-          value: "paragraph",
-          label: intl.formatMessage({
-            id: "setting.layout.paragraph.paragraph.label",
-          }),
-        },
-      ],
-      widget: "radio-button",
-    },
-    {
-      /**
-       * 第一巴利脚本
-       */
-      key: "setting.pali.script1",
-      label: intl.formatMessage({ id: "setting.pali.script1.label" }),
-      description: intl.formatMessage({
-        id: "setting.pali.script1.description",
-      }),
-      defaultValue: "roman",
-      options: [
-        {
-          value: "roman",
-          label: intl.formatMessage({
-            id: "setting.pali.script.rome.label",
-          }),
-        },
-        {
-          value: "roman_to_my",
-          label: intl.formatMessage({
-            id: "setting.pali.script.my.label",
-          }),
-        },
-        {
-          value: "roman_to_si",
-          label: intl.formatMessage({
-            id: "setting.pali.script.si.label",
-          }),
-        },
-        {
-          value: "roman_to_thai",
-          label: intl.formatMessage({
-            id: "setting.pali.script.thai.label",
-          }),
-        },
-        {
-          value: "roman_to_taitham",
-          label: intl.formatMessage({
-            id: "setting.pali.script.tai.label",
-          }),
-        },
-      ],
-    },
-    {
-      /**
-       * 第二巴利脚本
-       */
-      key: "setting.pali.script2",
-      label: intl.formatMessage({ id: "setting.pali.script2.label" }),
-      description: intl.formatMessage({
-        id: "setting.pali.script2.description",
-      }),
-      defaultValue: "none",
-      options: [
-        {
-          value: "none",
-          label: intl.formatMessage({
-            id: "setting.pali.script.none.label",
-          }),
-        },
-        {
-          value: "roman",
-          label: intl.formatMessage({
-            id: "setting.pali.script.rome.label",
-          }),
-        },
-        {
-          value: "roman_to_my",
-          label: intl.formatMessage({
-            id: "setting.pali.script.my.label",
-          }),
-        },
-        {
-          value: "roman_to_si",
-          label: intl.formatMessage({
-            id: "setting.pali.script.si.label",
-          }),
-        },
-      ],
-    },
-  ];
 
-  return defaultSetting;
-};
+export const defaultSetting: ISetting[] = [
+  {
+    /**
+     * 是否显示巴利原文
+     */
+    key: "setting.display.original",
+    label: "setting.display.original.label",
+    description: "setting.display.original.description",
+    defaultValue: true,
+  },
+  {
+    /**
+     * 排版方向
+     */
+    key: "setting.layout.direction",
+    label: "setting.layout.direction.label",
+    description: "setting.layout.direction.description",
+    defaultValue: "column",
+    options: [
+      {
+        value: "column",
+        label: "setting.layout.direction.col.label",
+      },
+      {
+        value: "row",
+        label: "setting.layout.direction.row.label",
+      },
+    ],
+    widget: "radio-button",
+  },
+  {
+    /**
+     * 段落或者逐句对读
+     */
+    key: "setting.layout.paragraph",
+    label: "setting.layout.paragraph.label",
+    description: "setting.layout.paragraph.description",
+    defaultValue: "sentence",
+    options: [
+      {
+        value: "sentence",
+        label: "setting.layout.paragraph.sentence.label",
+      },
+      {
+        value: "paragraph",
+        label: "setting.layout.paragraph.paragraph.label",
+      },
+    ],
+    widget: "radio-button",
+  },
+  {
+    /**
+     * 第一巴利脚本
+     */
+    key: "setting.pali.script.primary",
+    label: "setting.pali.script.primary.label",
+    description: "setting.pali.script.primary.description",
+    defaultValue: "roman",
+    options: [
+      {
+        value: "roman",
+        label: "setting.pali.script.rome.label",
+      },
+      {
+        value: "roman_to_my",
+        label: "setting.pali.script.my.label",
+      },
+      {
+        value: "roman_to_si",
+        label: "setting.pali.script.si.label",
+      },
+      {
+        value: "roman_to_thai",
+        label: "setting.pali.script.thai.label",
+      },
+      {
+        value: "roman_to_taitham",
+        label: "setting.pali.script.tai.label",
+      },
+    ],
+  },
+  {
+    /**
+     * 第二巴利脚本
+     */
+    key: "setting.pali.script.secondary",
+    label: "setting.pali.script.secondary.label",
+    description: "setting.pali.script.secondary.description",
+    defaultValue: "none",
+    options: [
+      {
+        value: "none",
+        label: "setting.pali.script.none.label",
+      },
+      {
+        value: "roman",
+        label: "setting.pali.script.rome.label",
+      },
+      {
+        value: "roman_to_my",
+        label: "setting.pali.script.my.label",
+      },
+      {
+        value: "roman_to_si",
+        label: "setting.pali.script.si.label",
+      },
+    ],
+  },
+];

+ 4 - 4
dashboard/src/components/code/my.ts

@@ -349,8 +349,8 @@ const char_myn_to_roman_1 = [
   { id: "၊", value: "," },
 ];
 
-export const roman_to_my = (input: string | null): string | null => {
-  if (input === null) {
+export const roman_to_my = (input: string | undefined): string | undefined => {
+  if (typeof input === "undefined") {
     return input;
   }
   let txt = input.toLowerCase();
@@ -366,8 +366,8 @@ export const roman_to_my = (input: string | null): string | null => {
   return txt;
 };
 
-export const my_to_roman = (input: string | null): string | null => {
-  if (input === null) {
+export const my_to_roman = (input: string | undefined): string | undefined => {
+  if (typeof input === "undefined") {
     return input;
   }
   let txt = input.toLowerCase();

+ 2 - 2
dashboard/src/components/code/si.ts

@@ -319,8 +319,8 @@ const char_unicode_to_si = [
   { id: "o", value: "ඔ" },
 ];
 
-export const roman_to_si = (input: string | null): string | null => {
-  if (input === null) {
+export const roman_to_si = (input: string | undefined): string | undefined => {
+  if (typeof input === "undefined") {
     return input;
   }
   let txt = input.toLowerCase();

+ 4 - 2
dashboard/src/components/code/tai-tham.ts

@@ -659,8 +659,10 @@ const char_tai_old_to_r = [
   { id: "aฺ", value: "" },
 ];
 */
-export const roman_to_taitham = (input: string | null): string | null => {
-  if (input === null) {
+export const roman_to_taitham = (
+  input: string | undefined
+): string | undefined => {
+  if (typeof input === "undefined") {
     return input;
   }
   let txt = input.toLowerCase();

+ 4 - 2
dashboard/src/components/code/thai.ts

@@ -117,8 +117,10 @@ const char_roman_to_thai = [
   { id: "ฺอ", value: "" },
 ];
 
-export const roman_to_thai = (input: string | null): string | null => {
-  if (input === null) {
+export const roman_to_thai = (
+  input: string | undefined
+): string | undefined => {
+  if (typeof input === "undefined") {
     return input;
   }
   let txt = input.toLowerCase();

+ 88 - 0
dashboard/src/components/comment/CommentBox.tsx

@@ -0,0 +1,88 @@
+import { useState } from "react";
+import { Drawer } from "antd";
+import CommentTopic from "./CommentTopic";
+import CommentListCard from "./CommentListCard";
+import { IComment } from "./CommentItem";
+
+export interface IAnswerCount {
+  id: string;
+  count: number;
+}
+interface IWidget {
+  trigger?: JSX.Element;
+  resId?: string;
+  resType?: string;
+  onCommentCountChange?: Function;
+}
+const Widget = ({ trigger, resId, resType, onCommentCountChange }: IWidget) => {
+  const [open, setOpen] = useState(false);
+  const [childrenDrawer, setChildrenDrawer] = useState(false);
+  const [topicComment, setTopicComment] = useState<IComment>();
+  const [answerCount, setAnswerCount] = useState<IAnswerCount>();
+  //console.log(resId, resType);
+  const showDrawer = () => {
+    setOpen(true);
+  };
+
+  const onClose = () => {
+    setOpen(false);
+  };
+
+  const showChildrenDrawer = (
+    e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+    comment: IComment
+  ) => {
+    setChildrenDrawer(true);
+    setTopicComment(comment);
+  };
+
+  const onChildrenDrawerClose = () => {
+    setChildrenDrawer(false);
+  };
+
+  return (
+    <>
+      <span onClick={showDrawer}>{trigger}</span>
+      <Drawer
+        title="Discussion"
+        width={520}
+        onClose={onClose}
+        open={open}
+        maskClosable={false}
+      >
+        <CommentListCard
+          resId={resId}
+          resType={resType}
+          onSelect={showChildrenDrawer}
+          changedAnswerCount={answerCount}
+          onItemCountChange={(count: number) => {
+            if (typeof onCommentCountChange !== "undefined") {
+              onCommentCountChange(count);
+            }
+          }}
+        />
+        <Drawer
+          title="回答"
+          width={480}
+          onClose={onChildrenDrawerClose}
+          open={childrenDrawer}
+        >
+          {resId && resType ? (
+            <CommentTopic
+              comment={topicComment}
+              resId={resId}
+              resType={resType}
+              onItemCountChange={(count: number, parent: string) => {
+                setAnswerCount({ id: parent, count: count });
+              }}
+            />
+          ) : (
+            <></>
+          )}
+        </Drawer>
+      </Drawer>
+    </>
+  );
+};
+
+export default Widget;

+ 125 - 0
dashboard/src/components/comment/CommentCreate.tsx

@@ -0,0 +1,125 @@
+import { useIntl } from "react-intl";
+import { message } from "antd";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { Col, Row, Space } from "antd";
+
+import { IComment } from "./CommentItem";
+import { post } from "../../request";
+import { ICommentRequest, ICommentResponse } from "../api/Comment";
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import { useRef } from "react";
+
+interface IWidget {
+  resId: string;
+  resType: string;
+  parent?: string;
+  onCreated?: Function;
+}
+const Widget = ({ resId, resType, parent, onCreated }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+  const _currUser = useAppSelector(_currentUser);
+  const formItemLayout = {
+    labelCol: { span: 4 },
+    wrapperCol: { span: 20 },
+  };
+  return (
+    <div>
+      <div>{_currUser?.nickName}:</div>
+      <ProForm<IComment>
+        {...formItemLayout}
+        layout="horizontal"
+        formRef={formRef}
+        submitter={{
+          render: (props, doms) => {
+            return (
+              <Row>
+                <Col span={14} offset={4}>
+                  <Space>{doms}</Space>
+                </Col>
+              </Row>
+            );
+          },
+        }}
+        onFinish={async (values) => {
+          //新建
+          console.log("create", resId, resType, parent);
+
+          post<ICommentRequest, ICommentResponse>(`/v2/discussion`, {
+            res_id: resId,
+            res_type: resType,
+            parent: parent,
+            title: values.title,
+            content: values.content,
+          })
+            .then((json) => {
+              console.log("new discussion", json);
+              if (json.ok) {
+                formRef.current?.resetFields();
+                if (typeof onCreated !== "undefined") {
+                  onCreated({
+                    id: json.data.id,
+                    resId: json.data.res_id,
+                    resType: json.data.res_type,
+                    user: {
+                      id: json.data.editor?.id ? json.data.editor.id : "null",
+                      nickName: json.data.editor?.nickName
+                        ? json.data.editor.nickName
+                        : "null",
+                      realName: json.data.editor?.userName
+                        ? json.data.editor.userName
+                        : "null",
+                      avatar: json.data.editor?.avatar
+                        ? json.data.editor.avatar
+                        : "null",
+                    },
+                    title: json.data.title,
+                    parent: json.data.parent,
+                    content: json.data.content,
+                    createdAt: json.data.created_at,
+                    updatedAt: json.data.updated_at,
+                  });
+                }
+              } else {
+                message.error(json.message);
+              }
+            })
+            .catch((e) => {
+              message.error(e.message);
+            });
+        }}
+        params={{}}
+      >
+        {parent ? (
+          <></>
+        ) : (
+          <ProFormText
+            name="title"
+            label={intl.formatMessage({ id: "forms.fields.title.label" })}
+            tooltip="最长为 24 位"
+            placeholder={intl.formatMessage({
+              id: "forms.message.title.required",
+            })}
+            rules={[{ required: true, message: "这是必填项" }]}
+          />
+        )}
+
+        <ProFormTextArea
+          name="content"
+          label={intl.formatMessage({ id: "forms.fields.content.label" })}
+          placeholder={intl.formatMessage({
+            id: "forms.fields.content.placeholder",
+          })}
+        />
+      </ProForm>
+    </div>
+  );
+};
+
+export default Widget;

+ 96 - 0
dashboard/src/components/comment/CommentEdit.tsx

@@ -0,0 +1,96 @@
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import { Button, Card } from "antd";
+import { Input, message } from "antd";
+import { SaveOutlined } from "@ant-design/icons";
+import {
+  ProForm,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { Col, Row, Space } from "antd";
+
+import { IComment } from "./CommentItem";
+import { post, put } from "../../request";
+import { ICommentRequest, ICommentResponse } from "../api/Comment";
+
+const { TextArea } = Input;
+
+interface IWidget {
+  data: IComment;
+  onCreated?: Function;
+}
+const Widget = ({ data, onCreated }: IWidget) => {
+  const intl = useIntl();
+  const formItemLayout = {
+    labelCol: { span: 4 },
+    wrapperCol: { span: 20 },
+  };
+  return (
+    <div>
+      <Card
+        title={<span>{data.user.nickName}</span>}
+        extra={
+          <Button shape="circle" size="small">
+            xxx
+          </Button>
+        }
+        style={{ width: "auto" }}
+      >
+        <ProForm<IComment>
+          {...formItemLayout}
+          layout="horizontal"
+          submitter={{
+            render: (props, doms) => {
+              return (
+                <Row>
+                  <Col span={14} offset={4}>
+                    <Space>{doms}</Space>
+                  </Col>
+                </Row>
+              );
+            },
+          }}
+          onFinish={async (values) => {
+            //新建
+            put<ICommentRequest, ICommentResponse>(
+              `/v2/discussion/${data.id}`,
+              {
+                title: values.title,
+                content: values.content,
+              }
+            )
+              .then((json) => {
+                console.log(json);
+                if (json.ok) {
+                  console.log(intl.formatMessage({ id: "flashes.success" }));
+                  if (typeof onCreated !== "undefined") {
+                    onCreated(json.data);
+                  }
+                } else {
+                  message.error(json.message);
+                }
+              })
+              .catch((e) => {
+                message.error(e.message);
+              });
+          }}
+          params={{}}
+          request={async () => {
+            return data;
+          }}
+        >
+          <ProFormTextArea
+            name="content"
+            label={intl.formatMessage({ id: "forms.fields.content.label" })}
+            placeholder={intl.formatMessage({
+              id: "forms.fields.content.placeholder",
+            })}
+          />
+        </ProForm>
+      </Card>
+    </div>
+  );
+};
+
+export default Widget;

+ 56 - 0
dashboard/src/components/comment/CommentItem.tsx

@@ -0,0 +1,56 @@
+import { Avatar } from "antd";
+import { useState } from "react";
+import { IUser } from "../auth/User";
+import CommentShow from "./CommentShow";
+import CommentEdit from "./CommentEdit";
+
+export interface IComment {
+  id?: string; //id未提供为新建
+  resId?: string;
+  resType?: string;
+  user: IUser;
+  parent?: string;
+  title?: string;
+  content?: string;
+  children?: IComment[];
+  childrenCount?: number;
+  createdAt?: string;
+  updatedAt?: string;
+}
+interface IWidget {
+  data: IComment;
+  onSelect?: Function;
+  onCreated?: Function;
+}
+const Widget = ({ data, onSelect, onCreated }: IWidget) => {
+  const [edit, setEdit] = useState(false);
+  console.log(data);
+  return (
+    <div style={{ display: "flex" }}>
+      <div style={{ width: "auto", padding: 8 }}>
+        <Avatar>{data.user?.nickName?.slice(0, 1)}</Avatar>
+      </div>
+      <div style={{ flex: "auto" }}>
+        {edit ? (
+          <CommentEdit
+            data={data}
+            onCreated={(e: IComment) => {
+              if (typeof onCreated !== "undefined") {
+                onCreated(e);
+              }
+            }}
+          />
+        ) : (
+          <CommentShow
+            data={data}
+            onEdit={() => {
+              setEdit(true);
+            }}
+          />
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default Widget;

+ 56 - 0
dashboard/src/components/comment/CommentList.tsx

@@ -0,0 +1,56 @@
+import { List, Avatar, Space } from "antd";
+import { MessageOutlined } from "@ant-design/icons";
+
+import { IComment } from "./CommentItem";
+
+interface IWidget {
+  data: IComment[];
+  onSelect?: Function;
+}
+const Widget = ({ data, onSelect }: IWidget) => {
+  return (
+    <div>
+      <List
+        pagination={{
+          onChange: (page) => {
+            console.log(page);
+          },
+          pageSize: 10,
+        }}
+        itemLayout="horizontal"
+        dataSource={data}
+        renderItem={(item) => (
+          <List.Item
+            actions={[
+              item.childrenCount ? (
+                <Space>
+                  <MessageOutlined /> {item.childrenCount}
+                </Space>
+              ) : (
+                <></>
+              ),
+            ]}
+          >
+            <List.Item.Meta
+              avatar={<Avatar src="https://joeschmoe.io/api/v1/random" />}
+              title={
+                <span
+                  onClick={(e) => {
+                    if (typeof onSelect !== "undefined") {
+                      onSelect(e, item);
+                    }
+                  }}
+                >
+                  {item.title ? item.title : item.content?.slice(0, 20)}
+                </span>
+              }
+              description={item.content?.slice(0, 40)}
+            />
+          </List.Item>
+        )}
+      />
+    </div>
+  );
+};
+
+export default Widget;

+ 113 - 0
dashboard/src/components/comment/CommentListCard.tsx

@@ -0,0 +1,113 @@
+import { useState, useEffect } from "react";
+import { useIntl } from "react-intl";
+import { Card, message } from "antd";
+
+import { get } from "../../request";
+import { ICommentListResponse } from "../api/Comment";
+import CommentCreate from "./CommentCreate";
+import { IComment } from "./CommentItem";
+import CommentList from "./CommentList";
+import { IAnswerCount } from "./CommentBox";
+
+interface IWidget {
+  resId?: string;
+  resType?: string;
+  changedAnswerCount?: IAnswerCount;
+  onSelect?: Function;
+  onItemCountChange?: Function;
+}
+const Widget = ({
+  resId,
+  resType,
+  onSelect,
+  changedAnswerCount,
+  onItemCountChange,
+}: IWidget) => {
+  const intl = useIntl();
+  const [data, setData] = useState<IComment[]>([]);
+  useEffect(() => {
+    console.log("changedAnswerCount", changedAnswerCount);
+    const newData = data.map((item) => {
+      const newItem = item;
+      if (newItem.id && changedAnswerCount?.id === newItem.id) {
+        newItem.childrenCount = changedAnswerCount.count;
+      }
+      return newItem;
+    });
+    setData(newData);
+  }, [changedAnswerCount]);
+
+  useEffect(() => {
+    get<ICommentListResponse>(`/v2/discussion?view=question&id=${resId}`)
+      .then((json) => {
+        console.log(json);
+        if (json.ok) {
+          console.log(intl.formatMessage({ id: "flashes.success" }));
+          const discussions: IComment[] = json.data.rows.map((item) => {
+            return {
+              id: item.id,
+              resId: item.res_id,
+              resType: item.res_type,
+              user: {
+                id: item.editor?.id ? item.editor.id : "",
+                nickName: item.editor?.nickName ? item.editor.nickName : "",
+                realName: item.editor?.userName ? item.editor.userName : "",
+                avatar: item.editor?.avatar ? item.editor.avatar : "",
+              },
+              title: item.title,
+              content: item.content,
+              childrenCount: item.children_count,
+              createdAt: item.created_at,
+              updatedAt: item.updated_at,
+            };
+          });
+          setData(discussions);
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  }, [resId]);
+
+  if (typeof resId === "undefined") {
+    return <div>该资源尚未创建,不能发表讨论。</div>;
+  }
+
+  return (
+    <div>
+      <Card title="问题列表" extra={<a href="#">More</a>}>
+        <CommentList
+          onSelect={(
+            e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+            comment: IComment
+          ) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect(e, comment);
+            }
+          }}
+          data={data}
+        />
+        {resId && resType ? (
+          <CommentCreate
+            resId={resId}
+            resType={resType}
+            onCreated={(e: IComment) => {
+              const newData = JSON.parse(JSON.stringify(e));
+              console.log("create", e);
+              if (typeof onItemCountChange !== "undefined") {
+                onItemCountChange(data.length + 1);
+              }
+              setData([...data, newData]);
+            }}
+          />
+        ) : (
+          <></>
+        )}
+      </Card>
+    </div>
+  );
+};
+
+export default Widget;

+ 5 - 0
dashboard/src/components/comment/CommentListItem.tsx

@@ -0,0 +1,5 @@
+const Widget = () => {
+  return <div>change password</div>;
+};
+
+export default Widget;

+ 89 - 0
dashboard/src/components/comment/CommentShow.tsx

@@ -0,0 +1,89 @@
+import { Button, Card, Dropdown, Space } from "antd";
+import { MoreOutlined } from "@ant-design/icons";
+import type { MenuProps } from "antd";
+
+import { IComment } from "./CommentItem";
+import TimeShow from "../general/TimeShow";
+
+interface IWidget {
+  data: IComment;
+  onEdit?: Function;
+  onSelect?: Function;
+}
+const Widget = ({ data, onEdit, onSelect }: IWidget) => {
+  const onClick: MenuProps["onClick"] = (e) => {
+    console.log("click ", e);
+    switch (e.key) {
+      case "edit":
+        if (typeof onEdit !== "undefined") {
+          onEdit();
+        }
+        break;
+      default:
+        break;
+    }
+  };
+
+  const items: MenuProps["items"] = [
+    {
+      key: "copy-link",
+      label: "复制链接",
+    },
+    {
+      key: "reply",
+      label: "回复",
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "edit",
+      label: "编辑",
+    },
+    {
+      key: "delete",
+      label: "删除",
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "report-content",
+      label: "举报",
+    },
+  ];
+  return (
+    <div>
+      <Card
+        title={
+          <Space>
+            {data.user.nickName}
+            <TimeShow time={data.updatedAt} title="UpdatedAt" />
+          </Space>
+        }
+        extra={
+          <Dropdown menu={{ items, onClick }} placement="bottomRight">
+            <Button
+              shape="circle"
+              size="small"
+              icon={<MoreOutlined />}
+            ></Button>
+          </Dropdown>
+        }
+        style={{ width: "auto" }}
+      >
+        <span
+          onClick={(e) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect();
+            }
+          }}
+        >
+          {data.content}
+        </span>
+      </Card>
+    </div>
+  );
+};
+
+export default Widget;

+ 91 - 0
dashboard/src/components/comment/CommentTopic.tsx

@@ -0,0 +1,91 @@
+import { useEffect, useState } from "react";
+import { Divider, message } from "antd";
+
+import CommentItem, { IComment } from "./CommentItem";
+import CommentTopicList from "./CommentTopicList";
+import CommentTopicHead from "./CommentTopicHead";
+import { get } from "../../request";
+import { ICommentListResponse } from "../api/Comment";
+import { useIntl } from "react-intl";
+import CommentCreate from "./CommentCreate";
+
+const defaultData: IComment[] = Array(5)
+  .fill(3)
+  .map((item, id) => {
+    return {
+      id: "dd",
+      content: "评论内容",
+      title: "评论标题" + id,
+      user: {
+        id: "string",
+        nickName: "Visuddhinanda",
+        realName: "Visuddhinanda",
+        avatar: "",
+      },
+    };
+  });
+
+interface IWidget {
+  resId: string;
+  resType: string;
+  comment?: IComment;
+  onItemCountChange?: Function;
+}
+const Widget = ({ resId, resType, comment, onItemCountChange }: IWidget) => {
+  const intl = useIntl();
+  const [childrenData, setChildrenData] = useState<IComment[]>(defaultData);
+  useEffect(() => {
+    get<ICommentListResponse>(`/v2/discussion?view=answer&id=${comment?.id}`)
+      .then((json) => {
+        console.log(json);
+        if (json.ok) {
+          console.log(intl.formatMessage({ id: "flashes.success" }));
+          const discussions: IComment[] = json.data.rows.map((item) => {
+            return {
+              id: item.id,
+              resId: item.res_id,
+              resType: item.res_type,
+              user: {
+                id: item.editor?.id ? item.editor.id : "null",
+                nickName: item.editor?.nickName ? item.editor.nickName : "null",
+                realName: item.editor?.userName ? item.editor.userName : "null",
+                avatar: item.editor?.avatar ? item.editor.avatar : "null",
+              },
+              title: item.title,
+              content: item.content,
+              createdAt: item.created_at,
+              updatedAt: item.updated_at,
+            };
+          });
+          setChildrenData(discussions);
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  }, [comment]);
+  return (
+    <div>
+      <CommentTopicHead data={comment} />
+      <Divider />
+      <CommentTopicList data={childrenData} />
+      <CommentCreate
+        resId={resId}
+        resType={resType}
+        parent={comment?.id}
+        onCreated={(e: IComment) => {
+          console.log("create", e);
+          const newData = JSON.parse(JSON.stringify(e));
+          if (typeof onItemCountChange !== "undefined") {
+            onItemCountChange(childrenData.length + 1, e.parent);
+          }
+          setChildrenData([...childrenData, newData]);
+        }}
+      />
+    </div>
+  );
+};
+
+export default Widget;

+ 30 - 0
dashboard/src/components/comment/CommentTopicHead.tsx

@@ -0,0 +1,30 @@
+import { Typography, Space } from "antd";
+import TimeShow from "../general/TimeShow";
+
+import { IComment } from "./CommentItem";
+
+const { Title, Text } = Typography;
+
+interface IWidget {
+  data?: IComment;
+}
+const Widget = ({ data }: IWidget) => {
+  return (
+    <div>
+      <Title editable level={1} style={{ margin: 0 }}>
+        {data?.title}
+      </Title>
+      <div>
+        <Text type="secondary">
+          <Space>
+            {" "}
+            {data?.user.nickName}{" "}
+            <TimeShow time={data?.createdAt} title="创建" />
+          </Space>
+        </Text>
+      </div>
+    </div>
+  );
+};
+
+export default Widget;

+ 30 - 0
dashboard/src/components/comment/CommentTopicList.tsx

@@ -0,0 +1,30 @@
+import { List } from "antd";
+
+import CommentItem, { IComment } from "./CommentItem";
+
+interface IWidget {
+  data: IComment[];
+}
+const Widget = ({ data }: IWidget) => {
+  return (
+    <div>
+      <List
+        pagination={{
+          onChange: (page) => {
+            console.log(page);
+          },
+          pageSize: 10,
+        }}
+        itemLayout="horizontal"
+        dataSource={data}
+        renderItem={(item) => (
+          <List.Item>
+            <CommentItem data={item} />
+          </List.Item>
+        )}
+      />
+    </div>
+  );
+};
+
+export default Widget;

+ 4 - 2
dashboard/src/components/corpus/TocPath.tsx

@@ -1,5 +1,6 @@
 import { Link } from "react-router-dom";
 import { Breadcrumb } from "antd";
+import PaliText from "../template/Wbw/PaliText";
 
 export interface ITocPathNode {
   book: number;
@@ -31,14 +32,15 @@ const Widget = ({
   const path = data.map((item, id) => {
     const linkChapter = `/article/chapter/${item.book}-${item.paragraph}${sChannel}`;
     let oneItem = <></>;
+    const title = <PaliText text={item.title} />;
     switch (link) {
       case "none":
-        oneItem = <>{item.title}</>;
+        oneItem = <>{title}</>;
         break;
       case "blank":
         oneItem = (
           <Link to={linkChapter} target="_blank">
-            {item.title}
+            {title}
           </Link>
         );
         break;

+ 81 - 61
dashboard/src/components/general/TimeShow.tsx

@@ -3,71 +3,91 @@ import { useIntl } from "react-intl";
 import { FieldTimeOutlined } from "@ant-design/icons";
 
 interface IWidgetTimeShow {
-	showIcon?: boolean;
-	showTitle?: boolean;
-	showTooltip?: boolean;
-	time: string;
-	title: string;
+  showIcon?: boolean;
+  showTitle?: boolean;
+  showTooltip?: boolean;
+  time?: string;
+  title: string;
 }
 
-const Widget = ({ showIcon = true, showTitle = false, showTooltip = true, time, title }: IWidgetTimeShow) => {
-	const intl = useIntl(); //i18n
+const Widget = ({
+  showIcon = true,
+  showTitle = false,
+  showTooltip = true,
+  time,
+  title,
+}: IWidgetTimeShow) => {
+  const intl = useIntl(); //i18n
+  if (typeof time === "undefined") {
+    return <></>;
+  }
+  const icon = showIcon ? <FieldTimeOutlined /> : <></>;
+  const strTitle = showTitle ? title : "";
 
-	const icon = showIcon ? <FieldTimeOutlined /> : <></>;
-	const strTitle = showTitle ? title : "";
+  const passTime: string = getPassDataTime(time);
+  const tooltip: string = getFullDataTime(time);
+  const color = "lime";
+  function getPassDataTime(t: string): string {
+    let currDate = new Date();
+    const time = new Date(t);
+    let pass = currDate.getTime() - time.getTime();
+    let strPassTime = "";
+    if (pass < 120 * 1000) {
+      //二分钟内
+      strPassTime =
+        Math.floor(pass / 1000) +
+        intl.formatMessage({ id: "utilities.time.secs_ago" });
+    } else if (pass < 7200 * 1000) {
+      //二小时内
+      strPassTime =
+        Math.floor(pass / 1000 / 60) +
+        intl.formatMessage({ id: "utilities.time.mins_ago" });
+    } else if (pass < 3600 * 48 * 1000) {
+      //二天内
+      strPassTime =
+        Math.floor(pass / 1000 / 3600) +
+        intl.formatMessage({ id: "utilities.time.hs_ago" });
+    } else if (pass < 3600 * 24 * 14 * 1000) {
+      //二周内
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24) +
+        intl.formatMessage({ id: "utilities.time.days_ago" });
+    } else if (pass < 3600 * 24 * 60 * 1000) {
+      //二个月内
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24 / 7) +
+        intl.formatMessage({ id: "utilities.time.weeks_ago" });
+    } else if (pass < 3600 * 24 * 365 * 1000) {
+      //一年内
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24 / 30) +
+        intl.formatMessage({ id: "utilities.time.months_ago" });
+    } else if (pass < 3600 * 24 * 730 * 1000) {
+      //超过1年小于2年
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24 / 365) +
+        intl.formatMessage({ id: "utilities.time.year_ago" });
+    } else {
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24 / 365) +
+        intl.formatMessage({ id: "utilities.time.years_ago" });
+    }
+    return strPassTime;
+  }
+  function getFullDataTime(t: string) {
+    let inputDate = new Date(t);
+    return inputDate.toLocaleString();
+  }
 
-	const passTime: string = getPassDataTime(time);
-	const tooltip: string = getFullDataTime(time);
-	const color = "lime";
-	function getPassDataTime(t: string): string {
-		let currDate = new Date();
-		const time = new Date(t);
-		let pass = currDate.getTime() - time.getTime();
-		let strPassTime = "";
-		if (pass < 120 * 1000) {
-			//二分钟内
-			strPassTime = Math.floor(pass / 1000) + intl.formatMessage({ id: "utilities.time.secs_ago" });
-		} else if (pass < 7200 * 1000) {
-			//二小时内
-			strPassTime = Math.floor(pass / 1000 / 60) + intl.formatMessage({ id: "utilities.time.mins_ago" });
-		} else if (pass < 3600 * 48 * 1000) {
-			//二天内
-			strPassTime = Math.floor(pass / 1000 / 3600) + intl.formatMessage({ id: "utilities.time.hs_ago" });
-		} else if (pass < 3600 * 24 * 14 * 1000) {
-			//二周内
-			strPassTime = Math.floor(pass / 1000 / 3600 / 24) + intl.formatMessage({ id: "utilities.time.days_ago" });
-		} else if (pass < 3600 * 24 * 60 * 1000) {
-			//二个月内
-			strPassTime =
-				Math.floor(pass / 1000 / 3600 / 24 / 7) + intl.formatMessage({ id: "utilities.time.weeks_ago" });
-		} else if (pass < 3600 * 24 * 365 * 1000) {
-			//一年内
-			strPassTime =
-				Math.floor(pass / 1000 / 3600 / 24 / 30) + intl.formatMessage({ id: "utilities.time.months_ago" });
-		} else if (pass < 3600 * 24 * 730 * 1000) {
-			//超过1年小于2年
-			strPassTime =
-				Math.floor(pass / 1000 / 3600 / 24 / 365) + intl.formatMessage({ id: "utilities.time.year_ago" });
-		} else {
-			strPassTime =
-				Math.floor(pass / 1000 / 3600 / 24 / 365) + intl.formatMessage({ id: "utilities.time.years_ago" });
-		}
-		return strPassTime;
-	}
-	function getFullDataTime(t: string) {
-		let inputDate = new Date(t);
-		return inputDate.toLocaleString();
-	}
-
-	return (
-		<Tooltip title={tooltip} color={color} key={color}>
-			<Space>
-				{icon}
-				{strTitle}
-				{passTime}
-			</Space>
-		</Tooltip>
-	);
+  return (
+    <Tooltip title={tooltip} color={color} key={color}>
+      <Space>
+        {icon}
+        {strTitle}
+        {passTime}
+      </Space>
+    </Tooltip>
+  );
 };
 
 export default Widget;

+ 45 - 0
dashboard/src/components/general/Video.tsx

@@ -0,0 +1,45 @@
+import { useRef } from "react";
+import videojs from "video.js";
+import VideoPlayer from "./VideoPlayer";
+
+interface IWidget {
+  src?: string;
+  type?: string;
+}
+export const Widget = ({ src, type }: IWidget) => {
+  const playerRef = useRef<videojs.Player>();
+
+  const handlePlayerReady = (player: videojs.Player) => {
+    if (playerRef.current) {
+      playerRef.current = player;
+      player.on("waiting", () => {
+        console.log("player is waiting");
+      });
+
+      player.on("dispose", () => {
+        console.log("player will dispose");
+      });
+    }
+  };
+
+  return (
+    <VideoPlayer
+      options={{
+        autoplay: true,
+        controls: true,
+        responsive: true,
+        fluid: true,
+        poster: "https://vjs.zencdn.net/v/oceans.png",
+        sources: [
+          {
+            src: src ? src : "",
+            type: type ? type : "video/mp4",
+          },
+        ],
+      }}
+      onReady={handlePlayerReady}
+    />
+  );
+};
+
+export default Widget;

+ 45 - 0
dashboard/src/components/general/VideoModal.tsx

@@ -0,0 +1,45 @@
+import { useState } from "react";
+import { Modal } from "antd";
+import Video from "./Video";
+
+interface IWidget {
+  src?: string;
+  type?: string;
+  trigger?: JSX.Element;
+}
+export const Widget = ({ src, type, trigger }: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        title="Basic Modal"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        width={1000}
+      >
+        <Video src={src} type={type} />
+        <div>
+          src = {src}
+          type = {type}
+        </div>
+      </Modal>
+    </>
+  );
+};
+
+export default Widget;

+ 55 - 0
dashboard/src/components/general/VideoPlayer.tsx

@@ -0,0 +1,55 @@
+import { useRef, useEffect } from "react";
+import videojs from "video.js";
+
+interface IProps {
+  options: videojs.PlayerOptions;
+  onReady: (player: videojs.Player) => void;
+}
+
+const Widget = ({ options, onReady }: IProps) => {
+  const videoRef = useRef<HTMLDivElement>(null);
+  const playerRef = useRef<videojs.Player | null>();
+
+  useEffect(() => {
+    if (!playerRef.current) {
+      const videoElement = document.createElement("video-js");
+
+      videoElement.classList.add("vjs-big-play-centered");
+      if (videoRef.current) {
+        videoRef.current.appendChild(videoElement);
+      }
+
+      const player = (playerRef.current = videojs(videoElement, options, () => {
+        onReady && onReady(player);
+      }));
+    } else {
+      const player = playerRef.current;
+
+      if (options.autoplay !== undefined) {
+        player.autoplay(options.autoplay);
+      }
+      if (options.sources !== undefined) {
+        player.src(options.sources);
+      }
+    }
+  }, [options, playerRef, videoRef, onReady]);
+
+  useEffect(() => {
+    const player = playerRef.current;
+
+    return () => {
+      if (player && !player.isDisposed()) {
+        player.dispose();
+        playerRef.current = null;
+      }
+    };
+  }, [playerRef]);
+
+  return (
+    <div data-vjs-player>
+      <div ref={videoRef} className="video-js" />
+    </div>
+  );
+};
+
+export default Widget;

+ 20 - 0
dashboard/src/components/nut/Home.tsx

@@ -8,11 +8,31 @@ import MarkdownShow from "./MarkdownShow";
 import FontBox from "./FontBox";
 import DemoForm from "./Form";
 import WbwTest from "./WbwTest";
+import CommentList from "../comment/CommentList";
+import TreeTest from "./TreeTest";
 
 const Widget = () => {
+  const data = Array(100)
+    .fill(4)
+    .map((item, id) => {
+      return {
+        id: "dd",
+        content: "评论内容",
+        title: "评论标题" + id,
+        user: {
+          id: "string",
+          nickName: "Visuddhinanda",
+          realName: "Visuddhinanda",
+          avatar: "",
+        },
+      };
+    });
   return (
     <div>
       <h1>Home</h1>
+      <TreeTest />
+      <h2>comment</h2>
+      <CommentList data={data} />
       <h2>wbw</h2>
       <WbwTest />
       <h2>channel picker</h2>

+ 38 - 0
dashboard/src/components/nut/TreeTest.tsx

@@ -0,0 +1,38 @@
+import type { TreeProps } from "antd/es/tree";
+import TocTree from "../article/TocTree";
+import { ListNodeData } from "../article/EditableTree";
+
+const treeData: ListNodeData[] = [
+  {
+    title: "title 1",
+    key: "0-1",
+    level: 1,
+  },
+  {
+    title: "title 2",
+    key: "0-2",
+    level: 2,
+  },
+  {
+    title: "title 1",
+    key: "0-3",
+    level: 2,
+  },
+  {
+    title: "title 1",
+    key: "1-0",
+    level: 1,
+  },
+];
+
+const Widget = () => {
+  const onSelect: TreeProps["onSelect"] = (selectedKeys, info) => {
+    console.log("selected", selectedKeys, info);
+  };
+
+  return (
+    <TocTree onSelect={onSelect} treeData={treeData} expandedKey={["0-3"]} />
+  );
+};
+
+export default Widget;

+ 29 - 0
dashboard/src/components/template/Exercise.tsx

@@ -0,0 +1,29 @@
+import { Button, Card } from "antd";
+
+interface IWidgetExerciseCtl {
+  id?: string;
+  channel?: string;
+  children?: React.ReactNode;
+}
+const ExerciseCtl = ({ id, channel, children }: IWidgetExerciseCtl) => {
+  return (
+    <Card
+      title="练习"
+      extra={<Button type="primary">做练习</Button>}
+      style={{ backgroundColor: "wheat" }}
+    >
+      {children}
+    </Card>
+  );
+};
+
+interface IWidget {
+  props: string;
+  children?: React.ReactNode;
+}
+const Widget = ({ props, children }: IWidget) => {
+  const prop: IWidgetExerciseCtl = JSON.parse(atob(props));
+  return <ExerciseCtl {...prop}>{children}</ExerciseCtl>;
+};
+
+export default Widget;

+ 7 - 3
dashboard/src/components/template/MdTpl.tsx

@@ -1,3 +1,4 @@
+import Exercise from "./Exercise";
 import Note from "./Note";
 import Quote from "./Quote";
 import SentEdit from "./SentEdit";
@@ -9,8 +10,9 @@ import Wd from "./Wd";
 interface IWidgetMdTpl {
   tpl?: string;
   props?: string;
+  children?: React.ReactNode;
 }
-const Widget = ({ tpl, props }: IWidgetMdTpl) => {
+const Widget = ({ tpl, props, children }: IWidgetMdTpl) => {
   switch (tpl) {
     case "term":
       return <Term props={props ? props : ""} />;
@@ -20,12 +22,14 @@ const Widget = ({ tpl, props }: IWidgetMdTpl) => {
       return <SentRead props={props ? props : ""} />;
     case "sentedit":
       return <SentEdit props={props ? props : ""} />;
-	  case "wbw_sent":
-		return <WbwSent props={props ? props : ""} />;
+    case "wbw_sent":
+      return <WbwSent props={props ? props : ""} />;
     case "wd":
       return <Wd props={props ? props : ""} />;
     case "quote":
       return <Quote props={props ? props : ""} />;
+    case "exercise":
+      return <Exercise props={props ? props : ""}>{children}</Exercise>;
     default:
       return <>未定义模版({tpl})</>;
   }

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

@@ -19,6 +19,7 @@ const Widget = ({ data, onDataChange, onClose }: ISentCellEditable) => {
   const intl = useIntl();
   const [value, setValue] = useState(data.content);
   const [saving, setSaving] = useState<boolean>(false);
+
   const save = () => {
     setSaving(true);
     put<ISentenceRequest, ISentenceResponse>(
@@ -62,6 +63,7 @@ const Widget = ({ data, onDataChange, onClose }: ISentCellEditable) => {
         message.error(e.message);
       });
   };
+
   return (
     <div>
       <TextArea

+ 1 - 1
dashboard/src/components/template/SentRead.tsx

@@ -52,7 +52,7 @@ const SentReadFrame = ({
       default:
         break;
     }
-    const _paliCode1 = GetUserSetting("setting.pali.script1", settings);
+    const _paliCode1 = GetUserSetting("setting.pali.script.primary", settings);
     if (typeof _paliCode1 !== "undefined") {
       setPaliCode1(_paliCode1.toString() as TCodeConvertor);
     }

+ 49 - 0
dashboard/src/components/template/Wbw/PaliText.tsx

@@ -0,0 +1,49 @@
+import { useEffect, useState } from "react";
+import { useAppSelector } from "../../../hooks";
+import { settingInfo } from "../../../reducers/setting";
+import { GetUserSetting } from "../../auth/setting/default";
+import { TCodeConvertor } from "../utilities";
+import { roman_to_my, my_to_roman } from "../../code/my";
+import { roman_to_si } from "../../code/si";
+import { roman_to_thai } from "../../code/thai";
+import { roman_to_taitham } from "../../code/tai-tham";
+
+interface IWidget {
+  text?: string;
+  primary?: boolean;
+}
+const Widget = ({ text, primary = true }: IWidget) => {
+  const [paliText, setPaliText] = useState(text);
+  const settings = useAppSelector(settingInfo);
+  useEffect(() => {
+    const _paliCode1 = GetUserSetting("setting.pali.script.primary", settings);
+    if (typeof _paliCode1 === "string") {
+      const paliConvertor = _paliCode1 as TCodeConvertor;
+      //编码转换
+
+      switch (paliConvertor) {
+        case "roman_to_my":
+          setPaliText(roman_to_my(text));
+          break;
+        case "my_to_roman":
+          setPaliText(my_to_roman(text));
+          break;
+        case "roman_to_si":
+          setPaliText(roman_to_si(text));
+          break;
+        case "roman_to_thai":
+          setPaliText(roman_to_thai(text));
+          break;
+        case "roman_to_taitham":
+          setPaliText(roman_to_taitham(text));
+          break;
+        default:
+          setPaliText(text);
+          break;
+      }
+    }
+  }, [settings]);
+  return text ? <span>{paliText}</span> : <></>;
+};
+
+export default Widget;

+ 49 - 12
dashboard/src/components/template/Wbw/WbwCase.tsx

@@ -1,26 +1,19 @@
+import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
-import { Typography, Button } from "antd";
+import { Typography, Button, Space } from "antd";
 import { SwapOutlined } from "@ant-design/icons";
 import type { MenuProps } from "antd";
 import { Dropdown } from "antd";
+import { LoadingOutlined } from "@ant-design/icons";
 
 import { IWbw, TWbwDisplayMode } from "./WbwWord";
 import { PaliReal } from "../../../utils";
 import "./wbw.css";
+import { useAppSelector } from "../../../hooks";
+import { inlineDict as _inlineDict } from "../../../reducers/inline-dict";
 
 const { Text } = Typography;
 
-const items: MenuProps["items"] = [
-  {
-    key: "n+m+sg+nom",
-    label: "n+m+sg+nom",
-  },
-  {
-    key: "un",
-    label: "un",
-  },
-];
-
 interface IWidget {
   data: IWbw;
   display?: TWbwDisplayMode;
@@ -29,6 +22,50 @@ interface IWidget {
 }
 const Widget = ({ data, display, onSplit, onChange }: IWidget) => {
   const intl = useIntl();
+  const defaultMenu: MenuProps["items"] = [
+    {
+      key: "loading",
+      label: (
+        <Space>
+          <LoadingOutlined />
+          {"Loading"}
+        </Space>
+      ),
+    },
+  ];
+  const [items, setItems] = useState<MenuProps["items"]>(defaultMenu);
+
+  const inlineDict = useAppSelector(_inlineDict);
+  useEffect(() => {
+    if (inlineDict.wordIndex.includes(data.word.value)) {
+      const result = inlineDict.wordList.filter(
+        (word) => word.word === data.word.value
+      );
+      //查重
+      //TODO 加入信心指数并排序
+      let myMap = new Map<string, number>();
+      let factors: string[] = [];
+      for (const iterator of result) {
+        myMap.set(iterator.type + "$" + iterator.grammar, 1);
+      }
+      myMap.forEach((value, key, map) => {
+        factors.push(key);
+      });
+
+      const menu = factors.map((item) => {
+        const arrItem: string[] = item.replaceAll(".", "").split("$");
+        let noNull = arrItem.filter((item) => item !== "");
+        const key = noNull.join("$");
+        noNull.forEach((item, index, arr) => {
+          arr[index] = intl.formatMessage({
+            id: `dict.fields.type.${item}.short.label`,
+          });
+        });
+        return { key: key, label: noNull.join(" ") };
+      });
+      setItems(menu);
+    }
+  }, [inlineDict]);
   const onClick: MenuProps["onClick"] = (e) => {
     console.log("click ", e);
     if (typeof onChange !== "undefined") {

+ 15 - 0
dashboard/src/components/template/Wbw/WbwDetail.tsx

@@ -10,6 +10,8 @@ import WbwDetailBookMark from "./WbwDetailBookMark";
 import WbwDetailNote from "./WbwDetailNote";
 import WbwDetailAdvance from "./WbwDetailAdvance";
 import { LockIcon, UnLockIcon } from "../../../assets/icon";
+import { UploadFile } from "antd/es/upload/interface";
+import { IAttachmentResponse } from "../../api/Attachments";
 
 interface IWidget {
   data: IWbw;
@@ -132,6 +134,19 @@ const Widget = ({ data, onClose, onSave }: IWidget) => {
                   onChange={(e: IWbwField) => {
                     fieldChanged(e.field, e.value);
                   }}
+                  onUpload={(fileList: UploadFile<IAttachmentResponse>[]) => {
+                    let mData = currWbwData;
+                    mData.attachments = fileList.map((item) => {
+                      return {
+                        uid: item.uid,
+                        name: item.name,
+                        size: item.size,
+                        type: item.type,
+                        url: item.response?.data.url,
+                      };
+                    });
+                    setCurrWbwData(mData);
+                  }}
                 />
               </div>
             ),

+ 12 - 2
dashboard/src/components/template/Wbw/WbwDetailAdvance.tsx

@@ -1,4 +1,6 @@
 import { Input, Divider } from "antd";
+import { UploadFile } from "antd/es/upload/interface";
+import { IAttachmentResponse } from "../../api/Attachments";
 import WbwDetailUpload from "./WbwDetailUpload";
 
 import { IWbw } from "./WbwWord";
@@ -6,8 +8,9 @@ import { IWbw } from "./WbwWord";
 interface IWidget {
   data: IWbw;
   onChange?: Function;
+  onUpload?: Function;
 }
-const Widget = ({ data, onChange }: IWidget) => {
+const Widget = ({ data, onChange, onUpload }: IWidget) => {
   const onWordChange = (
     e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
   ) => {
@@ -43,7 +46,14 @@ const Widget = ({ data, onChange }: IWidget) => {
       <Divider>附件</Divider>
       <div></div>
       <div>
-        <WbwDetailUpload />
+        <WbwDetailUpload
+          data={data}
+          onUpload={(fileList: UploadFile<IAttachmentResponse>[]) => {
+            if (typeof onUpload !== "undefined") {
+              onUpload(fileList);
+            }
+          }}
+        />
       </div>
     </>
   );

+ 36 - 19
dashboard/src/components/template/Wbw/WbwDetailUpload.tsx

@@ -2,28 +2,45 @@ import { useIntl } from "react-intl";
 import { UploadOutlined } from "@ant-design/icons";
 import type { UploadProps } from "antd";
 import { Button, message, Upload } from "antd";
-import { API_HOST } from "../../../request";
 
-const props: UploadProps = {
-  name: "file",
-  action: `${API_HOST}/api/v2/upload`,
-  headers: {
-    authorization: "authorization-text",
-  },
-  onChange(info) {
-    if (info.file.status !== "uploading") {
-      console.log(info.file, info.fileList);
-    }
-    if (info.file.status === "done") {
-      message.success(`${info.file.name} file uploaded successfully`);
-    } else if (info.file.status === "error") {
-      message.error(`${info.file.name} file upload failed.`);
-    }
-  },
-};
+import { API_HOST } from "../../../request";
+import { get as getToken } from "../../../reducers/current-user";
+import { IWbw } from "./WbwWord";
 
-const Widget = () => {
+interface IWidget {
+  data: IWbw;
+  onUpload?: Function;
+}
+const Widget = ({ data, onUpload }: IWidget) => {
   const intl = useIntl();
+
+  const props: UploadProps = {
+    name: "file",
+    action: `${API_HOST}/api/v2/attachments`,
+    headers: {
+      Authorization: `Bearer ${getToken()}`,
+    },
+    defaultFileList: data.attachments,
+    onChange(info) {
+      console.log("onchange", info);
+      if (typeof onUpload !== "undefined") {
+        onUpload(info.fileList);
+      }
+      if (info.file.status !== "uploading") {
+        console.log(info.file, info.fileList);
+      }
+      if (info.file.status === "done") {
+        message.success(`${info.file.name} file uploaded successfully`);
+        console.log("file info", info);
+      } else if (info.file.status === "error") {
+        message.error(`${info.file.name} file upload failed.`);
+      }
+    },
+    onRemove(file) {
+      console.log("remove", file);
+    },
+  };
+
   return (
     <Upload {...props}>
       <Button icon={<UploadOutlined />}>

+ 121 - 21
dashboard/src/components/template/Wbw/WbwMeaningSelect.tsx

@@ -1,6 +1,10 @@
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
 import { Collapse, Tag } from "antd";
 
 import { IWbw } from "./WbwWord";
+import { useAppSelector } from "../../../hooks";
+import { inlineDict as _inlineDict } from "../../../reducers/inline-dict";
 
 const { Panel } = Collapse;
 
@@ -8,10 +12,14 @@ interface IMeaning {
   text: string;
   count: number;
 }
-interface IDict {
+interface ICase {
   name: string;
   meaning: IMeaning[];
 }
+interface IDict {
+  name: string;
+  case: ICase[];
+}
 interface IParent {
   word: string;
   dict: IDict[];
@@ -22,6 +30,90 @@ interface IWidget {
   onSelect?: Function;
 }
 const Widget = ({ data, onSelect }: IWidget) => {
+  const intl = useIntl();
+  const inlineDict = useAppSelector(_inlineDict);
+  const [parent, setParent] = useState<IParent[]>();
+
+  useEffect(() => {
+    if (inlineDict.wordIndex.includes(data.word.value)) {
+      let mParent: IParent[] = [];
+      const word1 = data.word.value;
+      const result1 = inlineDict.wordList.filter((word) => word.word === word1);
+      mParent.push({ word: word1, dict: [] });
+      const indexParent = mParent.findIndex((item) => item.word === word1);
+      result1.forEach((value, index, array) => {
+        let indexDict = mParent[indexParent].dict.findIndex(
+          (item) => item.name === value.dict_id
+        );
+        if (indexDict === -1) {
+          //没找到,添加一个dict
+          mParent[indexParent].dict.push({ name: value.dict_id, case: [] });
+          indexDict = mParent[indexParent].dict.findIndex(
+            (item) => item.name === value.dict_id
+          );
+        }
+        const wordType = value.type === "" ? "null" : value.type;
+        let indexCase = mParent[indexParent].dict[indexDict].case.findIndex(
+          (item) => item.name === wordType
+        );
+        if (indexCase === -1) {
+          mParent[indexParent].dict[indexDict].case.push({
+            name: wordType,
+            meaning: [],
+          });
+          indexCase = mParent[indexParent].dict[indexDict].case.findIndex(
+            (item) => item.name === wordType
+          );
+        }
+        console.log("indexCase", indexCase, value.mean);
+        if (value.mean && value.mean.trim() !== "") {
+          for (const valueMeaning of value.mean.trim().split("$")) {
+            if (valueMeaning.trim() !== "") {
+              const mValue = valueMeaning.trim();
+              console.log("meaning", mValue);
+              console.log(
+                "in",
+                mParent[indexParent].dict[indexDict].case[indexCase].meaning
+              );
+              let indexMeaning = mParent[indexParent].dict[indexDict].case[
+                indexCase
+              ].meaning.findIndex((itemMeaning) => itemMeaning.text === mValue);
+
+              console.log("indexMeaning", indexMeaning);
+              let indexM: number;
+              const currMeanings =
+                mParent[indexParent].dict[indexDict].case[indexCase].meaning;
+              for (indexM = 0; indexM < currMeanings.length; indexM++) {
+                console.log("index", indexM);
+                console.log("array", currMeanings);
+                console.log("word1", currMeanings[indexM].text);
+                console.log("word2", mValue);
+                if (currMeanings[indexM].text === mValue) {
+                  break;
+                }
+              }
+              console.log("new index", indexM);
+              if (indexMeaning === -1) {
+                mParent[indexParent].dict[indexDict].case[
+                  indexCase
+                ].meaning.push({
+                  text: mValue,
+                  count: 1,
+                });
+              } else {
+                mParent[indexParent].dict[indexDict].case[indexCase].meaning[
+                  indexMeaning
+                ].count++;
+              }
+            }
+          }
+        }
+      });
+
+      setParent(mParent);
+    }
+  }, [inlineDict]);
+  /*
   const meaning: IMeaning[] = Array.from(Array(10).keys()).map((item) => {
     return { text: "意思" + item, count: item };
   });
@@ -31,35 +123,43 @@ const Widget = ({ data, onSelect }: IWidget) => {
   const parent: IParent[] = Array.from(Array(3).keys()).map((item) => {
     return { word: data.word.value + item, dict: dict };
   });
+  */
   return (
     <div>
       <Collapse defaultActiveKey={["0"]}>
-        {parent.map((item, id) => {
+        {parent?.map((item, id) => {
           return (
             <Panel header={item.word} style={{ padding: 0 }} key={id}>
               {item.dict.map((itemDict, idDict) => {
                 return (
                   <div key={idDict}>
                     <div>{itemDict.name}</div>
-                    <div>
-                      {itemDict.meaning.map((itemMeaning, idMeaning) => {
-                        return (
-                          <Tag
-                            key={idMeaning}
-                            onClick={(
-                              e: React.MouseEvent<HTMLAnchorElement>
-                            ) => {
-                              e.preventDefault();
-                              if (typeof onSelect !== "undefined") {
-                                onSelect(itemMeaning.text);
-                              }
-                            }}
-                          >
-                            {itemMeaning.text}-{itemMeaning.count}
-                          </Tag>
-                        );
-                      })}
-                    </div>
+                    {itemDict.case.map((itemCase, idCase) => {
+                      return (
+                        <div key={idCase}>
+                          <div>{itemCase.name}</div>
+                          <div>
+                            {itemCase.meaning.map((itemMeaning, idMeaning) => {
+                              return (
+                                <Tag
+                                  key={idMeaning}
+                                  onClick={(
+                                    e: React.MouseEvent<HTMLAnchorElement>
+                                  ) => {
+                                    e.preventDefault();
+                                    if (typeof onSelect !== "undefined") {
+                                      onSelect(itemMeaning.text);
+                                    }
+                                  }}
+                                >
+                                  {itemMeaning.text}-{itemMeaning.count}
+                                </Tag>
+                              );
+                            })}
+                          </div>
+                        </div>
+                      );
+                    })}
                   </div>
                 );
               })}

+ 104 - 19
dashboard/src/components/template/Wbw/WbwPali.tsx

@@ -1,44 +1,58 @@
 import { useState } from "react";
-import { Popover, Typography } from "antd";
-import { TagTwoTone, InfoCircleOutlined } from "@ant-design/icons";
+import { Popover, Typography, Button, Space } from "antd";
+import {
+  TagTwoTone,
+  InfoCircleOutlined,
+  CommentOutlined,
+} from "@ant-design/icons";
 
+import "./wbw.css";
 import WbwDetail from "./WbwDetail";
-import { IWbw } from "./WbwWord";
+import { IWbw, TWbwDisplayMode } from "./WbwWord";
 import { bookMarkColor } from "./WbwDetailBookMark";
-import "./wbw.css";
 import { PaliReal } from "../../../utils";
+import WbwVideoButton from "./WbwVideoButton";
+import CommentBox from "../../comment/CommentBox";
+import PaliText from "./PaliText";
+
 const { Paragraph } = Typography;
 interface IWidget {
   data: IWbw;
+  display?: TWbwDisplayMode;
   onSave?: Function;
 }
-const Widget = ({ data, onSave }: IWidget) => {
-  const [open, setOpen] = useState(false);
+const Widget = ({ data, display, onSave }: IWidget) => {
+  const [click, setClicked] = useState(false);
   const [paliColor, setPaliColor] = useState("unset");
+  const [isHover, setIsHover] = useState(false);
+  const [hasComment, setHasComment] = useState(data.hasComment);
+
+  const handleClickChange = (open: boolean) => {
+    if (open) {
+      setPaliColor("lightblue");
+    } else {
+      setPaliColor("unset");
+    }
+    setClicked(open);
+  };
+
   const wbwDetail = (
     <WbwDetail
       data={data}
       onClose={() => {
         setPaliColor("unset");
-        setOpen(false);
+        setClicked(false);
       }}
       onSave={(e: IWbw) => {
         if (typeof onSave !== "undefined") {
           onSave(e);
-          setOpen(false);
+          setClicked(false);
           setPaliColor("unset");
         }
       }}
     />
   );
-  const handleClickChange = (open: boolean) => {
-    setOpen(open);
-    if (open) {
-      setPaliColor("lightblue");
-    } else {
-      setPaliColor("unset");
-    }
-  };
+
   const noteIcon = data.note ? (
     <Popover content={data.note.value} placement="bottom">
       <InfoCircleOutlined style={{ color: "blue" }} />
@@ -50,6 +64,22 @@ const Widget = ({ data, onSave }: IWidget) => {
     ? bookMarkColor[data.bookMarkColor.value]
     : "white";
 
+  //生成视频播放按钮
+  const videoList = data.attachments?.filter((item) =>
+    item.type?.includes("video")
+  );
+  const videoIcon = videoList ? (
+    <WbwVideoButton
+      video={videoList?.map((item) => {
+        return {
+          url: item.url ? item.url : "",
+          type: item.type,
+          title: item.name,
+        };
+      })}
+    />
+  ) : undefined;
+
   const bookMarkIcon = data.bookMarkText ? (
     <Popover
       content={<Paragraph copyable>{data.bookMarkText.value}</Paragraph>}
@@ -76,25 +106,80 @@ const Widget = ({ data, onSave }: IWidget) => {
         borderRadius: 5,
       }}
     >
-      {data.word.value}
+      {<PaliText text={data.word.value} />}
     </span>
   );
 
+  let commentShellStyle: React.CSSProperties = {
+    display: "inline-block",
+  };
+  let commentIconStyle: React.CSSProperties = {
+    cursor: "pointer",
+  };
+
+  if (display === "block") {
+    commentIconStyle = {
+      cursor: "pointer",
+      visibility: isHover || hasComment ? "visible" : "hidden",
+    };
+  } else {
+    if (!hasComment) {
+      commentShellStyle = {
+        display: "inline-block",
+        position: "absolute",
+        padding: 8,
+        marginTop: "-1.5em",
+        marginLeft: "-2em",
+      };
+      commentShellStyle = {
+        visibility: isHover ? "visible" : "hidden",
+        cursor: "pointer",
+      };
+    }
+  }
+
+  const discussionIcon = (
+    <div style={commentShellStyle}>
+      <CommentBox
+        resId={data.uid}
+        resType="wbw"
+        trigger={<CommentOutlined style={commentIconStyle} />}
+        onCommentCountChange={(count: number) => {
+          if (count > 0) {
+            setHasComment(true);
+          } else {
+            setHasComment(false);
+          }
+        }}
+      />
+    </div>
+  );
+
   if (typeof data.real !== "undefined" && PaliReal(data.real.value) !== "") {
     //非标点符号
     return (
-      <div className="pali_shell">
+      <div
+        className="pali_shell"
+        onMouseEnter={() => {
+          setIsHover(true);
+        }}
+        onMouseLeave={() => {
+          setIsHover(false);
+        }}
+      >
         <Popover
           content={wbwDetail}
           placement="bottom"
           trigger="click"
-          open={open}
+          open={click}
           onOpenChange={handleClickChange}
         >
           {paliWord}
         </Popover>
+        {videoIcon}
         {noteIcon}
         {bookMarkIcon}
+        {discussionIcon}
       </div>
     );
   } else {

+ 28 - 0
dashboard/src/components/template/Wbw/WbwVideoButton.tsx

@@ -0,0 +1,28 @@
+import { VideoCameraOutlined } from "@ant-design/icons";
+import VideoModal from "../../general/VideoModal";
+
+export interface IVideo {
+  url?: string;
+  type?: string;
+  title?: string;
+}
+interface IWidget {
+  video: IVideo[];
+}
+const Widget = ({ video }: IWidget) => {
+  const url = video ? video[0].url : "";
+  const src: string = process.env.REACT_APP_WEB_HOST
+    ? process.env.REACT_APP_WEB_HOST
+    : "";
+  return video ? (
+    <VideoModal
+      src={src + "/" + url}
+      type={video[0].type}
+      trigger={<VideoCameraOutlined />}
+    />
+  ) : (
+    <></>
+  );
+};
+
+export default Widget;

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

@@ -1,4 +1,5 @@
 import { useState, useEffect, useRef } from "react";
+import type { UploadFile } from "antd/es/upload/interface";
 
 import { useAppSelector } from "../../../hooks";
 import { add, wordIndex } from "../../../reducers/inline-dict";
@@ -56,6 +57,7 @@ interface WbwElement3 {
   status: WbwStatus;
 }
 export interface IWbw {
+  uid?: string;
   word: WbwElement;
   real?: WbwElement;
   meaning?: WbwElement2;
@@ -72,6 +74,8 @@ export interface IWbw {
   bookMarkText?: WbwElement;
   locked?: boolean;
   confidence: number;
+  attachments?: UploadFile[];
+  hasComment?: boolean;
 }
 export interface IWbwFields {
   meaning?: boolean;
@@ -139,7 +143,7 @@ const Widget = ({
     }
     get<IApiResponseDictList>(`/v2/wbwlookup?word=${word}`).then((json) => {
       console.log("lookup ok", json.data.count);
-      //扫描结果将结果按照词头分开
+      //存储到redux
       store.dispatch(add(json.data.rows));
     });
 
@@ -171,6 +175,7 @@ const Widget = ({
       >
         <WbwPali
           data={wordData}
+          display={display}
           onSave={(e: IWbw) => {
             console.log("save", e);
             const newData: IWbw = JSON.parse(JSON.stringify(e));
@@ -236,7 +241,7 @@ const Widget = ({
               }}
               onChange={(e: string) => {
                 const newData: IWbw = JSON.parse(JSON.stringify(wordData));
-                newData.case = { value: e.split("+"), status: 5 };
+                newData.case = { value: e.split("$"), status: 5 };
                 setWordData(newData);
               }}
             />

+ 2 - 2
dashboard/src/components/template/utilities.ts

@@ -89,9 +89,9 @@ export function XmlToReact(
         case 2: //attribute node
           return [];
         case 3: //text node
-          let textValue = value.nodeValue;
+          let textValue = value.nodeValue ? value.nodeValue : undefined;
           //编码转换
-          if (typeof convertor !== "undefined") {
+          if (typeof convertor !== "undefined" && textValue !== null) {
             switch (convertor) {
               case "roman_to_my":
                 textValue = roman_to_my(textValue);

+ 10 - 0
dashboard/src/locales/zh-Hans/dict/index.ts

@@ -132,8 +132,18 @@ const items = {
   "dict.fields.type.un.short.label": "连音",
   "dict.fields.type.none.label": "无",
   "dict.fields.type.none.short.label": "",
+  "dict.fields.type.null.label": "空",
+  "dict.fields.type.null.short.label": "",
   "dict.fields.type.?.label": "?",
   "dict.fields.type.?.short.label": "?",
+  "dict.fields.type.ti:base.label": "三性词干",
+  "dict.fields.type.ti:base.short.label": "三性词干",
+  "dict.fields.type.n:base.label": "名词干",
+  "dict.fields.type.n:base.short.label": "名词干",
+  "dict.fields.type.v:base.label": "动词干",
+  "dict.fields.type.v:base.short.label": "动词干",
+  "dict.fields.type.adj:base.label": "形词干",
+  "dict.fields.type.ajd:base.short.label": "形词干",
 };
 
 export default items;

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

@@ -12,6 +12,7 @@ const items = {
   "forms.fields.subtitle.label": "副标题",
   "forms.fields.summary.label": "简介",
   "forms.fields.content.label": "内容",
+  "forms.fields.content.placeholder": "请输入内容",
   "forms.fields.tag.label": "标签",
   "forms.fields.power.label": "权限",
   "forms.fields.type.label": "类型",

+ 4 - 4
dashboard/src/locales/zh-Hans/setting/index.ts

@@ -9,10 +9,10 @@ const items = {
   "setting.layout.paragraph.description": "逐段或逐句对照(仅阅读模式)",
   "setting.layout.paragraph.paragraph.label": "逐段",
   "setting.layout.paragraph.sentence.label": "逐句",
-  "setting.pali.script1.label": "第一巴利脚本",
-  "setting.pali.script1.description": "首要的巴利语脚本",
-  "setting.pali.script2.label": "第二巴利脚本",
-  "setting.pali.script2.description": "第二个巴利语脚本",
+  "setting.pali.script.primary.label": "巴利脚本",
+  "setting.pali.script.primary.description": "首要的巴利语脚本",
+  "setting.pali.script.secondary.label": "第二巴利脚本",
+  "setting.pali.script.secondary.description": "第二个巴利语脚本",
   "setting.pali.script.rome.label": "罗马巴利",
   "setting.pali.script.my.label": "缅文字母",
   "setting.pali.script.si.label": "新哈拉字母",

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

@@ -18,6 +18,7 @@ import ProTabs from "../../../components/article/ProTabs";
  */
 const Widget = () => {
   const { type, id, mode = "read" } = useParams(); //url 参数
+  console.log("mode", mode);
   const [articleMode, setArticleMode] = useState<ArticleMode>(
     mode as ArticleMode
   );
@@ -56,7 +57,7 @@ const Widget = () => {
             />
           </ArticleCard>
         </div>
-        <div style={{ flex: 5 }} ref={box}>
+        <div style={{ flex: 5, display: "none" }} ref={box}>
           <ArticleTabs onClose={closeCol} />
         </div>
       </div>

+ 2 - 2
dashboard/src/pages/studio/anthology/edit.tsx

@@ -13,8 +13,8 @@ import {
   IAnthologyDataRequest,
   IAnthologyResponse,
 } from "../../../components/api/Article";
-import EditableTree from "../../../components/studio/EditableTree";
-import type { ListNodeData } from "../../../components/studio/EditableTree";
+import EditableTree from "../../../components/article/EditableTree";
+import type { ListNodeData } from "../../../components/article/EditableTree";
 import LangSelect from "../../../components/general/LangSelect";
 import PublicitySelect from "../../../components/studio/PublicitySelect";
 import GoBack from "../../../components/studio/GoBack";

+ 4 - 4
dashboard/src/reducers/inline-dict.ts

@@ -1,7 +1,7 @@
 import { createSlice, PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
-import { IDictDataRequest } from "../components/api/Dict";
+import { IApiResponseDictData } from "../components/api/Dict";
 
 /**
  * 在查询字典后,将查询结果放入map
@@ -9,7 +9,7 @@ import { IDictDataRequest } from "../components/api/Dict";
  * value: 查询到的单词列表
  */
 interface IState {
-  wordList: IDictDataRequest[];
+  wordList: IApiResponseDictData[];
   wordIndex: string[];
 }
 
@@ -22,7 +22,7 @@ export const slice = createSlice({
   name: "inline-dict",
   initialState,
   reducers: {
-    add: (state, action: PayloadAction<IDictDataRequest[]>) => {
+    add: (state, action: PayloadAction<IApiResponseDictData[]>) => {
       let words: string[] = [];
       let newWordData = new Array(...state.wordList);
       let newIndexData = new Array(...state.wordIndex);
@@ -47,7 +47,7 @@ export const { add } = slice.actions;
 
 export const inlineDict = (state: RootState): IState => state.inlineDict;
 
-export const wordList = (state: RootState): IDictDataRequest[] =>
+export const wordList = (state: RootState): IApiResponseDictData[] =>
   state.inlineDict.wordList;
 export const wordIndex = (state: RootState): string[] =>
   state.inlineDict.wordIndex;

+ 2 - 0
openapi/public/assets/protocol/main.yaml

@@ -17,3 +17,5 @@ paths:
     $ref: "./resources/channel/index.yaml"
   /palitext:
     $ref: "./resources/corpus/pali-text/index.yaml"
+  /discussion:
+    $ref: "./resources/discussion/index.yaml"

+ 3 - 0
openapi/public/assets/protocol/resources/discussion/discussion.yaml

@@ -0,0 +1,3 @@
+properties:
+  title:
+    type: string

+ 28 - 0
openapi/public/assets/protocol/resources/discussion/index.yaml

@@ -0,0 +1,28 @@
+get:
+  summary: Returns a list of discussions.
+  tags:
+    - discussion
+  description: 返回多行数据。支持关键字搜索,分页,排序
+  parameters:
+    - name: view
+      in: path
+      required: true
+      description: 查询的视图。如:resId  parent 等
+      schema:
+        type: string
+        enum:
+          - resId
+          - parent
+  responses:
+    "200": # status code
+      description: A JSON array of user names
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              ok:
+                type: boolean
+              message:
+                type: string
+$ref: "./store.yaml"

+ 39 - 0
openapi/public/assets/protocol/resources/discussion/store.yaml

@@ -0,0 +1,39 @@
+post:
+  summary: 新建一条discussion.
+  tags:
+    - discussion
+  description:
+  parameters:
+    - name: title
+      in: path
+      required: false
+      description: title of discussion 建立新问题时, title为必填项目
+      schema:
+        type: string
+    - name: content
+      in: path
+      required: false
+      description: content of discussion
+      schema:
+        type: string
+    - name: parent
+      in: path
+      required: false
+      description: 回答的问题的id. 建立问题时无需此参数。建立回答时需要。
+      schema:
+        type: uuid
+  responses:
+    "200": # status code
+      description: A JSON array of user names
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              ok:
+                type: boolean
+              message:
+                type: string
+              data:
+                type: object
+                $ref: "./discussion.yaml"