소스 검색

Merge pull request #1128 from visuddhinanda/agile

自动查词
visuddhinanda 2 년 전
부모
커밋
1d849ca6dc
100개의 변경된 파일3681개의 추가작업 그리고 780개의 파일을 삭제
  1. 1 1
      dashboard/package.json
  2. 2 0
      dashboard/src/App.css
  3. 27 6
      dashboard/src/Router.tsx
  4. 43 14
      dashboard/src/assets/library/images/wikipali_logo_library.svg
  5. 59 0
      dashboard/src/components/admin/HeadBar.tsx
  6. 57 0
      dashboard/src/components/admin/LeftSider.tsx
  7. 41 0
      dashboard/src/components/admin/relation/CaseSelect.tsx
  8. 99 0
      dashboard/src/components/admin/relation/DataImport.tsx
  9. 111 0
      dashboard/src/components/admin/relation/NissayaEndingEdit.tsx
  10. 127 0
      dashboard/src/components/admin/relation/RelationEdit.tsx
  11. 108 27
      dashboard/src/components/anthology/AnthologyList.tsx
  12. 4 2
      dashboard/src/components/anthology/AnthologyModal.tsx
  13. 52 0
      dashboard/src/components/anthology/AnthologySelect.tsx
  14. 1 1
      dashboard/src/components/anthology/AnthologyTocTree.tsx
  15. 2 6
      dashboard/src/components/anthology/EditableTocTree.tsx
  16. 9 1
      dashboard/src/components/api/Article.ts
  17. 1 0
      dashboard/src/components/api/Auth.ts
  18. 1 1
      dashboard/src/components/api/Channel.ts
  19. 19 5
      dashboard/src/components/api/Corpus.ts
  20. 8 6
      dashboard/src/components/api/Course.ts
  21. 3 0
      dashboard/src/components/api/Dict.ts
  22. 12 3
      dashboard/src/components/api/Group.ts
  23. 14 4
      dashboard/src/components/api/Share.ts
  24. 15 6
      dashboard/src/components/api/Term.ts
  25. 46 0
      dashboard/src/components/article/AddToAnthology.tsx
  26. 1 5
      dashboard/src/components/article/AnthologStudioList.tsx
  27. 5 1
      dashboard/src/components/article/AnthologyCard.tsx
  28. 11 9
      dashboard/src/components/article/AnthologyDetail.tsx
  29. 0 1
      dashboard/src/components/article/AnthologyInfoEdit.tsx
  30. 55 34
      dashboard/src/components/article/AnthologyList.tsx
  31. 173 118
      dashboard/src/components/article/Article.tsx
  32. 4 1
      dashboard/src/components/article/ArticleCreate.tsx
  33. 11 13
      dashboard/src/components/article/ArticleView.tsx
  34. 23 4
      dashboard/src/components/article/EditableTree.tsx
  35. 3 3
      dashboard/src/components/article/MainMenu.tsx
  36. 3 3
      dashboard/src/components/article/TermShell.tsx
  37. 22 10
      dashboard/src/components/article/TocTree.tsx
  38. 1 1
      dashboard/src/components/article/ToolButton.tsx
  39. 131 0
      dashboard/src/components/article/ToolButtonDiscussion.tsx
  40. 2 1
      dashboard/src/components/auth/StudioName.tsx
  41. 2 0
      dashboard/src/components/auth/setting/SettingArticle.tsx
  42. 96 28
      dashboard/src/components/auth/setting/SettingItem.tsx
  43. 45 6
      dashboard/src/components/auth/setting/default.ts
  44. 19 12
      dashboard/src/components/channel/ChannelPickerTable.tsx
  45. 72 0
      dashboard/src/components/channel/ChannelSelect.tsx
  46. 15 14
      dashboard/src/components/channel/ChannelSentDiff.tsx
  47. 248 0
      dashboard/src/components/channel/ChapterInChannelList.tsx
  48. 52 45
      dashboard/src/components/channel/CopyToStep.tsx
  49. 56 0
      dashboard/src/components/channel/StudioSelect.tsx
  50. 3 0
      dashboard/src/components/comment/CommentBox.tsx
  51. 34 7
      dashboard/src/components/comment/CommentCreate.tsx
  52. 0 2
      dashboard/src/components/comment/CommentEdit.tsx
  53. 8 8
      dashboard/src/components/comment/CommentItem.tsx
  54. 1 1
      dashboard/src/components/comment/CommentList.tsx
  55. 4 3
      dashboard/src/components/comment/CommentListCard.tsx
  56. 2 1
      dashboard/src/components/comment/CommentShow.tsx
  57. 2 1
      dashboard/src/components/comment/CommentTopicChildren.tsx
  58. 1 1
      dashboard/src/components/comment/CommentTopicInfo.tsx
  59. 97 30
      dashboard/src/components/corpus/BookTree.tsx
  60. 91 55
      dashboard/src/components/corpus/BookTreeList.tsx
  61. 27 11
      dashboard/src/components/corpus/BookViewer.tsx
  62. 49 0
      dashboard/src/components/corpus/ChapterAppendTag.tsx
  63. 5 5
      dashboard/src/components/corpus/ChapterCard.tsx
  64. 4 4
      dashboard/src/components/corpus/ChapterFilter.tsx
  65. 8 6
      dashboard/src/components/corpus/ChapterFilterLang.tsx
  66. 48 30
      dashboard/src/components/corpus/ChapterInChannel.tsx
  67. 13 15
      dashboard/src/components/corpus/ChapterList.tsx
  68. 64 0
      dashboard/src/components/corpus/ChapterTag.tsx
  69. 71 50
      dashboard/src/components/corpus/ChapterTagList.tsx
  70. 13 7
      dashboard/src/components/corpus/PaliChapterCard.tsx
  71. 3 2
      dashboard/src/components/corpus/PaliChapterChannelList.tsx
  72. 3 2
      dashboard/src/components/corpus/PaliChapterListByPara.tsx
  73. 20 17
      dashboard/src/components/corpus/PaliChapterListByTag.tsx
  74. 59 0
      dashboard/src/components/corpus/Recent.tsx
  75. 128 0
      dashboard/src/components/corpus/RelatedPara.tsx
  76. 24 13
      dashboard/src/components/corpus/TocPath.tsx
  77. 1 1
      dashboard/src/components/corpus/TocStyleSelect.tsx
  78. 79 0
      dashboard/src/components/course/AcceptCourse.tsx
  79. 79 0
      dashboard/src/components/course/AcceptNotCourse.tsx
  80. 1 0
      dashboard/src/components/course/AddMember.tsx
  81. 17 9
      dashboard/src/components/course/CourseHead.tsx
  82. 6 7
      dashboard/src/components/course/CourseIntro.tsx
  83. 1 1
      dashboard/src/components/course/CourseList.tsx
  84. 1 1
      dashboard/src/components/course/CourseMember.tsx
  85. 56 9
      dashboard/src/components/course/CourseMemberList.tsx
  86. 25 6
      dashboard/src/components/course/JoinCourse.tsx
  87. 15 10
      dashboard/src/components/course/LeaveCourse.tsx
  88. 2 2
      dashboard/src/components/course/SelectChannel.tsx
  89. 98 0
      dashboard/src/components/course/SignUp.tsx
  90. 159 0
      dashboard/src/components/course/Status.tsx
  91. 1 1
      dashboard/src/components/dict/CaseList.tsx
  92. 228 0
      dashboard/src/components/dict/Community.tsx
  93. 1 1
      dashboard/src/components/dict/Compound.tsx
  94. 4 4
      dashboard/src/components/dict/DictContent.tsx
  95. 3 6
      dashboard/src/components/dict/DictSearch.tsx
  96. 0 2
      dashboard/src/components/dict/Dictionary.tsx
  97. 15 13
      dashboard/src/components/dict/GrammarPop.tsx
  98. 62 19
      dashboard/src/components/dict/MyCreate.tsx
  99. 2 8
      dashboard/src/components/dict/SearchVocabulary.tsx
  100. 131 17
      dashboard/src/components/dict/SelectCase.tsx

+ 1 - 1
dashboard/package.json

@@ -25,7 +25,7 @@
     "@types/react-pdf": "^5.7.4",
     "@types/video.js": "^7.3.50",
     "@uiw/react-md-editor": "^3.19.7",
-    "antd": "^4.24.2",
+    "antd": "^4.24.10",
     "dayjs": "^1.11.6",
     "diff": "^5.1.0",
     "dinero.js": "^2.0.0-alpha.9",

+ 2 - 0
dashboard/src/App.css

@@ -8,4 +8,6 @@
 body {
   margin: 0;
   padding: 0;
+  font-family: "Noto Sans", "NotoSans-ExtraLight", "Noto Sans SC",
+    "Noto Sans TC", "Noto Sans Myanmar", "ATaiThamKHNewV3-Normal";
 }

+ 27 - 6
dashboard/src/Router.tsx

@@ -18,17 +18,20 @@ import NutSwitchLanguage from "./pages/nut/switch-languages";
 import NutHome from "./pages/nut";
 
 import AdminHome from "./pages/admin";
+import AdminRelation from "./pages/admin/relation";
+import AdminRelationList from "./pages/admin/relation/list";
+import AdminNissayaEnding from "./pages/admin/nissaya-ending";
+import AdminNissayaEndingList from "./pages/admin/nissaya-ending/list";
+
 import LibraryHome from "./pages/library";
 import LibraryCommunity from "./pages/library/community";
 import LibraryCommunityList from "./pages/library/community/list";
-import LibraryCommunityRecent from "./pages/library/community/recent";
 import LibraryPalicanon from "./pages/library/palicanon";
 import LibraryPalicanonByPath from "./pages/library/palicanon/bypath";
 import LibraryPalicanonChapter from "./pages/library/palicanon/chapter";
 import LibraryCourse from "./pages/library/course";
 import LibraryCourseList from "./pages/library/course/list";
 import LibraryCourseShow from "./pages/library/course/course";
-import LibraryLessonShow from "./pages/library/course/lesson";
 
 import LibraryTerm from "./pages/library/term/show";
 import LibraryDict from "./pages/library/dict";
@@ -50,11 +53,15 @@ import LibraryDiscussion from "./pages/library/discussion";
 import LibraryDiscussionList from "./pages/library/discussion/list";
 import LibraryDiscussionTopic from "./pages/library/discussion/topic";
 
+import LibrarySearch from "./pages/library/search";
+import LibrarySearchKey from "./pages/library/search/search";
+
 import Studio from "./pages/studio";
 import StudioHome from "./pages/studio/home";
 
 import StudioPalicanon from "./pages/studio/palicanon";
 import StudioRecent from "./pages/studio/recent";
+import StudioRecentList from "./pages/studio/recent/list";
 
 import StudioChannel from "./pages/studio/channel";
 import StudioChannelList from "./pages/studio/channel/list";
@@ -97,7 +104,15 @@ const Widget = () => {
   return (
     <ConfigProvider prefixCls={theme}>
       <Routes>
-        <Route path="admin" element={<AdminHome />} />
+        <Route path="admin" element={<AdminHome />}>
+          <Route path="relation" element={<AdminRelation />}>
+            <Route path="list" element={<AdminRelationList />} />
+          </Route>
+          <Route path="nissaya-ending" element={<AdminNissayaEnding />}>
+            <Route path="list" element={<AdminNissayaEndingList />} />
+            <Route path="list/:relation" element={<AdminNissayaEndingList />} />
+          </Route>
+        </Route>
         <Route path="anonymous" element={<Anonymous />}>
           <Route path="users">
             <Route path="sign-in" element={<NutUsersSignIn />} />
@@ -136,7 +151,6 @@ const Widget = () => {
 
         <Route path="community" element={<LibraryCommunity />}>
           <Route path="list" element={<LibraryCommunityList />} />
-          <Route path="recent" element={<LibraryCommunityRecent />} />
         </Route>
 
         <Route path="palicanon" element={<LibraryPalicanon />}>
@@ -153,7 +167,6 @@ const Widget = () => {
         <Route path="course" element={<LibraryCourse />}>
           <Route path="list" element={<LibraryCourseList />}></Route>
           <Route path="show/:id" element={<LibraryCourseShow />}></Route>
-          <Route path="lesson" element={<LibraryLessonShow />}></Route>
         </Route>
 
         <Route path="term/:word" element={<LibraryTerm />} />
@@ -173,6 +186,7 @@ const Widget = () => {
         </Route>
 
         <Route path="article" element={<LibraryArticle />}>
+          <Route path=":type" element={<LibraryArticleShow />} />
           <Route path=":type/:id" element={<LibraryArticleShow />} />
           <Route path=":type/:id/:mode" element={<LibraryArticleShow />} />
           <Route
@@ -195,10 +209,17 @@ const Widget = () => {
           <Route path="term" element={<LibraryBlogTerm />} />
         </Route>
 
+        <Route path="search" element={<LibrarySearch />}>
+          <Route path="home" element={<LibrarySearchKey />} />
+          <Route path="key/:key" element={<LibrarySearchKey />} />
+        </Route>
+
         <Route path="studio/:studioname" element={<Studio />}>
           <Route path="home" element={<StudioHome />} />
           <Route path="palicanon" element={<StudioPalicanon />}></Route>
-          <Route path="recent" element={<StudioRecent />}></Route>
+          <Route path="recent" element={<StudioRecent />}>
+            <Route path="list" element={<StudioRecentList />} />
+          </Route>
 
           <Route path="channel" element={<StudioChannel />}>
             <Route path="list" element={<StudioChannelList />} />

+ 43 - 14
dashboard/src/assets/library/images/wikipali_logo_library.svg

@@ -2,21 +2,50 @@
   <g id="Group_12" data-name="Group 12" transform="translate(-396 -320)">
     <g id="Group_2" data-name="Group 2" transform="translate(396 320)">
       <g id="Group_1" data-name="Group 1" transform="translate(39.472 12.369)">
-        <path id="Path_1" data-name="Path 1" d="M252.239,132.886a1.184,1.184,0,0,1-.733-.244,1.144,1.144,0,0,1-.424-.63l-3.447-12.729a.825.825,0,0,1-.026-.206.683.683,0,0,1,.155-.411.655.655,0,0,1,.539-.257h1.234a1.129,1.129,0,0,1,.721.244,1.171,1.171,0,0,1,.411.63l1.7,6.97q.026.1.8,4.063a.046.046,0,0,0,.053.051.046.046,0,0,0,.051-.051q.925-3.96.952-4.063l1.8-6.97a1.187,1.187,0,0,1,1.132-.874h1a1.187,1.187,0,0,1,1.13.874l1.851,6.97q.153.643.475,1.993t.5,2.071a.046.046,0,0,0,.051.051.084.084,0,0,0,.078-.051q.076-.464.36-1.865t.462-2.2l1.647-6.97a1.187,1.187,0,0,1,1.132-.874h1.028a.66.66,0,0,1,.54.257.723.723,0,0,1,.155.437.813.813,0,0,1-.026.18l-3.266,12.729a1.143,1.143,0,0,1-.424.63,1.174,1.174,0,0,1-.733.244h-1.8a1.129,1.129,0,0,1-.721-.244,1.165,1.165,0,0,1-.411-.63l-1.62-6.3q-.258-1.028-.9-4.114a.1.1,0,0,0-.091-.051.091.091,0,0,0-.089.051q-.514,2.726-.9,4.142l-1.543,6.275a1.166,1.166,0,0,1-.411.63,1.12,1.12,0,0,1-.721.244h-1.671Z" transform="translate(-247.61 -111.903)" fill="#fff"/>
-        <path id="Path_2" data-name="Path 2" d="M395.183,81.944a1.988,1.988,0,0,1-1.389.5,1.942,1.942,0,0,1-1.376-.5,1.661,1.661,0,0,1-.539-1.274,1.692,1.692,0,0,1,.539-1.3,1.94,1.94,0,0,1,1.376-.5,1.978,1.978,0,0,1,1.389.5,1.673,1.673,0,0,1,.553,1.3A1.644,1.644,0,0,1,395.183,81.944ZM393.216,99.65a.92.92,0,0,1-.926-.926V86.072a.864.864,0,0,1,.269-.63.892.892,0,0,1,.657-.269h1.157a.9.9,0,0,1,.657.269.865.865,0,0,1,.271.63V98.723a.923.923,0,0,1-.928.926Z" transform="translate(-368.881 -78.666)" fill="#fff"/>
-        <path id="Path_3" data-name="Path 3" d="M444.3,98.574a.92.92,0,0,1-.926-.926V78.489a.869.869,0,0,1,.269-.63.892.892,0,0,1,.657-.269H445.4a.9.9,0,0,1,.657.269.863.863,0,0,1,.269.63v12.6c0,.018.013.026.038.026a.091.091,0,0,0,.065-.026l5.092-6.3a1.831,1.831,0,0,1,1.492-.693h1.518a.387.387,0,0,1,.373.244.382.382,0,0,1-.064.45l-4.269,5.092a.167.167,0,0,0,0,.18l4.937,7.74a.49.49,0,0,1,.078.257.481.481,0,0,1-.078.257.448.448,0,0,1-.437.257h-1.465a1.55,1.55,0,0,1-1.389-.772l-3.4-5.683c-.033-.069-.077-.077-.129-.026l-2.288,2.649a.336.336,0,0,0-.078.206v2.7a.92.92,0,0,1-.926.926Z" transform="translate(-412.163 -77.59)" fill="#fff"/>
-        <path id="Path_4" data-name="Path 4" d="M540.613,81.944a1.987,1.987,0,0,1-1.388.5,1.94,1.94,0,0,1-1.376-.5,1.661,1.661,0,0,1-.539-1.274,1.692,1.692,0,0,1,.539-1.3,1.942,1.942,0,0,1,1.376-.5,1.977,1.977,0,0,1,1.388.5,1.673,1.673,0,0,1,.553,1.3A1.649,1.649,0,0,1,540.613,81.944ZM538.646,99.65a.923.923,0,0,1-.928-.926V86.072a.86.86,0,0,1,.271-.63.892.892,0,0,1,.657-.269H539.8a.9.9,0,0,1,.657.269.863.863,0,0,1,.269.63V98.723a.92.92,0,0,1-.926.926Z" transform="translate(-491.128 -78.666)" fill="#fff"/>
-        <path id="Path_5" data-name="Path 5" d="M589.735,137.187a.92.92,0,0,1-.925-.925V117.746a.92.92,0,0,1,.925-.926h.642a1.016,1.016,0,0,1,.682.257,1.136,1.136,0,0,1,.373.642l.077.642c.018.035.038.053.064.053a.1.1,0,0,0,.065-.026,7.051,7.051,0,0,1,4.424-1.929,5,5,0,0,1,4.231,1.993,8.742,8.742,0,0,1,1.5,5.388,10.164,10.164,0,0,1-.515,3.3,6.991,6.991,0,0,1-1.389,2.469,6.473,6.473,0,0,1-1.993,1.5,5.339,5.339,0,0,1-2.353.54,5.846,5.846,0,0,1-3.729-1.569.031.031,0,0,0-.051,0,.075.075,0,0,0-.025.053l.076,2.366v3.754a.92.92,0,0,1-.926.925h-1.159Zm5.246-8.023a3.153,3.153,0,0,0,2.65-1.4,6.426,6.426,0,0,0,1.028-3.871q0-4.912-3.394-4.912a5.135,5.135,0,0,0-3.368,1.7.245.245,0,0,0-.078.18v6.866a.243.243,0,0,0,.078.18A4.734,4.734,0,0,0,594.981,129.164Z" transform="translate(-534.418 -110.264)" fill="#fff"/>
-        <path id="Path_6" data-name="Path 6" d="M691.509,105.4a4.264,4.264,0,0,1-3.073-1.143,4.27,4.27,0,0,1,.85-6.609,16.4,16.4,0,0,1,6.518-1.787c.069,0,.1-.041.1-.129q-.1-3.032-2.751-3.034a7.157,7.157,0,0,0-3.419,1,.864.864,0,0,1-.669.089.811.811,0,0,1-.539-.424l-.257-.462a.955.955,0,0,1-.089-.706.822.822,0,0,1,.424-.553,10.423,10.423,0,0,1,5.066-1.44,4.826,4.826,0,0,1,3.96,1.594,6.988,6.988,0,0,1,1.312,4.551v7.792a.923.923,0,0,1-.928.926h-.642a1.008,1.008,0,0,1-.681-.257,1.118,1.118,0,0,1-.373-.642l-.1-.721c-.018-.033-.038-.053-.065-.053s-.046.018-.064.053A7.155,7.155,0,0,1,691.509,105.4Zm-.977-18.027a.92.92,0,0,1-.926-.926v-.206a.92.92,0,0,1,.926-.926h6.3a.92.92,0,0,1,.925.926v.206a.92.92,0,0,1-.925.926Zm1.9,15.637a5.287,5.287,0,0,0,3.4-1.6.278.278,0,0,0,.077-.206V97.9c0-.086-.033-.12-.1-.1a11.688,11.688,0,0,0-4.346,1.17,2.336,2.336,0,0,0-1.286,2.045,1.82,1.82,0,0,0,.617,1.518A2.582,2.582,0,0,0,692.435,103.015Z" transform="translate(-617.157 -84.088)" fill="#fff"/>
-        <path id="Path_7" data-name="Path 7" d="M792.08,98.907a2.68,2.68,0,0,1-2.3-.952,4.607,4.607,0,0,1-.708-2.779V78.489a.865.865,0,0,1,.271-.63A.893.893,0,0,1,790,77.59h1.157a.9.9,0,0,1,.657.269.865.865,0,0,1,.271.63V95.333a1.12,1.12,0,0,0,.411,1.028c.034.018.11.061.231.129s.206.12.257.155.12.081.206.14a.691.691,0,0,1,.193.193.438.438,0,0,1,.064.231l.1.566a.736.736,0,0,1,.026.18.96.96,0,0,1-.155.54.792.792,0,0,1-.591.386C792.585,98.9,792.336,98.907,792.08,98.907Z" transform="translate(-702.754 -77.59)" fill="#fff"/>
-        <path id="Path_8" data-name="Path 8" d="M840.663,81.944a1.988,1.988,0,0,1-1.389.5,1.94,1.94,0,0,1-1.376-.5,1.661,1.661,0,0,1-.539-1.274,1.692,1.692,0,0,1,.539-1.3,1.939,1.939,0,0,1,1.376-.5,1.978,1.978,0,0,1,1.389.5,1.673,1.673,0,0,1,.553,1.3A1.649,1.649,0,0,1,840.663,81.944ZM838.7,99.65a.92.92,0,0,1-.926-.926V86.072a.864.864,0,0,1,.269-.63.892.892,0,0,1,.657-.269h1.157a.9.9,0,0,1,.657.269.865.865,0,0,1,.271.63V98.723a.923.923,0,0,1-.928.926Z" transform="translate(-743.346 -78.666)" fill="#fff"/>
+        <path id="Path_1" data-name="Path 1"
+          d="M252.239,132.886a1.184,1.184,0,0,1-.733-.244,1.144,1.144,0,0,1-.424-.63l-3.447-12.729a.825.825,0,0,1-.026-.206.683.683,0,0,1,.155-.411.655.655,0,0,1,.539-.257h1.234a1.129,1.129,0,0,1,.721.244,1.171,1.171,0,0,1,.411.63l1.7,6.97q.026.1.8,4.063a.046.046,0,0,0,.053.051.046.046,0,0,0,.051-.051q.925-3.96.952-4.063l1.8-6.97a1.187,1.187,0,0,1,1.132-.874h1a1.187,1.187,0,0,1,1.13.874l1.851,6.97q.153.643.475,1.993t.5,2.071a.046.046,0,0,0,.051.051.084.084,0,0,0,.078-.051q.076-.464.36-1.865t.462-2.2l1.647-6.97a1.187,1.187,0,0,1,1.132-.874h1.028a.66.66,0,0,1,.54.257.723.723,0,0,1,.155.437.813.813,0,0,1-.026.18l-3.266,12.729a1.143,1.143,0,0,1-.424.63,1.174,1.174,0,0,1-.733.244h-1.8a1.129,1.129,0,0,1-.721-.244,1.165,1.165,0,0,1-.411-.63l-1.62-6.3q-.258-1.028-.9-4.114a.1.1,0,0,0-.091-.051.091.091,0,0,0-.089.051q-.514,2.726-.9,4.142l-1.543,6.275a1.166,1.166,0,0,1-.411.63,1.12,1.12,0,0,1-.721.244h-1.671Z"
+          transform="translate(-247.61 -111.903)" fill="#fff" />
+        <path id="Path_2" data-name="Path 2"
+          d="M395.183,81.944a1.988,1.988,0,0,1-1.389.5,1.942,1.942,0,0,1-1.376-.5,1.661,1.661,0,0,1-.539-1.274,1.692,1.692,0,0,1,.539-1.3,1.94,1.94,0,0,1,1.376-.5,1.978,1.978,0,0,1,1.389.5,1.673,1.673,0,0,1,.553,1.3A1.644,1.644,0,0,1,395.183,81.944ZM393.216,99.65a.92.92,0,0,1-.926-.926V86.072a.864.864,0,0,1,.269-.63.892.892,0,0,1,.657-.269h1.157a.9.9,0,0,1,.657.269.865.865,0,0,1,.271.63V98.723a.923.923,0,0,1-.928.926Z"
+          transform="translate(-368.881 -78.666)" fill="#fff" />
+        <path id="Path_3" data-name="Path 3"
+          d="M444.3,98.574a.92.92,0,0,1-.926-.926V78.489a.869.869,0,0,1,.269-.63.892.892,0,0,1,.657-.269H445.4a.9.9,0,0,1,.657.269.863.863,0,0,1,.269.63v12.6c0,.018.013.026.038.026a.091.091,0,0,0,.065-.026l5.092-6.3a1.831,1.831,0,0,1,1.492-.693h1.518a.387.387,0,0,1,.373.244.382.382,0,0,1-.064.45l-4.269,5.092a.167.167,0,0,0,0,.18l4.937,7.74a.49.49,0,0,1,.078.257.481.481,0,0,1-.078.257.448.448,0,0,1-.437.257h-1.465a1.55,1.55,0,0,1-1.389-.772l-3.4-5.683c-.033-.069-.077-.077-.129-.026l-2.288,2.649a.336.336,0,0,0-.078.206v2.7a.92.92,0,0,1-.926.926Z"
+          transform="translate(-412.163 -77.59)" fill="#fff" />
+        <path id="Path_4" data-name="Path 4"
+          d="M540.613,81.944a1.987,1.987,0,0,1-1.388.5,1.94,1.94,0,0,1-1.376-.5,1.661,1.661,0,0,1-.539-1.274,1.692,1.692,0,0,1,.539-1.3,1.942,1.942,0,0,1,1.376-.5,1.977,1.977,0,0,1,1.388.5,1.673,1.673,0,0,1,.553,1.3A1.649,1.649,0,0,1,540.613,81.944ZM538.646,99.65a.923.923,0,0,1-.928-.926V86.072a.86.86,0,0,1,.271-.63.892.892,0,0,1,.657-.269H539.8a.9.9,0,0,1,.657.269.863.863,0,0,1,.269.63V98.723a.92.92,0,0,1-.926.926Z"
+          transform="translate(-491.128 -78.666)" fill="#fff" />
+        <path id="Path_5" data-name="Path 5"
+          d="M589.735,137.187a.92.92,0,0,1-.925-.925V117.746a.92.92,0,0,1,.925-.926h.642a1.016,1.016,0,0,1,.682.257,1.136,1.136,0,0,1,.373.642l.077.642c.018.035.038.053.064.053a.1.1,0,0,0,.065-.026,7.051,7.051,0,0,1,4.424-1.929,5,5,0,0,1,4.231,1.993,8.742,8.742,0,0,1,1.5,5.388,10.164,10.164,0,0,1-.515,3.3,6.991,6.991,0,0,1-1.389,2.469,6.473,6.473,0,0,1-1.993,1.5,5.339,5.339,0,0,1-2.353.54,5.846,5.846,0,0,1-3.729-1.569.031.031,0,0,0-.051,0,.075.075,0,0,0-.025.053l.076,2.366v3.754a.92.92,0,0,1-.926.925h-1.159Zm5.246-8.023a3.153,3.153,0,0,0,2.65-1.4,6.426,6.426,0,0,0,1.028-3.871q0-4.912-3.394-4.912a5.135,5.135,0,0,0-3.368,1.7.245.245,0,0,0-.078.18v6.866a.243.243,0,0,0,.078.18A4.734,4.734,0,0,0,594.981,129.164Z"
+          transform="translate(-534.418 -110.264)" fill="#fff" />
+        <path id="Path_6" data-name="Path 6"
+          d="M691.509,105.4a4.264,4.264,0,0,1-3.073-1.143,4.27,4.27,0,0,1,.85-6.609,16.4,16.4,0,0,1,6.518-1.787c.069,0,.1-.041.1-.129q-.1-3.032-2.751-3.034a7.157,7.157,0,0,0-3.419,1,.864.864,0,0,1-.669.089.811.811,0,0,1-.539-.424l-.257-.462a.955.955,0,0,1-.089-.706.822.822,0,0,1,.424-.553,10.423,10.423,0,0,1,5.066-1.44,4.826,4.826,0,0,1,3.96,1.594,6.988,6.988,0,0,1,1.312,4.551v7.792a.923.923,0,0,1-.928.926h-.642a1.008,1.008,0,0,1-.681-.257,1.118,1.118,0,0,1-.373-.642l-.1-.721c-.018-.033-.038-.053-.065-.053s-.046.018-.064.053A7.155,7.155,0,0,1,691.509,105.4Zm-.977-18.027a.92.92,0,0,1-.926-.926v-.206a.92.92,0,0,1,.926-.926h6.3a.92.92,0,0,1,.925.926v.206a.92.92,0,0,1-.925.926Zm1.9,15.637a5.287,5.287,0,0,0,3.4-1.6.278.278,0,0,0,.077-.206V97.9c0-.086-.033-.12-.1-.1a11.688,11.688,0,0,0-4.346,1.17,2.336,2.336,0,0,0-1.286,2.045,1.82,1.82,0,0,0,.617,1.518A2.582,2.582,0,0,0,692.435,103.015Z"
+          transform="translate(-617.157 -84.088)" fill="#fff" />
+        <path id="Path_7" data-name="Path 7"
+          d="M792.08,98.907a2.68,2.68,0,0,1-2.3-.952,4.607,4.607,0,0,1-.708-2.779V78.489a.865.865,0,0,1,.271-.63A.893.893,0,0,1,790,77.59h1.157a.9.9,0,0,1,.657.269.865.865,0,0,1,.271.63V95.333a1.12,1.12,0,0,0,.411,1.028c.034.018.11.061.231.129s.206.12.257.155.12.081.206.14a.691.691,0,0,1,.193.193.438.438,0,0,1,.064.231l.1.566a.736.736,0,0,1,.026.18.96.96,0,0,1-.155.54.792.792,0,0,1-.591.386C792.585,98.9,792.336,98.907,792.08,98.907Z"
+          transform="translate(-702.754 -77.59)" fill="#fff" />
+        <path id="Path_8" data-name="Path 8"
+          d="M840.663,81.944a1.988,1.988,0,0,1-1.389.5,1.94,1.94,0,0,1-1.376-.5,1.661,1.661,0,0,1-.539-1.274,1.692,1.692,0,0,1,.539-1.3,1.939,1.939,0,0,1,1.376-.5,1.978,1.978,0,0,1,1.389.5,1.673,1.673,0,0,1,.553,1.3A1.649,1.649,0,0,1,840.663,81.944ZM838.7,99.65a.92.92,0,0,1-.926-.926V86.072a.864.864,0,0,1,.269-.63.892.892,0,0,1,.657-.269h1.157a.9.9,0,0,1,.657.269.865.865,0,0,1,.271.63V98.723a.923.923,0,0,1-.928.926Z"
+          transform="translate(-743.346 -78.666)" fill="#fff" />
       </g>
-      <path id="Path_9" data-name="Path 9" d="M125.632,125.382a1.531,1.531,0,0,1-1.532-1.532v-5.532c0-8.629,4.134-13.579,11.342-13.579a1.532,1.532,0,0,1,0,3.064c-5.493,0-8.28,3.537-8.28,10.517v5.532A1.529,1.529,0,0,1,125.632,125.382Z" transform="translate(-104.317 -88.043)" fill="#f1ca23"/>
-      <path id="Path_10" data-name="Path 10" d="M144.722,179.085a1.532,1.532,0,1,1,0-3.064c2.987,0,5.076-4,5.076-9.727V149.842a1.532,1.532,0,0,1,3.064,0v16.452C152.86,175.13,148.773,179.085,144.722,179.085Z" transform="translate(-120.364 -124.667)" fill="#f1ca23"/>
-      <path id="Path_11" data-name="Path 11" d="M84.262,37.339a1.531,1.531,0,0,1-1.532-1.532V1.532a1.532,1.532,0,0,1,3.064,0V35.809A1.531,1.531,0,0,1,84.262,37.339Z" transform="translate(-69.542)" fill="#f1ca23"/>
-      <path id="Path_12" data-name="Path 12" d="M42.892,37.339a1.531,1.531,0,0,1-1.532-1.532V1.532a1.532,1.532,0,0,1,3.064,0V35.809A1.531,1.531,0,0,1,42.892,37.339Z" transform="translate(-34.767)" fill="#f1ca23"/>
-      <path id="Path_13" data-name="Path 13" d="M1.532,37.339A1.531,1.531,0,0,1,0,35.808V1.532a1.532,1.532,0,0,1,3.064,0V35.809A1.533,1.533,0,0,1,1.532,37.339Z" fill="#f1ca23"/>
+      <path id="Path_9" data-name="Path 9"
+        d="M125.632,125.382a1.531,1.531,0,0,1-1.532-1.532v-5.532c0-8.629,4.134-13.579,11.342-13.579a1.532,1.532,0,0,1,0,3.064c-5.493,0-8.28,3.537-8.28,10.517v5.532A1.529,1.529,0,0,1,125.632,125.382Z"
+        transform="translate(-104.317 -88.043)" fill="#f1ca23" />
+      <path id="Path_10" data-name="Path 10"
+        d="M144.722,179.085a1.532,1.532,0,1,1,0-3.064c2.987,0,5.076-4,5.076-9.727V149.842a1.532,1.532,0,0,1,3.064,0v16.452C152.86,175.13,148.773,179.085,144.722,179.085Z"
+        transform="translate(-120.364 -124.667)" fill="#f1ca23" />
+      <path id="Path_11" data-name="Path 11"
+        d="M84.262,37.339a1.531,1.531,0,0,1-1.532-1.532V1.532a1.532,1.532,0,0,1,3.064,0V35.809A1.531,1.531,0,0,1,84.262,37.339Z"
+        transform="translate(-69.542)" fill="#f1ca23" />
+      <path id="Path_12" data-name="Path 12"
+        d="M42.892,37.339a1.531,1.531,0,0,1-1.532-1.532V1.532a1.532,1.532,0,0,1,3.064,0V35.809A1.531,1.531,0,0,1,42.892,37.339Z"
+        transform="translate(-34.767)" fill="#f1ca23" />
+      <path id="Path_13" data-name="Path 13"
+        d="M1.532,37.339A1.531,1.531,0,0,1,0,35.808V1.532a1.532,1.532,0,0,1,3.064,0V35.809A1.533,1.533,0,0,1,1.532,37.339Z"
+        fill="#f1ca23" />
     </g>
-    <text id="studio" transform="translate(542 353.2)" fill="#fff" font-size="27" font-family="NotoSans-ExtraLight, Noto Sans" font-weight="200"><tspan x="0" y="0">Library</tspan></text>
+    <text id="studio" transform="translate(542 353.2)" fill="#fff" font-size="24"
+      font-family="NotoSans-ExtraLight, Noto Sans" font-weight="200">
+      <tspan x="0" y="0">Library</tspan>
+    </text>
   </g>
 </svg>

+ 59 - 0
dashboard/src/components/admin/HeadBar.tsx

@@ -0,0 +1,59 @@
+import { Link } from "react-router-dom";
+import { Col, Row, Input, Layout, Space } from "antd";
+
+import img_banner from "../../assets/studio/images/wikipali_banner.svg";
+import UiLangSelect from "../general/UiLangSelect";
+import SignInAvatar from "../auth/SignInAvatar";
+import ToLibaray from "../auth/ToLibaray";
+import ThemeSelect from "../general/ThemeSelect";
+
+const { Search } = Input;
+const { Header } = Layout;
+
+const onSearch = (value: string) => console.log(value);
+
+const Widget = () => {
+  return (
+    <Header
+      className="header"
+      style={{
+        lineHeight: "44px",
+        height: 44,
+        paddingLeft: 10,
+        paddingRight: 10,
+      }}
+    >
+      <div
+        style={{
+          display: "flex",
+          width: "100%",
+          justifyContent: "space-between",
+        }}
+      >
+        <div style={{ width: 80 }}>
+          <Link to="/">
+            <img alt="code" style={{ height: 36 }} src={img_banner} />
+          </Link>
+        </div>
+        <div style={{ width: 500, lineHeight: 44 }}>
+          <Search
+            disabled
+            placeholder="input search text"
+            onSearch={onSearch}
+            style={{ width: "100%" }}
+          />
+        </div>
+        <div>
+          <Space>
+            <ToLibaray />
+            <SignInAvatar />
+            <UiLangSelect />
+            <ThemeSelect />
+          </Space>
+        </div>
+      </div>
+    </Header>
+  );
+};
+
+export default Widget;

+ 57 - 0
dashboard/src/components/admin/LeftSider.tsx

@@ -0,0 +1,57 @@
+import { Link } from "react-router-dom";
+import type { MenuProps } from "antd";
+import { Affix, Layout } from "antd";
+import { Menu } from "antd";
+import { AppstoreOutlined, HomeOutlined } from "@ant-design/icons";
+
+const { Sider } = Layout;
+
+const onClick: MenuProps["onClick"] = (e) => {
+  console.log("click ", e);
+};
+
+type IWidgetHeadBar = {
+  selectedKeys?: string;
+};
+const Widget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
+  const items: MenuProps["items"] = [
+    {
+      label: "管理",
+      key: "manage",
+      icon: <HomeOutlined />,
+      children: [
+        {
+          label: <Link to="/admin/relation/list">Relation</Link>,
+          key: "relation",
+        },
+        {
+          label: <Link to="/admin/nissaya-ending/list">nissaya-ending</Link>,
+          key: "nissaya-ending",
+        },
+      ],
+    },
+    {
+      label: "统计",
+      key: "advance",
+      icon: <AppstoreOutlined />,
+      children: [],
+    },
+  ];
+
+  return (
+    <Affix offsetTop={0}>
+      <Sider width={200} breakpoint="lg" className="site-layout-background">
+        <Menu
+          theme="light"
+          onClick={onClick}
+          defaultSelectedKeys={[selectedKeys]}
+          defaultOpenKeys={["basic", "advance", "collaboration"]}
+          mode="inline"
+          items={items}
+        />
+      </Sider>
+    </Affix>
+  );
+};
+
+export default Widget;

+ 41 - 0
dashboard/src/components/admin/relation/CaseSelect.tsx

@@ -0,0 +1,41 @@
+import { ProFormSelect } from "@ant-design/pro-components";
+
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  name?: string;
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+}
+const Widget = ({ name = "case", width = "md" }: IWidget) => {
+  const intl = useIntl();
+  const _case = [
+    "nom",
+    "acc",
+    "gen",
+    "dat",
+    "inst",
+    "abl",
+    "loc",
+    "ger",
+    "adv",
+  ];
+  const caseOptions = _case.map((item) => {
+    return {
+      value: item,
+      label: intl.formatMessage({
+        id: `dict.fields.type.${item}.label`,
+      }),
+    };
+  });
+
+  return (
+    <ProFormSelect
+      options={caseOptions}
+      width={width}
+      name={name}
+      label={intl.formatMessage({ id: "forms.fields.case.label" })}
+    />
+  );
+};
+
+export default Widget;

+ 99 - 0
dashboard/src/components/admin/relation/DataImport.tsx

@@ -0,0 +1,99 @@
+import { ModalForm, ProFormUploadDragger } from "@ant-design/pro-components";
+import { Form, message } from "antd";
+
+import { API_HOST, get } from "../../../request";
+import { UploadFile } from "antd/es/upload/interface";
+import { IAttachmentResponse } from "../../api/Attachments";
+import modal from "antd/lib/modal";
+
+interface INissayaEndingUpload {
+  filename: UploadFile<IAttachmentResponse>[];
+}
+export interface INissayaEndingImportResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    success: number;
+    fail: number;
+  };
+}
+
+interface IWidget {
+  url: string;
+  urlExtra?: string;
+  trigger?: JSX.Element;
+  onSuccess?: Function;
+}
+const Widget = ({
+  url,
+  urlExtra,
+  trigger = <>{"trigger"}</>,
+  onSuccess,
+}: IWidget) => {
+  const [form] = Form.useForm<INissayaEndingUpload>();
+
+  return (
+    <ModalForm<INissayaEndingUpload>
+      title="upload"
+      trigger={trigger}
+      form={form}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnClose: true,
+        onCancel: () => console.log("run"),
+      }}
+      submitTimeout={2000}
+      onFinish={async (values) => {
+        console.log(values);
+        let _filename: string = "";
+
+        if (
+          typeof values.filename === "undefined" ||
+          values.filename.length === 0
+        ) {
+          _filename = "";
+        } else if (typeof values.filename[0].response === "undefined") {
+          _filename = values.filename[0].uid;
+        } else {
+          _filename = values.filename[0].response.data.url;
+        }
+
+        const queryUrl = `${url}?filename=${_filename}&${urlExtra}`;
+        console.log("url", queryUrl);
+        const res = await get<INissayaEndingImportResponse>(queryUrl);
+
+        console.log("import", res);
+        if (res.ok) {
+          if (res.data.fail > 0) {
+            modal.info({
+              title: "error",
+              content: `成功${res.data.success}-失败${res.data.fail}\n${res.message}`,
+            });
+          } else {
+            message.success(`成功导入${res.data.success}`);
+          }
+
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+          }
+        } else {
+          message.error(res.message);
+        }
+
+        return true;
+      }}
+    >
+      <ProFormUploadDragger
+        max={1}
+        label="上传xlsx"
+        name="filename"
+        fieldProps={{
+          name: "file",
+        }}
+        action={`${API_HOST}/api/v2/attachments`}
+      />
+    </ModalForm>
+  );
+};
+
+export default Widget;

+ 111 - 0
dashboard/src/components/admin/relation/NissayaEndingEdit.tsx

@@ -0,0 +1,111 @@
+import { ModalForm, ProForm, ProFormText } from "@ant-design/pro-components";
+import { Form, message } from "antd";
+
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import {
+  INissayaEnding,
+  INissayaEndingRequest,
+  INissayaEndingResponse,
+} from "../../../pages/admin/nissaya-ending/list";
+import { get, post, put } from "../../../request";
+import LangSelect from "../../general/LangSelect";
+import CaseSelect from "./CaseSelect";
+
+interface IWidget {
+  trigger?: JSX.Element;
+  id?: string;
+  onSuccess?: Function;
+}
+const Widget = ({ trigger = <>{"trigger"}</>, id, onSuccess }: IWidget) => {
+  const [title, setTitle] = useState<string | undefined>(id ? "" : "新建");
+  const [form] = Form.useForm<INissayaEnding>();
+  const intl = useIntl();
+  return (
+    <ModalForm<INissayaEnding>
+      title={title}
+      trigger={trigger}
+      form={form}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnClose: true,
+        onCancel: () => console.log("run"),
+      }}
+      submitTimeout={2000}
+      onFinish={async (values) => {
+        console.log(values.ending);
+        let res: INissayaEndingResponse;
+        if (typeof id === "undefined") {
+          res = await post<INissayaEndingRequest, INissayaEndingResponse>(
+            `/v2/nissaya-ending`,
+            values
+          );
+        } else {
+          res = await put<INissayaEndingRequest, INissayaEndingResponse>(
+            `/v2/nissaya-ending/${id}`,
+            values
+          );
+        }
+        console.log(res);
+        if (res.ok) {
+          message.success("提交成功");
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+          }
+        } else {
+          message.error(res.message);
+        }
+
+        return true;
+      }}
+      request={
+        id
+          ? async () => {
+              const res = await get<INissayaEndingResponse>(
+                `/v2/nissaya-ending/${id}`
+              );
+              console.log("nissaya-ending get", res);
+              if (res.ok) {
+                setTitle(res.data.ending);
+
+                return {
+                  id: id,
+                  ending: res.data.ending,
+                  relation: res.data.relation,
+                  lang: res.data.lang,
+                };
+              } else {
+                return {
+                  id: undefined,
+                  ending: "",
+                  relation: "",
+                  lang: "",
+                };
+              }
+            }
+          : undefined
+      }
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="ending"
+          label={intl.formatMessage({ id: "forms.fields.ending.label" })}
+          tooltip="最长为 24 位"
+        />
+
+        <ProFormText
+          width="md"
+          name="relation"
+          label={intl.formatMessage({ id: "forms.fields.relation.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <CaseSelect width="md" name="case" />
+        <LangSelect width="md" />
+      </ProForm.Group>
+    </ModalForm>
+  );
+};
+
+export default Widget;

+ 127 - 0
dashboard/src/components/admin/relation/RelationEdit.tsx

@@ -0,0 +1,127 @@
+import {
+  ModalForm,
+  ProForm,
+  ProFormInstance,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Form, message } from "antd";
+
+import { useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import {
+  IRelation,
+  IRelationRequest,
+  IRelationResponse,
+} from "../../../pages/admin/relation/list";
+import { get, post, put } from "../../../request";
+import CaseSelect from "./CaseSelect";
+
+interface IWidget {
+  trigger?: JSX.Element;
+  id?: string;
+  onSuccess?: Function;
+}
+const Widget = ({ trigger = <>{"trigger"}</>, id, onSuccess }: IWidget) => {
+  const [title, setTitle] = useState<string | undefined>(id ? "" : "新建");
+  const [form] = Form.useForm<IRelation>();
+  const formRef = useRef<ProFormInstance>();
+  const intl = useIntl();
+
+  const _verb = ["v", "pass", "caus", "ger", "fpp", "pp", "n", "adv"];
+  const verbOptions = _verb.map((item) => {
+    return {
+      value: item,
+      label: intl.formatMessage({
+        id: `dict.fields.type.${item}.label`,
+      }),
+    };
+  });
+  return (
+    <ModalForm<IRelation>
+      title={title}
+      trigger={trigger}
+      formRef={formRef}
+      form={form}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnClose: true,
+        onCancel: () => console.log("run"),
+      }}
+      submitTimeout={2000}
+      onFinish={async (values) => {
+        console.log("submit", values);
+        let res: IRelationResponse;
+        if (typeof id === "undefined") {
+          res = await post<IRelationRequest, IRelationResponse>(
+            `/v2/relation`,
+            values
+          );
+        } else {
+          res = await put<IRelationRequest, IRelationResponse>(
+            `/v2/relation/${id}`,
+            values
+          );
+        }
+        console.log(res);
+        if (res.ok) {
+          message.success("提交成功");
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+          }
+        } else {
+          message.error(res.message);
+        }
+
+        return true;
+      }}
+      request={
+        id
+          ? async () => {
+              const res = await get<IRelationResponse>(`/v2/relation/${id}`);
+              console.log("relation get", res);
+              if (res.ok) {
+                setTitle(res.data.name);
+
+                return {
+                  id: id,
+                  name: res.data.name,
+                  case: res.data.case,
+                  to: res.data.to,
+                };
+              } else {
+                return {
+                  id: undefined,
+                  name: "",
+                };
+              }
+            }
+          : undefined
+      }
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          label={intl.formatMessage({ id: "forms.fields.name.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <CaseSelect width="md" name="case" />
+
+        <ProFormSelect
+          options={verbOptions}
+          fieldProps={{
+            mode: "tags",
+          }}
+          width="md"
+          name="to"
+          allowClear={false}
+          label={intl.formatMessage({ id: "forms.fields.case.label" })}
+        />
+      </ProForm.Group>
+    </ModalForm>
+  );
+};
+
+export default Widget;

+ 108 - 27
dashboard/src/components/anthology/AnthologyList.tsx

@@ -8,6 +8,7 @@ import {
   ExclamationCircleOutlined,
   TeamOutlined,
   DeleteOutlined,
+  EyeOutlined,
 } from "@ant-design/icons";
 
 import AnthologyCreate from "../../components/anthology/AnthologyCreate";
@@ -17,7 +18,14 @@ import {
 } from "../../components/api/Article";
 import { delete_, get } from "../../request";
 import { PublicityValueEnum } from "../../components/studio/table";
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
+import ShareModal from "../share/ShareModal";
+import { EResType } from "../share/Share";
+import {
+  IResNumberResponse,
+  renderBadge,
+} from "../../pages/studio/channel/list";
+import StudioName, { IStudio } from "../auth/StudioName";
 
 const { Text } = Typography;
 
@@ -28,23 +36,46 @@ interface IItem {
   subtitle: string;
   publicity: number;
   articles: number;
+  studio?: IStudio;
   createdAt: number;
 }
 interface IWidget {
+  title?: string;
   studioName?: string;
   showCol?: string[];
   showCreate?: boolean;
+  showOption?: boolean;
   onTitleClick?: Function;
 }
 const Widget = ({
+  title,
   studioName,
   showCol,
   showCreate = true,
+  showOption = true,
   onTitleClick,
 }: IWidget) => {
   const intl = useIntl();
   const [openCreate, setOpenCreate] = useState(false);
 
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("my");
+  const [myNumber, setMyNumber] = useState<number>(0);
+  const [collaborationNumber, setCollaborationNumber] = useState<number>(0);
+
+  useEffect(() => {
+    /**
+     * 获取各种课程的数量
+     */
+    const url = `/v2/anthology-my-number?studio=${studioName}`;
+    console.log("url", url);
+    get<IResNumberResponse>(url).then((json) => {
+      if (json.ok) {
+        setMyNumber(json.data.my);
+        setCollaborationNumber(json.data.collaboration);
+      }
+    });
+  }, [studioName]);
+
   const showDeleteConfirm = (id: string, title: string) => {
     Modal.confirm({
       icon: <ExclamationCircleOutlined />,
@@ -83,6 +114,7 @@ const Widget = ({
   return (
     <>
       <ProTable<IItem>
+        headerTitle={title}
         actionRef={ref}
         columns={[
           {
@@ -121,6 +153,16 @@ const Widget = ({
               );
             },
           },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.owner.label",
+            }),
+            dataIndex: "studio",
+            key: "studio",
+            render: (text, row, index, action) => {
+              return <StudioName data={row.studio} />;
+            },
+          },
           {
             title: intl.formatMessage({
               id: "forms.fields.publicity.label",
@@ -141,7 +183,6 @@ const Widget = ({
             key: "articles",
             width: 100,
             search: false,
-            sorter: (a, b) => a.articles - b.articles,
           },
           {
             title: intl.formatMessage({
@@ -158,43 +199,46 @@ const Widget = ({
             title: intl.formatMessage({ id: "buttons.option" }),
             key: "option",
             width: 120,
+            hideInTable: !showOption,
             valueType: "option",
             render: (text, row, index, action) => [
               <Dropdown.Button
                 key={index}
                 type="link"
+                trigger={["click", "contextMenu"]}
                 menu={{
                   items: [
                     {
                       key: "open",
-                      label: intl.formatMessage({
-                        id: "buttons.open.in.library",
-                      }),
-                      icon: <TeamOutlined />,
-                      disabled: true,
+                      label: (
+                        <Link to={`/anthology/${row.id}`}>
+                          {intl.formatMessage({
+                            id: "buttons.open.in.library",
+                          })}
+                        </Link>
+                      ),
+                      icon: <EyeOutlined />,
                     },
                     {
                       key: "share",
-                      label: intl.formatMessage({
-                        id: "buttons.share",
-                      }),
+                      label: (
+                        <ShareModal
+                          trigger={intl.formatMessage({
+                            id: "buttons.share",
+                          })}
+                          resId={row.id}
+                          resType={EResType.collection}
+                        />
+                      ),
                       icon: <TeamOutlined />,
-                      disabled: true,
                     },
                     {
                       key: "remove",
-                      label: (
-                        <Text type="danger">
-                          {intl.formatMessage({
-                            id: "buttons.delete",
-                          })}
-                        </Text>
-                      ),
-                      icon: (
-                        <Text type="danger">
-                          <DeleteOutlined />
-                        </Text>
-                      ),
+                      label: intl.formatMessage({
+                        id: "buttons.delete",
+                      }),
+                      icon: <DeleteOutlined />,
+                      danger: true,
                     },
                   ],
                   onClick: (e) => {
@@ -225,14 +269,16 @@ const Widget = ({
         request={async (params = {}, sorter, filter) => {
           // TODO
           console.log(params, sorter, filter);
-          let url = `/v2/anthology?view=studio&name=${studioName}`;
+          let url = `/v2/anthology?view=studio&view2=${activeKey}&name=${studioName}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
             (params.pageSize ? params.pageSize : 20);
           url += `&limit=${params.pageSize}&offset=${offset}`;
-          if (typeof params.keyword !== "undefined") {
-            url += "&search=" + (params.keyword ? params.keyword : "");
-          }
+          url += params.keyword ? "&search=" + params.keyword : "";
+          url += sorter.createdAt
+            ? "&order=created_at&dir=" +
+              (sorter.createdAt === "ascend" ? "asc" : "desc")
+            : "";
 
           const res = await get<IAnthologyListResponse>(url);
           const items: IItem[] = res.data.rows.map((item, id) => {
@@ -244,6 +290,7 @@ const Widget = ({
               subtitle: item.subtitle,
               publicity: item.status,
               articles: item.childrenNumber,
+              studio: item.studio,
               createdAt: date.getTime(),
             };
           });
@@ -259,6 +306,7 @@ const Widget = ({
         pagination={{
           showQuickJumper: true,
           showSizeChanger: true,
+          pageSize: 10,
         }}
         search={false}
         options={{
@@ -289,6 +337,39 @@ const Widget = ({
             </Popover>
           ) : undefined,
         ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: "my",
+                label: (
+                  <span>
+                    此工作室的
+                    {renderBadge(myNumber, activeKey === "my")}
+                  </span>
+                ),
+              },
+              {
+                key: "collaboration",
+                label: (
+                  <span>
+                    协作
+                    {renderBadge(
+                      collaborationNumber,
+                      activeKey === "collaboration"
+                    )}
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              console.log("show course", key);
+              setActiveKey(key);
+              ref.current?.reload();
+            },
+          },
+        }}
       />
     </>
   );

+ 4 - 2
dashboard/src/components/anthology/AnthologyModal.tsx

@@ -4,7 +4,7 @@ import AnthologyList from "./AnthologyList";
 
 interface IWidget {
   studioName?: string;
-  trigger?: JSX.Element;
+  trigger?: React.ReactNode;
   onSelect?: Function;
   onCancel?: Function;
 }
@@ -28,14 +28,16 @@ const Widget = ({ studioName, trigger, onSelect, onCancel }: IWidget) => {
       <span onClick={showModal}>{trigger}</span>
       <Modal
         width={"80%"}
-        title="选择文集"
+        title="加入文集"
         open={isModalOpen}
         onOk={handleOk}
         onCancel={handleCancel}
       >
         <AnthologyList
+          title={"选择文集"}
           studioName={studioName}
           showCreate={false}
+          showOption={false}
           onTitleClick={(id: string) => {
             if (typeof onSelect !== "undefined") {
               onSelect(id);

+ 52 - 0
dashboard/src/components/anthology/AnthologySelect.tsx

@@ -0,0 +1,52 @@
+import { Select } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import { IAnthologyListResponse } from "../api/Article";
+
+interface IOptions {
+  value: string;
+  label: string;
+}
+interface IWidget {
+  studioName?: string;
+  onSelect?: Function;
+}
+const Widget = ({ studioName, onSelect }: IWidget) => {
+  const [anthology, setAnthology] = useState<IOptions[]>([
+    { value: "all", label: "全部" },
+    { value: "none", label: "没有加入文集的" },
+  ]);
+  useEffect(() => {
+    let url = `/v2/anthology?view=studio&name=${studioName}`;
+    get<IAnthologyListResponse>(url).then((json) => {
+      if (json.ok) {
+        const data = json.data.rows.map((item) => {
+          return {
+            value: item.uid,
+            label: item.title,
+          };
+        });
+        setAnthology([
+          { value: "all", label: "全部" },
+          { value: "none", label: "没有加入文集的" },
+          ...data,
+        ]);
+      }
+    });
+  }, [studioName]);
+  return (
+    <Select
+      defaultValue="all"
+      style={{ width: 180 }}
+      onChange={(value: string) => {
+        console.log(`selected ${value}`);
+        if (typeof onSelect !== "undefined") {
+          onSelect(value);
+        }
+      }}
+      options={anthology}
+    />
+  );
+};
+
+export default Widget;

+ 1 - 1
dashboard/src/components/anthology/AnthologyTocTree.tsx

@@ -1,4 +1,3 @@
-import { Key } from "antd/lib/table/interface";
 import { useEffect, useState } from "react";
 import { useNavigate } from "react-router-dom";
 
@@ -27,6 +26,7 @@ const Widget = ({ anthologyId, onSelect, onArticleSelect }: IWidget) => {
             key: item.article_id ? item.article_id : item.title,
             title: item.title,
             level: item.level,
+            deletedAt: item.deleted_at,
           };
         });
         setTocData(toc);

+ 2 - 6
dashboard/src/components/anthology/EditableTocTree.tsx

@@ -1,8 +1,7 @@
 import { message } from "antd";
-import { Key } from "antd/lib/table/interface";
 import { useEffect, useState } from "react";
 
-import { get, post, put } from "../../request";
+import { get, put } from "../../request";
 import {
   IArticleMapAddResponse,
   IArticleMapListResponse,
@@ -16,7 +15,6 @@ interface IWidget {
 }
 const Widget = ({ anthologyId, onSelect }: IWidget) => {
   const [tocData, setTocData] = useState<ListNodeData[]>([]);
-  const [keys, setKeys] = useState<Key[]>();
 
   useEffect(() => {
     get<IArticleMapListResponse>(
@@ -29,6 +27,7 @@ const Widget = ({ anthologyId, onSelect }: IWidget) => {
             key: item.article_id ? item.article_id : item.title,
             title: item.title,
             level: item.level,
+            deletedAt: item.deleted_at,
           };
         });
         setTocData(toc);
@@ -73,9 +72,6 @@ const Widget = ({ anthologyId, onSelect }: IWidget) => {
             })
             .catch((e) => console.error(e));
         }}
-        onSelect={(selectedKeys: Key[]) => {
-          setKeys(selectedKeys);
-        }}
       />
     </div>
   );

+ 9 - 1
dashboard/src/components/api/Article.ts

@@ -1,5 +1,7 @@
+import { IStudio } from "../auth/StudioName";
+import { IUser } from "../auth/User";
 import { ITocPathNode } from "../corpus/TocPath";
-import type { IStudioApiResponse } from "./Auth";
+import type { IStudioApiResponse, TRole } from "./Auth";
 
 export interface IArticleListApiResponse {
   article: string;
@@ -85,6 +87,11 @@ export interface IArticleDataResponse {
   path?: ITocPathNode[];
   status: number;
   lang: string;
+  anthology_count?: number;
+  anthology_first?: { title: string };
+  role?: TRole;
+  studio?: IStudio;
+  editor?: IUser;
   created_at: string;
   updated_at: string;
 }
@@ -121,6 +128,7 @@ export interface IArticleMapRequest {
   level: number;
   title: string;
   children?: number;
+  deleted_at?: string;
 }
 export interface IArticleMapListResponse {
   ok: boolean;

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

@@ -3,6 +3,7 @@ export type TRole =
   | "manager"
   | "editor"
   | "member"
+  | "reader"
   | "student"
   | "assistant"
   | "unknown";

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

@@ -1,5 +1,5 @@
 import { IStudio } from "../auth/StudioName";
-import { IStudioApiResponse, TRole } from "./Auth";
+import { TRole } from "./Auth";
 export type TChannelType =
   | "translation"
   | "nissaya"

+ 19 - 5
dashboard/src/components/api/Corpus.ts

@@ -1,7 +1,7 @@
 import { IStudio } from "../auth/StudioName";
 import { IUser } from "../auth/User";
 import { IChannel } from "../channel/Channel";
-import { ISuggestionCount } from "../template/SentEdit";
+import { ISuggestionCount, IWidgetSentEditInner } from "../template/SentEdit";
 import { TChannelType } from "./Channel";
 import { TagNode } from "./Tag";
 
@@ -19,9 +19,10 @@ export interface IApiPaliChapterList {
   parent: number;
   chapter_strlen: number;
   path: string;
+  progress_line?: number[];
 }
 
-export interface IApiResponsePaliChapterList {
+export interface IPaliChapterListResponse {
   ok: boolean;
   message: string;
   data: { rows: IApiPaliChapterList[]; count: number };
@@ -52,12 +53,13 @@ export interface IApiResponsePaliPara {
 /**
  * progress?view=chapter_channels&book=98&par=22
  */
-export interface IApiChapterChannels {
+export interface IChapterChannelData {
   book: number;
   para: number;
   uid: string;
   channel_id: string;
   progress: number;
+  progress_line?: number[];
   updated_at: string;
   views: number;
   likes: number[];
@@ -76,10 +78,10 @@ export interface IApiChapterChannels {
   studio: IStudio;
 }
 
-export interface IApiResponseChapterChannelList {
+export interface IChapterChannelListResponse {
   ok: boolean;
   message: string;
-  data: { rows: IApiChapterChannels[]; count: number };
+  data: { rows: IChapterChannelData[]; count: number };
 }
 
 export interface IApiChapterTag {
@@ -169,6 +171,11 @@ export interface ISentenceResponse {
   message: string;
   data: ISentenceData;
 }
+export interface ISentenceListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentenceData[]; count: number };
+}
 export interface ISentenceNewRequest {
   sentences: ISentenceDiffData[];
   channel?: string;
@@ -213,6 +220,7 @@ export interface IChapterData {
   like: number;
   status?: number;
   progress: number;
+  progress_line?: number[];
   created_at: string;
   updated_at: string;
 }
@@ -256,3 +264,9 @@ export interface ISentencePrResponse {
     webhook: { message: string; ok: boolean };
   };
 }
+
+export interface ISentenceSimListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IWidgetSentEditInner[]; count: number };
+}

+ 8 - 6
dashboard/src/components/api/Course.ts

@@ -89,18 +89,20 @@ export interface ICourseNumberResponse {
 }
 
 export type TCourseMemberStatus =
-  | "normal"
-  | "progressing"
-  | "accepted"
-  | "rejected"
-  | "left"
-  | "blocked";
+  | "normal" /*开放课程直接加入*/
+  | "invited" /**管理员已经邀请学生加入 */
+  | "sign_up" /**学生已经报名 管理员尚未审核 */
+  | "accepted" /**已经接受 */
+  | "rejected" /**已经拒绝 */
+  | "left" /**学生自己退出 */
+  | "blocked"; /**学生被管理员屏蔽 */
 export interface ICourseMemberData {
   id?: string;
   user_id: string;
   course_id: string;
   channel_id?: string;
   role?: string;
+  operating?: "invite" | "sign_up";
   user?: IUserRequest;
   status?: TCourseMemberStatus;
   created_at?: string;

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

@@ -1,3 +1,4 @@
+import { useIntl } from "react-intl";
 import { IStudio } from "../auth/StudioName";
 import { IUser } from "../auth/User";
 import { ICaseListData } from "../dict/CaseList";
@@ -48,6 +49,8 @@ export interface IApiResponseDictData {
   confidence: number;
   creator_id: number;
   updated_at: string;
+  exp?: number;
+  editor?: IUser;
 }
 export interface IApiResponseDict {
   ok: boolean;

+ 12 - 3
dashboard/src/components/api/Group.ts

@@ -1,18 +1,22 @@
 import { IStudio } from "../auth/StudioName";
-import { IStudioApiResponse, IUserRequest, TRole } from "./Auth";
+import { IUserRequest, TRole } from "./Auth";
 
 export interface IGroupRequest {
+  id?: string;
   name: string;
-  studio_name: string;
+  description?: string;
+  studio_name?: string;
 }
 
 export interface IGroupDataRequest {
   uid: string;
   name: string;
   description: string;
-  studio: IStudioApiResponse;
+  owner: string;
+  studio: IStudio;
   role: TRole;
   created_at: string;
+  updated_at: string;
 }
 export interface IGroupResponse {
   ok: boolean;
@@ -59,3 +63,8 @@ export interface IGroupMemberDeleteResponse {
   message: string;
   data: boolean;
 }
+export interface IDeleteResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}

+ 14 - 4
dashboard/src/components/api/Share.ts

@@ -1,22 +1,27 @@
 import { IUser } from "../auth/User";
+import { IGroup } from "../group/Group";
 import { TRole } from "./Auth";
 
 export interface IShareRequest {
   res_id: string;
-  res_type: string;
+  res_type: number;
   role: TRole;
-  user_id: string;
+  user_id: string[];
   user_type: string;
 }
-
+export interface IShareUpdateRequest {
+  role: TRole;
+}
 export interface IShareData {
-  id?: number;
+  id?: string;
   res_id: string;
   res_type: string;
   power?: number;
   res_name: string;
   user?: IUser;
+  group?: IGroup;
   owner?: IUser;
+  role?: TRole;
   created_at?: string;
   updated_at?: string;
 }
@@ -34,3 +39,8 @@ export interface IShareListResponse {
     count: number;
   };
 }
+export interface IShareDeleteResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}

+ 15 - 6
dashboard/src/components/api/Term.ts

@@ -1,12 +1,17 @@
+import { IStudio } from "../auth/StudioName";
+import { IUser } from "../auth/User";
+import { IChannel } from "../channel/Channel";
+
 export interface ITermDataRequest {
-  id: number;
+  id?: string;
   word: string;
-  tag: string;
+  tag?: string;
   meaning: string;
-  other_meaning: string;
-  note: string;
-  channal: string;
-  language: string;
+  other_meaning?: string;
+  note?: string;
+  channal?: string;
+  studioName?: string;
+  language?: string;
 }
 export interface ITermDataResponse {
   id: number;
@@ -17,6 +22,9 @@ export interface ITermDataResponse {
   other_meaning: string;
   note: string;
   channal: string;
+  channel?: IChannel;
+  studio: IStudio;
+  editor: IUser;
   language: string;
   created_at: string;
   updated_at: string;
@@ -48,6 +56,7 @@ export interface ITermCreate {
   meaningCount: IMeaningCount[];
   studioChannels: IStudioChannel[];
   language: string;
+  studio: IStudio;
 }
 export interface ITermCreateResponse {
   ok: boolean;

+ 46 - 0
dashboard/src/components/article/AddToAnthology.tsx

@@ -0,0 +1,46 @@
+import { Button, message } from "antd";
+import React from "react";
+import { post } from "../../request";
+import AnthologyModal from "../anthology/AnthologyModal";
+import { IArticleMapAddRequest, IArticleMapAddResponse } from "../api/Article";
+interface IWidget {
+  trigger?: React.ReactNode;
+  studioName?: string;
+  articleIds?: string[];
+  onFinally?: Function;
+}
+const Widget = ({ trigger, studioName, articleIds, onFinally }: IWidget) => {
+  return (
+    <AnthologyModal
+      studioName={studioName}
+      trigger={trigger ? trigger : <Button type="link">加入文集</Button>}
+      onSelect={(id: string) => {
+        if (typeof articleIds !== "undefined") {
+          post<IArticleMapAddRequest, IArticleMapAddResponse>(
+            "/v2/article-map",
+            {
+              anthology_id: id,
+              article_id: articleIds,
+              operation: "add",
+            }
+          )
+            .finally(() => {
+              if (typeof onFinally !== "undefined") {
+                onFinally();
+              }
+            })
+            .then((json) => {
+              if (json.ok) {
+                message.success(json.data);
+              } else {
+                message.error(json.message);
+              }
+            })
+            .catch((e) => console.error(e));
+        }
+      }}
+    />
+  );
+};
+
+export default Widget;

+ 1 - 5
dashboard/src/components/article/AnthologStudioList.tsx

@@ -20,10 +20,6 @@ const Widget = () => {
   const [tableData, setTableData] = useState<IAnthologyStudioData[]>([]);
   useEffect(() => {
     console.log("useEffect");
-    fetchData();
-  }, []);
-
-  function fetchData() {
     let url = `/v2/anthology?view=studio_list`;
     get<IAnthologyStudioListApiResponse>(url).then(function (json) {
       console.log("ajex", json);
@@ -35,7 +31,7 @@ const Widget = () => {
       });
       setTableData(newTree);
     });
-  }
+  }, []);
 
   return (
     <Card title="作者">

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

@@ -39,7 +39,11 @@ const Widget = (prop: IWidgetAnthologyCard) => {
   });
   return (
     <>
-      <Card hoverable bordered={false} style={{ width: "100%" }}>
+      <Card
+        hoverable
+        bordered={false}
+        style={{ width: "100%", borderRadius: 8 }}
+      >
         <Title level={4}>
           <Link to={`/anthology/${prop.data.id}`}>{prop.data.title}</Link>
         </Title>

+ 11 - 9
dashboard/src/components/article/AnthologyDetail.tsx

@@ -13,7 +13,7 @@ import TimeShow from "../general/TimeShow";
 import Marked from "../general/Marked";
 import AnthologyTocTree from "../anthology/AnthologyTocTree";
 
-const { Title, Text } = Typography;
+const { Title, Text, Paragraph } = Typography;
 
 interface IWidgetAnthologyDetail {
   aid?: string;
@@ -57,18 +57,20 @@ const Widget = ({ aid, channels, onArticleSelect }: IWidgetAnthologyDetail) => {
       });
   }
   return (
-    <>
+    <div style={{ padding: 12 }}>
       <Title level={4}>{tableData?.title}</Title>
       <div>
         <Text type="secondary">{tableData?.subTitle}</Text>
       </div>
-      <Space>
-        <StudioName data={tableData?.studio} />
-        <TimeShow time={tableData?.updated_at} title="updated" />
-      </Space>
-      <div>
+      <Paragraph>
+        <Space>
+          <StudioName data={tableData?.studio} />
+          <TimeShow time={tableData?.updated_at} title="updated" />
+        </Space>
+      </Paragraph>
+      <Paragraph>
         <Marked text={tableData?.summary} />
-      </div>
+      </Paragraph>
       <Title level={5}>目录</Title>
 
       <AnthologyTocTree
@@ -81,7 +83,7 @@ const Widget = ({ aid, channels, onArticleSelect }: IWidgetAnthologyDetail) => {
           }
         }}
       />
-    </>
+    </div>
   );
 };
 

+ 0 - 1
dashboard/src/components/article/AnthologyInfoEdit.tsx

@@ -1,5 +1,4 @@
 import { Form, message } from "antd";
-import { useState } from "react";
 import { useIntl } from "react-intl";
 import { ProForm, ProFormText } from "@ant-design/pro-components";
 import MDEditor from "@uiw/react-md-editor";

+ 55 - 34
dashboard/src/components/article/AnthologyList.tsx

@@ -6,54 +6,75 @@ import type { IAnthologyListResponse } from "../api/Article";
 import AnthologyCard from "./AnthologyCard";
 import type { IAnthologyData } from "./AnthologyCard";
 
-interface IWidgetAnthologyList {
-  view: string;
-  id?: string;
+interface IWidget {
+  studioName?: string;
+  searchKey?: string;
 }
-const Widget = (prop: IWidgetAnthologyList) => {
+const Widget = ({ studioName, searchKey }: IWidget) => {
   const [tableData, setTableData] = useState<IAnthologyData[]>([]);
+  const [total, setTotal] = useState<number>();
+  const [currPage, setCurrPage] = useState<number>(1);
+  const pageSize = 20;
 
   useEffect(() => {
-    console.log("useEffect", prop);
-    if (typeof prop.id === "undefined") {
-      fetchData(prop.view);
-    } else {
-      fetchData(prop.view, prop.id);
+    const offset = (currPage - 1) * pageSize;
+    let url = `/v2/anthology?view=public&offset=${offset}&limit=${pageSize}`;
+    if (typeof studioName !== "undefined") {
+      url += `&studio=${studioName}`;
+    }
+    if (typeof searchKey === "string" && searchKey.length > 0) {
+      url += `&search=${searchKey}`;
     }
-  }, [prop]);
 
-  function fetchData(view: string, id?: string) {
-    let url = `/v2/anthology?view=${view}` + (id ? `&studio=${id}` : "");
     console.log("get-url", url);
-    get<IAnthologyListResponse>(url).then(function (response) {
-      console.log("ajex", response);
-      let newTree: IAnthologyData[] = response.data.rows.map((item) => {
-        return {
-          id: item.uid,
-          title: item.title,
-          subTitle: item.subtitle,
-          summary: item.summary,
-          articles: item.article_list.map((al) => {
-            return {
-              key: al.article,
-              title: al.title,
-              level: parseInt(al.level),
-            };
-          }),
-          studio: item.studio,
-          created_at: item.created_at,
-          updated_at: item.updated_at,
-        };
-      });
-      setTableData(newTree);
+    get<IAnthologyListResponse>(url).then(function (json) {
+      if (json.ok) {
+        let newTree: IAnthologyData[] = json.data.rows.map((item) => {
+          return {
+            id: item.uid,
+            title: item.title,
+            subTitle: item.subtitle,
+            summary: item.summary,
+            articles: item.article_list.map((al) => {
+              return {
+                key: al.article,
+                title: al.title,
+                level: parseInt(al.level),
+              };
+            }),
+            studio: item.studio,
+            created_at: item.created_at,
+            updated_at: item.updated_at,
+          };
+        });
+        setTableData(newTree);
+        setTotal(json.data.count);
+      } else {
+        setTableData([]);
+        setTotal(0);
+      }
     });
-  }
+  }, [currPage, searchKey, studioName]);
 
   return (
     <List
       itemLayout="vertical"
       size="large"
       dataSource={tableData}
+      pagination={{
+        onChange: (page) => {
+          console.log(page);
+          setCurrPage(page);
+        },
+        showQuickJumper: true,
+        showSizeChanger: false,
+        pageSize: pageSize,
+        total: total,
+        position: "both",
+        showTotal: (total) => {
+          return `结果: ${total}`;
+        },
+      }}
       renderItem={(item) => (
         <List.Item>
           <AnthologyCard data={item} />

+ 173 - 118
dashboard/src/components/article/Article.tsx

@@ -1,8 +1,7 @@
 import { useEffect, useState } from "react";
-import { Divider, message, Tag } from "antd";
+import { Divider, message, Result, Tag } from "antd";
 
-import { modeChange } from "../../reducers/article-mode";
-import { get } from "../../request";
+import { get, post } from "../../request";
 import store from "../../store";
 import { IArticleDataResponse, IArticleResponse } from "../api/Article";
 import ArticleView from "./ArticleView";
@@ -17,11 +16,16 @@ import TocTree from "./TocTree";
 import PaliText from "../template/Wbw/PaliText";
 import ArticleSkeleton from "./ArticleSkeleton";
 
+import {
+  IViewRequest,
+  IViewStoreResponse,
+} from "../../pages/studio/recent/list";
+
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleType =
   | "article"
   | "chapter"
-  | "paragraph"
+  | "para"
   | "cs-para"
   | "sent"
   | "sim"
@@ -29,13 +33,35 @@ export type ArticleType =
   | "textbook"
   | "exercise"
   | "exercise-list"
-  | "corpus_sent/original"
-  | "corpus_sent/commentary"
-  | "corpus_sent/nissaya"
-  | "corpus_sent/translation";
+  | "sent-original"
+  | "sent-commentary"
+  | "sent-nissaya"
+  | "sent-translation"
+  | "term";
+/**
+ * 每种article type 对应的路由参数
+ * article/id?anthology=id&channel=id1,id2&mode=ArticleMode
+ * chapter/book-para?channel=id1,id2&mode=ArticleMode
+ * para/book?par=para1,para2&channel=id1,id2&mode=ArticleMode
+ * cs-para/book-para?channel=id1,id2&mode=ArticleMode
+ * sent/id?channel=id1,id2&mode=ArticleMode
+ * sim/id?channel=id1,id2&mode=ArticleMode
+ * textbook/articleId?course=id&mode=ArticleMode
+ * exercise/articleId?course=id&exercise=id&username=name&mode=ArticleMode
+ * exercise-list/articleId?course=id&exercise=id&mode=ArticleMode
+ * sent-original/id
+ */
 interface IWidgetArticle {
   type?: ArticleType;
+  id?: string;
+  book?: string | null;
+  para?: string | null;
+  channelId?: string | null;
   articleId?: string;
+  anthologyId?: string;
+  courseId?: string;
+  exerciseId?: string;
+  userName?: string;
   mode?: ArticleMode;
   active?: boolean;
   onArticleChange?: Function;
@@ -43,24 +69,28 @@ interface IWidgetArticle {
 }
 const Widget = ({
   type,
+  id,
+  book,
+  para,
+  channelId,
   articleId,
+  anthologyId,
+  courseId,
+  exerciseId,
+  userName,
   mode = "read",
   active = false,
   onArticleChange,
   onFinal,
 }: IWidgetArticle) => {
   const [articleData, setArticleData] = useState<IArticleDataResponse>();
-  const [articleMode, setArticleMode] = useState<ArticleMode>(mode);
+  const [articleMode, setArticleMode] = useState<ArticleMode>();
   const [extra, setExtra] = useState(<></>);
   const [showSkeleton, setShowSkeleton] = useState(true);
+  const [unauthorized, setUnauthorized] = useState(false);
+
+  const channels = channelId?.split("_");
 
-  let channels: string[] = [];
-  if (typeof articleId !== "undefined") {
-    const aId = articleId.split("_");
-    if (aId.length > 1) {
-      channels = aId.slice(1);
-    }
-  }
   useEffect(() => {
     /**
      * 由课本进入查询当前用户的权限和channel
@@ -101,142 +131,167 @@ const Widget = ({
     if (!active) {
       return;
     }
-    setArticleMode(mode);
-    //发布mode变更
-    store.dispatch(modeChange(mode));
 
+    //发布mode变更
+    //store.dispatch(modeChange(mode));
     if (mode !== articleMode && mode !== "read" && articleMode !== "read") {
       console.log("set mode", mode, articleMode);
+      setArticleMode(mode);
       return;
     }
-
-    if (typeof type !== "undefined" && typeof articleId !== "undefined") {
+    setArticleMode(mode);
+    if (typeof type !== "undefined") {
       let url = "";
       switch (type) {
+        case "chapter":
+          if (typeof articleId !== "undefined") {
+            url = `/v2/corpus-chapter/${articleId}?mode=${mode}`;
+            url += channelId ? `&channels=${channelId}` : "";
+          }
+          break;
+        case "para":
+          url = `/v2/corpus?view=para&book=${book}&par=${para}&mode=${mode}`;
+          url += channelId ? `&channels=${channelId}` : "";
+          break;
         case "article":
-          const aIds = articleId.split("_");
-          url = `/v2/article/${aIds[0]}?mode=${mode}`;
-          if (aIds.length > 1) {
-            const channels = aIds.slice(1);
-            url += "&channel=" + channels.join();
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?mode=${mode}`;
+            url += channelId ? `&channel=${channelId}` : "";
+            url += anthologyId ? `&anthology=${anthologyId}` : "";
           }
           break;
         case "textbook":
-          /**
-           * 从课本进入
-           * id两部分组成
-           * 课程id_文章id
-           */
-          const id = articleId.split("_");
-          if (id.length < 2) {
-            message.error("文章id期待2个,实际只给了一个");
-            return;
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?view=textbook&course=${courseId}&mode=${mode}`;
           }
-          url = `/v2/article/${id[1]}?mode=${mode}&view=textbook&course=${id[0]}`;
           break;
         case "exercise":
-          /**
-           * 从练习进入
-           * id 由4部分组成
-           * 课程id_文章id_练习id_username
-           */
-          const exerciseId = articleId.split("_");
-          if (exerciseId.length < 3) {
-            message.error("练习id期待3个");
-            return;
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?mode=${mode}&course=${courseId}&exercise=${exerciseId}&user=${userName}`;
+            setExtra(
+              <ExerciseAnswer
+                courseId={courseId}
+                articleId={articleId}
+                exerciseId={exerciseId}
+              />
+            );
           }
-          console.log("exe", exerciseId);
-          url = `/v2/article/${exerciseId[1]}?mode=${mode}&course=${exerciseId[0]}&exercise=${exerciseId[2]}&user=${exerciseId[3]}`;
-
-          setExtra(
-            <ExerciseAnswer
-              courseId={exerciseId[0]}
-              articleId={exerciseId[1]}
-              exerciseId={exerciseId[2]}
-            />
-          );
           break;
         case "exercise-list":
-          /**
-           * 从练习进入
-           * id 由3部分组成
-           * 课程id_文章id_练习id
-           */
-          const exerciseListId = articleId.split("_");
-          if (exerciseListId.length < 3) {
-            message.error("练习id期待3个");
-            return;
-          }
-          url = `/v2/article/${exerciseListId[1]}?mode=${mode}&course=${exerciseListId[0]}&exercise=${exerciseListId[2]}`;
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?mode=${mode}&course=${courseId}&exercise=${exerciseId}`;
 
-          //url = `/v2/article/${exerciseListId[1]}?mode=${mode}&course=${exerciseListId[0]}&exercise=${exerciseListId[2]}&list=true`;
-          setExtra(
-            <ExerciseList
-              courseId={exerciseListId[0]}
-              articleId={exerciseListId[1]}
-              exerciseId={exerciseListId[2]}
-            />
-          );
+            setExtra(
+              <ExerciseList
+                courseId={courseId}
+                articleId={articleId}
+                exerciseId={exerciseId}
+              />
+            );
+          }
           break;
         default:
-          const aid = articleId.split("_");
-
-          url = `/v2/corpus/${type}/${articleId}/${mode}?mode=${mode}`;
-          if (aid.length > 0) {
-            const channels = aid.slice(1).join();
-            url += `&channels=${channels}`;
+          if (typeof articleId !== "undefined") {
+            url = `/v2/corpus/${type}/${articleId}/${mode}?mode=${mode}`;
+            url += channelId ? `&channel=${channelId}` : "";
           }
           break;
       }
       console.log("url", url);
       setShowSkeleton(true);
-      get<IArticleResponse>(url).then((json) => {
-        console.log("article", json);
-        if (json.ok) {
-          setArticleData(json.data);
-          setShowSkeleton(false);
+      get<IArticleResponse>(url)
+        .then((json) => {
+          console.log("article", json);
+          if (json.ok) {
+            setArticleData(json.data);
+            setShowSkeleton(false);
 
-          setExtra(
-            <TocTree
-              treeData={json.data.toc?.map((item) => {
-                const strTitle = item.title ? item.title : item.pali_title;
-                const progress = item.progress?.map((item, id) => (
-                  <Tag key={id}>{Math.round(item * 100)}</Tag>
-                ));
+            setExtra(
+              <TocTree
+                treeData={json.data.toc?.map((item) => {
+                  const strTitle = item.title ? item.title : item.pali_title;
+                  const progress = item.progress?.map((item, id) => (
+                    <Tag key={id}>{Math.round(item * 100)}</Tag>
+                  ));
 
-                return {
-                  key: `${item.book}-${item.paragraph}`,
-                  title: (
-                    <>
-                      <PaliText text={strTitle} />
-                      {progress}
-                    </>
-                  ),
-                  level: item.level,
-                };
-              })}
-              onSelect={(keys: string[]) => {
-                console.log(keys);
-                if (typeof onArticleChange !== "undefined" && keys.length > 0) {
-                  const aid = articleId.split("_");
-                  const channels =
-                    aid.length > 1 ? "_" + aid.slice(1).join("_") : undefined;
-                  onArticleChange(keys[0] + channels);
+                  return {
+                    key: `${item.book}-${item.paragraph}`,
+                    title: (
+                      <>
+                        <PaliText text={strTitle} />
+                        {progress}
+                      </>
+                    ),
+                    level: item.level,
+                  };
+                })}
+                onSelect={(keys: string[]) => {
+                  console.log(keys);
+                  if (
+                    typeof onArticleChange !== "undefined" &&
+                    keys.length > 0
+                  ) {
+                    onArticleChange(keys[0]);
+                  }
+                }}
+              />
+            );
+
+            switch (type) {
+              case "chapter":
+                if (typeof articleId === "string" && channelId) {
+                  const [book, para] = articleId?.split("-");
+                  post<IViewRequest, IViewStoreResponse>("/v2/view", {
+                    target_type: type,
+                    book: parseInt(book),
+                    para: parseInt(para),
+                    channel: channelId,
+                    mode: mode,
+                  }).then((json) => {
+                    console.log("view", json.data);
+                  });
                 }
-              }}
-            />
-          );
-        } else {
-          message.error(json.message);
-        }
-      });
+
+                break;
+              default:
+                break;
+            }
+          } else {
+            setShowSkeleton(false);
+            setUnauthorized(true);
+            message.error(json.message);
+          }
+        })
+        .catch((e) => {
+          console.error(e);
+        });
     }
-  }, [active, type, articleId, mode, articleMode]);
+  }, [
+    active,
+    type,
+    articleId,
+    mode,
+    articleMode,
+    book,
+    para,
+    channelId,
+    anthologyId,
+    courseId,
+    exerciseId,
+    userName,
+  ]);
 
   return (
     <div>
       {showSkeleton ? (
         <ArticleSkeleton />
+      ) : unauthorized ? (
+        <Result
+          status="403"
+          title="无权访问"
+          subTitle="您无权访问该内容。您可能没有登录,或者内容的所有者没有给您所需的权限。"
+          extra={<></>}
+        />
       ) : (
         <ArticleView
           id={articleData?.uid}

+ 4 - 1
dashboard/src/components/article/ArticleCreate.tsx

@@ -15,13 +15,15 @@ interface IFormData {
   title: string;
   lang: string;
   studio: string;
+  anthologyId?: string;
 }
 
 interface IWidget {
   studio?: string;
+  anthologyId?: string;
   onSuccess?: Function;
 }
-const Widget = ({ studio, onSuccess }: IWidget) => {
+const Widget = ({ studio, anthologyId, onSuccess }: IWidget) => {
   const intl = useIntl();
   const formRef = useRef<ProFormInstance>();
 
@@ -34,6 +36,7 @@ const Widget = ({ studio, onSuccess }: IWidget) => {
           return;
         }
         values.studio = studio;
+        values.anthologyId = anthologyId;
         const res = await post<IArticleCreateRequest, IArticleResponse>(
           `/v2/article`,
           values

+ 11 - 13
dashboard/src/components/article/ArticleView.tsx

@@ -40,20 +40,18 @@ const Widget = ({
   let currChannelList = <></>;
   switch (type) {
     case "chapter":
-      const chapterProps = articleId?.split("_");
+      const chapterProps = articleId?.split("-");
       if (typeof chapterProps === "object" && chapterProps.length > 0) {
-        const para = chapterProps[0].split("-");
-        const channels =
-          chapterProps.length > 1 ? chapterProps.slice(1) : undefined;
-        if (typeof para === "object" && para.length > 1) {
-          currChannelList = (
-            <PaliChapterChannelList
-              para={{ book: parseInt(para[0]), para: parseInt(para[1]) }}
-              channelId={channels}
-              openTarget="_self"
-            />
-          );
-        }
+        currChannelList = (
+          <PaliChapterChannelList
+            para={{
+              book: parseInt(chapterProps[0]),
+              para: parseInt(chapterProps[1]),
+            }}
+            channelId={channels}
+            openTarget="_self"
+          />
+        );
       }
 
       break;

+ 23 - 4
dashboard/src/components/article/EditableTree.tsx

@@ -1,6 +1,6 @@
 import React, { useState } from "react";
 import { useEffect } from "react";
-import { Tree } from "antd";
+import { Tree, Typography } from "antd";
 import type { DataNode, TreeProps } from "antd/es/tree";
 import { Key } from "antd/lib/table/interface";
 import {
@@ -11,10 +11,13 @@ import {
 import { Button, Divider, Space } from "antd";
 import { useIntl } from "react-intl";
 
+const { Text } = Typography;
+
 interface TreeNodeData {
   key: string;
   title: string | React.ReactNode;
   children: TreeNodeData[];
+  deletedAt?: string;
   level: number;
 }
 export type ListNodeData = {
@@ -22,6 +25,7 @@ export type ListNodeData = {
   title: string | React.ReactNode;
   level: number;
   children?: number;
+  deletedAt?: string;
 };
 
 var tocActivePath: TreeNodeData[] = [];
@@ -47,6 +51,7 @@ function tocGetTreeData(articles: ListNodeData[], active = "") {
       title: element.title,
       children: [],
       level: element.level,
+      deletedAt: element.deletedAt,
     };
     /*
 		if (active == element.article) {
@@ -130,8 +135,8 @@ const Widget = ({
 
   const [gData, setGData] = useState<TreeNodeData[]>([]);
   const [listTreeData, setListTreeData] = useState<ListNodeData[]>();
-
   const [keys, setKeys] = useState<Key>("");
+
   useEffect(() => {
     const data = tocGetTreeData(treeData);
     console.log("tree data", data);
@@ -208,10 +213,10 @@ const Widget = ({
       }
     }
     setGData(data);
+    const list = treeToList(data);
+    setListTreeData(list);
     if (typeof onChange !== "undefined") {
-      const list = treeToList(data);
       onChange(list);
-      setListTreeData(list);
     }
   };
 
@@ -242,6 +247,11 @@ const Widget = ({
 
             console.log("delete", keys, find, tmp);
             setGData(tmp);
+            const list = treeToList(tmp);
+            setListTreeData(list);
+            if (typeof onChange !== "undefined") {
+              onChange(list);
+            }
           }}
         >
           {intl.formatMessage({ id: "buttons.remove" })}
@@ -278,6 +288,15 @@ const Widget = ({
           }
         }}
         treeData={gData}
+        titleRender={(node: TreeNodeData) => {
+          return node.deletedAt ? (
+            <Text delete disabled>
+              {node.title}
+            </Text>
+          ) : (
+            <>{node.title}</>
+          );
+        }}
       />
     </>
   );

+ 3 - 3
dashboard/src/components/article/MainMenu.tsx

@@ -1,5 +1,5 @@
 import { Button, Dropdown } from "antd";
-import { MenuOutlined } from "@ant-design/icons";
+import { AppstoreOutlined } from "@ant-design/icons";
 import { mainMenuItems } from "../library/HeadBar";
 
 const Widget = () => {
@@ -10,9 +10,9 @@ const Widget = () => {
       trigger={["click"]}
     >
       <Button
+        type="text"
         style={{ display: "block" }}
-        size="small"
-        icon={<MenuOutlined />}
+        icon={<AppstoreOutlined />}
       ></Button>
     </Dropdown>
   );

+ 3 - 3
dashboard/src/components/article/TermShell.tsx

@@ -3,10 +3,10 @@ import { useEffect, useState } from "react";
 import { useAppSelector } from "../../hooks";
 import { message } from "../../reducers/command";
 
-import TermCreate, { IWidgetDictCreate } from "../term/TermCreate";
+import TermEdit, { ITerm } from "../term/TermEdit";
 
 const Widget = () => {
-  const [termProps, setTermProps] = useState<IWidgetDictCreate>();
+  const [termProps, setTermProps] = useState<ITerm>();
   //接收术语消息
   const commandMsg = useAppSelector(message);
   useEffect(() => {
@@ -17,7 +17,7 @@ const Widget = () => {
   }, [commandMsg]);
   return (
     <div>
-      <TermCreate type="inline" {...termProps} />
+      <TermEdit {...termProps} />
     </div>
   );
 };

+ 22 - 10
dashboard/src/components/article/TocTree.tsx

@@ -1,15 +1,18 @@
-import { Tree } from "antd";
-
-import type { DataNode, TreeProps } from "antd/es/tree";
+import { Tree, Typography } from "antd";
+import type { TreeProps } from "antd/es/tree";
 import { useEffect, useState } from "react";
+
 import type { ListNodeData } from "./EditableTree";
 import PaliText from "../template/Wbw/PaliText";
 
-interface TreeNodeData {
+const { Text } = Typography;
+
+export interface TreeNodeData {
   key: string;
   title: string | React.ReactNode;
   children?: TreeNodeData[];
   level: number;
+  deletedAt?: string;
 }
 
 function tocGetTreeData(
@@ -36,6 +39,7 @@ function tocGetTreeData(
       key: element.key,
       title: element.title,
       level: element.level,
+      deletedAt: element.deletedAt,
     };
 
     if (newNode.level > iCurrLevel) {
@@ -119,12 +123,20 @@ const Widget = ({ treeData, expandedKey, onSelect }: IWidgetTocTree) => {
       defaultExpandedKeys={expanded}
       defaultSelectedKeys={expanded}
       blockNode
-      titleRender={(node: DataNode) => {
-        if (typeof node.title === "string") {
-          return <PaliText text={node.title} />;
-        } else {
-          return <>{node.title}</>;
-        }
+      titleRender={(node: TreeNodeData) => {
+        const currNode =
+          typeof node.title === "string" ? (
+            <PaliText text={node.title} />
+          ) : (
+            <>{node.title}</>
+          );
+        return node.deletedAt ? (
+          <Text delete disabled>
+            {currNode}
+          </Text>
+        ) : (
+          <>{currNode}</>
+        );
       }}
     />
   );

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

@@ -22,7 +22,7 @@ const Widget = ({ icon, content, title }: IWidget) => {
       </Tooltip>
       <Drawer
         title={title}
-        width={350}
+        width={460}
         placement="left"
         onClose={() => {
           setOpen(false);

+ 131 - 0
dashboard/src/components/article/ToolButtonDiscussion.tsx

@@ -0,0 +1,131 @@
+import { useEffect, useState } from "react";
+import { Button, Tag, Tree } from "antd";
+import { CommentOutlined } from "@ant-design/icons";
+
+import ToolButton from "./ToolButton";
+import { post } from "../../request";
+import { IUser } from "../auth/User";
+
+interface IPrTreeData {
+  book: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  channel_id: string;
+  content: string;
+  pr_count: number;
+}
+interface IPrTreeRequestData {
+  book: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  channel_id: string;
+}
+interface IPrData {
+  title: string;
+  children_count: number;
+  editor?: IUser;
+}
+interface IPrTreeRequest {
+  data: IPrTreeRequestData[];
+}
+interface IPrTreeResponseData {
+  sentence: IPrTreeData;
+  pr: IPrData[];
+}
+interface IPrTreeResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IPrTreeResponseData[]; count: number };
+}
+interface DataNode {
+  title: string;
+  key: string;
+  isLeaf?: boolean;
+  children?: DataNode[];
+}
+
+interface IWidget {
+  type?: string;
+  articleId?: string;
+}
+const Widget = ({ type, articleId }: IWidget) => {
+  const [treeData, setTreeData] = useState<DataNode[]>([]);
+
+  const refresh = () => {
+    const pr = document.querySelectorAll("div.pr_icon[has-disc='true']");
+
+    let prRequestData: IPrTreeRequestData[] = [];
+    for (let index = 0; index < pr.length; index++) {
+      const element = pr[index];
+      const id = element.id.split("_");
+      prRequestData.push({
+        book: parseInt(id[0]),
+        paragraph: parseInt(id[1]),
+        word_start: parseInt(id[2]),
+        word_end: parseInt(id[3]),
+        channel_id: id[4],
+      });
+    }
+    console.log("request pr tree", prRequestData);
+    post<IPrTreeRequest, IPrTreeResponse>("/v2/sent-discussion-tree", {
+      data: prRequestData,
+    }).then((json) => {
+      console.log("discussion tree", json);
+      if (json.ok) {
+        const newTree: DataNode[] = json.data.rows.map((item) => {
+          const children = item.pr.map((pr) => {
+            return { title: pr.title, key: pr.title };
+          });
+          return {
+            title: item.sentence.content,
+            key: `${item.sentence.book}_${item.sentence.paragraph}_${item.sentence.word_start}_${item.sentence.word_end}_${item.sentence.channel_id}`,
+            children: children,
+          };
+        });
+        setTreeData(newTree);
+      }
+    });
+  };
+
+  useEffect(() => {
+    refresh();
+  }, []);
+  return (
+    <ToolButton
+      title="讨论"
+      icon={<CommentOutlined />}
+      content={
+        <>
+          <Button
+            onClick={() => {
+              refresh();
+            }}
+          >
+            refresh
+          </Button>
+          <Tree
+            treeData={treeData}
+            titleRender={(node) => {
+              const ele = document.getElementById(node.key);
+              const count = node.children?.length;
+              return (
+                <div
+                  onClick={() => {
+                    ele?.scrollIntoView();
+                  }}
+                >
+                  {node.title}
+                  <Tag style={{ borderRadius: 5 }}>{count}</Tag>
+                </div>
+              );
+            }}
+          />
+        </>
+      }
+    />
+  );
+};
+
+export default Widget;

+ 2 - 1
dashboard/src/components/auth/StudioName.tsx

@@ -5,7 +5,8 @@ import StudioCard from "./StudioCard";
 export interface IStudio {
   id: string;
   nickName: string;
-  studioName: string;
+  studioName?: string;
+  realName?: string;
   avatar?: string;
 }
 interface IWidghtStudio {

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

@@ -15,6 +15,8 @@ const Widget = () => {
       <Divider>翻译</Divider>
 
       <Divider>逐词解析</Divider>
+      <Divider>字典</Divider>
+      <SettingItem data={SettingFind("setting.dict.lang")} />
     </div>
   );
 };

+ 96 - 28
dashboard/src/components/auth/setting/SettingItem.tsx

@@ -1,6 +1,13 @@
 import { useIntl } from "react-intl";
 import { useEffect, useState } from "react";
-import { Switch, Typography, Radio, RadioChangeEvent, Select } from "antd";
+import {
+  Switch,
+  Typography,
+  Radio,
+  RadioChangeEvent,
+  Select,
+  Transfer,
+} from "antd";
 
 import {
   onChange as onSettingChanged,
@@ -10,23 +17,32 @@ import {
 import { useAppSelector } from "../../../hooks";
 import store from "../../../store";
 import { ISetting } from "./default";
+import { TransferDirection } from "antd/lib/transfer";
 
-const { Title, Text } = Typography;
+const { Text } = Typography;
 
 interface IWidgetSettingItem {
   data?: ISetting;
+  autoSave?: boolean;
   onChange?: Function;
 }
-const Widget = ({ data, onChange }: IWidgetSettingItem) => {
+const Widget = ({ data, onChange, autoSave = true }: IWidgetSettingItem) => {
   const intl = useIntl();
   const settings: ISettingItem[] | undefined = useAppSelector(settingInfo);
   const [value, setValue] = useState(data?.defaultValue);
-  const title = (
-    <Title level={5}>
-      {data?.label ? intl.formatMessage({ id: data.label }) : ""}
-    </Title>
-  );
-  console.log(data);
+
+  const [targetKeys, setTargetKeys] = useState<string[]>([]);
+
+  useEffect(() => {
+    if (typeof data?.defaultValue === "object") {
+      setTargetKeys(data.defaultValue);
+    }
+  }, [data?.defaultValue]);
+
+  useEffect(() => {
+    setValue(data?.defaultValue);
+  }, [data?.defaultValue]);
+
   useEffect(() => {
     const currSetting = settings?.find((element) => element.key === data?.key);
     if (typeof currSetting !== "undefined") {
@@ -38,10 +54,50 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
   if (typeof data === "undefined") {
     return content;
   } else {
-    const description: string = intl.formatMessage({ id: data.description });
+    const description: string | undefined = data.description
+      ? intl.formatMessage({ id: data.description })
+      : undefined;
     switch (typeof data.defaultValue) {
       case "number":
         break;
+      case "object":
+        switch (data.widget) {
+          case "transfer":
+            if (typeof data.options !== "undefined") {
+              content = (
+                <Transfer
+                  dataSource={data.options.map((item) => {
+                    return {
+                      key: item.value,
+                      title: intl.formatMessage({ id: item.label }),
+                    };
+                  })}
+                  titles={["备选", "我的选择"]}
+                  targetKeys={targetKeys}
+                  onChange={(
+                    newTargetKeys: string[],
+                    direction: TransferDirection,
+                    moveKeys: string[]
+                  ) => {
+                    setTargetKeys(newTargetKeys);
+                    store.dispatch(
+                      onSettingChanged({
+                        key: data.key,
+                        value: newTargetKeys,
+                      })
+                    );
+                    if (typeof onChange !== "undefined") {
+                      onChange(data.key, newTargetKeys);
+                    }
+                  }}
+                  render={(item) => item.title}
+                  oneWay
+                />
+              );
+            }
+            break;
+        }
+        break;
       case "string":
         switch (data.widget) {
           case "radio-button":
@@ -53,12 +109,17 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
                     buttonStyle="solid"
                     onChange={(e: RadioChangeEvent) => {
                       setValue(e.target.value);
-                      store.dispatch(
-                        onSettingChanged({
-                          key: data.key,
-                          value: e.target.value,
-                        })
-                      );
+                      if (autoSave) {
+                        store.dispatch(
+                          onSettingChanged({
+                            key: data.key,
+                            value: e.target.value,
+                          })
+                        );
+                      }
+                      if (typeof onChange !== "undefined") {
+                        onChange(data.key, e.target.value);
+                      }
                     }}
                   >
                     {data.options.map((item, id) => {
@@ -72,8 +133,8 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
                 </>
               );
             }
-
             break;
+
           default:
             if (typeof data.options !== "undefined") {
               content = (
@@ -83,12 +144,17 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
                     style={{ width: 120 }}
                     onChange={(value: string) => {
                       console.log(`selected ${value}`);
-                      store.dispatch(
-                        onSettingChanged({
-                          key: data.key,
-                          value: value,
-                        })
-                      );
+                      if (autoSave) {
+                        store.dispatch(
+                          onSettingChanged({
+                            key: data.key,
+                            value: value,
+                          })
+                        );
+                      }
+                      if (typeof onChange !== "undefined") {
+                        onChange(data.key, value);
+                      }
                     }}
                     options={data.options.map((item) => {
                       return {
@@ -110,13 +176,15 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
             <Switch
               defaultChecked={value as boolean}
               onChange={(checked) => {
+                console.log("setting changed", data.key, checked);
+                if (autoSave) {
+                  store.dispatch(
+                    onSettingChanged({ key: data.key, value: checked })
+                  );
+                }
                 if (typeof onChange !== "undefined") {
-                  onChange(checked);
+                  onChange(data.key, checked);
                 }
-                console.log("setting changed", data.key, checked);
-                store.dispatch(
-                  onSettingChanged({ key: data.key, value: checked })
-                );
               }}
             />
           </div>

+ 45 - 6
dashboard/src/components/auth/setting/default.ts

@@ -1,4 +1,5 @@
-import { ISettingItem } from "../../../reducers/setting";
+import { useAppSelector } from "../../../hooks";
+import { ISettingItem, settingInfo } from "../../../reducers/setting";
 
 export interface ISettingItemOption {
   label: string;
@@ -7,10 +8,10 @@ export interface ISettingItemOption {
 export interface ISetting {
   key: string;
   label: string;
-  description: string;
-  defaultValue: string | number | boolean;
+  description?: string;
+  defaultValue: string | number | boolean | string[];
   value?: string | number | boolean;
-  widget?: "input" | "select" | "radio" | "radio-button";
+  widget?: "input" | "select" | "radio" | "radio-button" | "transfer";
   options?: ISettingItemOption[];
   max?: number;
   min?: number;
@@ -19,7 +20,7 @@ export interface ISetting {
 export const GetUserSetting = (
   key: string,
   curr?: ISettingItem[]
-): string | number | boolean | undefined => {
+): string | number | boolean | string[] | undefined => {
   const currSetting = curr?.find((element) => element.key === key);
   if (typeof currSetting !== "undefined") {
     return currSetting.value;
@@ -34,7 +35,13 @@ export const GetUserSetting = (
 };
 
 export const SettingFind = (key: string): ISetting | undefined => {
-  return defaultSetting.find((element) => element.key === key);
+  const settings = useAppSelector(settingInfo);
+  const userSetting = GetUserSetting(key, settings);
+  let result = defaultSetting.find((element) => element.key === key);
+  if (userSetting && result) {
+    result.defaultValue = userSetting;
+  }
+  return result;
 };
 
 export const defaultSetting: ISetting[] = [
@@ -145,4 +152,36 @@ export const defaultSetting: ISetting[] = [
       },
     ],
   },
+  {
+    /**
+     * 字典语言
+     */
+    key: "setting.dict.lang",
+    label: "setting.dict.lang.label",
+    description: "setting.dict.lang.description",
+    defaultValue: ["zh-Hans"],
+    widget: "transfer",
+    options: [
+      {
+        value: "en",
+        label: "languages.en-US",
+      },
+      {
+        value: "zh-Hans",
+        label: "languages.zh-Hans",
+      },
+      {
+        value: "zh-Hant",
+        label: "languages.zh-Hant",
+      },
+      {
+        value: "my",
+        label: "languages.my",
+      },
+      {
+        value: "vi",
+        label: "languages.vi",
+      },
+    ],
+  },
 ];

+ 19 - 12
dashboard/src/components/channel/ChannelPickerTable.tsx

@@ -11,7 +11,7 @@ import {
 } from "@ant-design/icons";
 
 import { IApiResponseChannelList, IFinal, TChannelType } from "../api/Channel";
-import { get, post } from "../../request";
+import { post } from "../../request";
 import { LockIcon } from "../../assets/icon";
 import StudioName, { IStudio } from "../auth/StudioName";
 import ProgressSvg from "./ProgressSvg";
@@ -21,7 +21,6 @@ import CopyToModal from "./CopyToModal";
 
 import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
-import { sentenceList } from "../../reducers/sentence";
 
 const { Link } = Typography;
 
@@ -64,7 +63,6 @@ const Widget = ({
   const [showCheckBox, setShowCheckBox] = useState<boolean>(false);
   const user = useAppSelector(_currentUser);
   const ref = useRef<ActionType>();
-  const sentences = useAppSelector(sentenceList);
 
   useEffect(() => {
     if (reload) {
@@ -150,19 +148,21 @@ const Widget = ({
         request={async (params = {}, sorter, filter) => {
           // TODO
           console.log(params, sorter, filter);
-          let url: string = "";
-          if (typeof articleId !== "undefined") {
-            const id = articleId.split("_");
-            const [book, para] = id[0].split("-");
-            url = `/v2/channel-progress?view=user-in-chapter&book=${book}&para=${para}&progress=sent`;
+          const sentElement = document.querySelectorAll(".pcd_sent");
+          let sentList: string[] = [];
+          for (let index = 0; index < sentElement.length; index++) {
+            const element = sentElement[index];
+            const id = element.id.split("_")[1];
+            sentList.push(id);
           }
+          console.log("sentList", sentList);
           const res = await post<IProgressRequest, IApiResponseChannelList>(
-            url,
+            `/v2/channel-progress`,
             {
-              sentence: sentences,
+              sentence: sentList,
             }
           );
-          console.log("data", res.data.rows);
+          console.log("progress data", res.data.rows);
           const items: IItem[] = res.data.rows.map((item, id) => {
             const date = new Date(item.created_at);
             let all: number = 0;
@@ -208,10 +208,17 @@ const Widget = ({
           );
           console.log("user:", user);
           setSelectedRowKeys(selectedRowKeys);
+          const channelData = [
+            ...currChannel,
+            ...progressing,
+            ...myChannel,
+            ...others,
+          ];
+          console.log("channel list ", channelData);
           return {
             total: res.data.count,
             succcess: true,
-            data: [...currChannel, ...progressing, ...myChannel, ...others],
+            data: channelData,
           };
         }}
         rowKey="uid"

+ 72 - 0
dashboard/src/components/channel/ChannelSelect.tsx

@@ -0,0 +1,72 @@
+import { ProFormCascader } from "@ant-design/pro-components";
+import { message } from "antd";
+
+import { get } from "../../request";
+import { IApiResponseChannelList } from "../api/Channel";
+
+interface IOption {
+  value: string;
+  label: string;
+}
+
+interface IWidget {
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  channelId?: string;
+  name?: string;
+  tooltip?: string;
+  label?: string;
+  onSelect?: Function;
+}
+const Widget = ({
+  width = "md",
+  channelId,
+  name = "channel",
+  tooltip,
+  label,
+  onSelect,
+}: IWidget) => {
+  return (
+    <ProFormCascader
+      width={width}
+      name={name}
+      tooltip={tooltip}
+      label={label}
+      request={async ({ keyWords }) => {
+        console.log("keyWord", keyWords);
+        const json = await get<IApiResponseChannelList>(
+          `/v2/channel?view=user-edit&key=${keyWords}`
+        );
+        if (json.ok) {
+          //获取studio list
+          let studio = new Map<string, string>();
+          for (const iterator of json.data.rows) {
+            studio.set(iterator.studio.id, iterator.studio.nickName);
+          }
+          let channels: IOption[] = [];
+
+          studio.forEach((value, key, map) => {
+            const node = {
+              value: key,
+              label: value,
+              children: json.data.rows
+                .filter((value) => value.studio.id === key)
+                .map((item) => {
+                  return { value: item.uid, label: item.name };
+                }),
+            };
+            channels.push(node);
+          });
+
+          console.log("json", channels);
+          return channels;
+        } else {
+          message.error(json.message);
+          return [];
+        }
+      }}
+      fieldProps={{}}
+    />
+  );
+};
+
+export default Widget;

+ 15 - 14
dashboard/src/components/channel/ChannelSentDiff.tsx

@@ -187,20 +187,21 @@ const Widget = ({
           开始复制
         </Button>
       </div>
-      <List
-        header={<div>Header</div>}
-        footer={<div>Footer</div>}
-        bordered
-        dataSource={diffData}
-        renderItem={(item) => (
-          <List.Item>
-            <Row style={{ width: "100%" }}>
-              <Col span={12}>{item.srcContent}</Col>
-              <Col span={12}>{item.destContent}</Col>
-            </Row>
-          </List.Item>
-        )}
-      />
+      <div style={{ height: 400, overflowY: "scroll" }}>
+        <List
+          footer={<div style={{ textAlign: "center" }}>到底了</div>}
+          bordered
+          dataSource={diffData}
+          renderItem={(item) => (
+            <List.Item>
+              <Row style={{ width: "100%" }}>
+                <Col span={12}>{item.srcContent}</Col>
+                <Col span={12}>{item.destContent}</Col>
+              </Row>
+            </List.Item>
+          )}
+        />
+      </div>
     </div>
   );
 };

+ 248 - 0
dashboard/src/components/channel/ChapterInChannelList.tsx

@@ -0,0 +1,248 @@
+import { useIntl } from "react-intl";
+import { Progress, Typography } from "antd";
+import { ProTable } from "@ant-design/pro-components";
+import { Link } from "react-router-dom";
+import { Space, Table } from "antd";
+import { Button, Dropdown } from "antd";
+import { DeleteOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+
+import { IChapterListResponse } from "../../components/api/Corpus";
+
+const { Text } = Typography;
+
+interface IItem {
+  sn: number;
+  title: string;
+  subTitle: string;
+  summary: string;
+  book: number;
+  paragraph: number;
+  path: string;
+  progress: number;
+  view: number;
+  createdAt: number;
+  updatedAt: number;
+}
+interface IWidget {
+  channelId?: string;
+  onChange?: Function;
+}
+const Widget = ({ channelId, onChange }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <ProTable<IItem>
+      columns={[
+        {
+          title: intl.formatMessage({
+            id: "dict.fields.sn.label",
+          }),
+          dataIndex: "sn",
+          key: "sn",
+          width: 50,
+          search: false,
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.title.label",
+          }),
+          dataIndex: "title",
+          key: "title",
+          tip: "过长会自动收缩",
+          ellipsis: true,
+          render: (text, row, index, action) => {
+            return (
+              <div key={index}>
+                <div key={1}>
+                  <Link
+                    to={
+                      `/article/chapter/${row.book}-${row.paragraph}` +
+                      channelId
+                        ? `?channel=${channelId}`
+                        : ""
+                    }
+                  >
+                    {row.title ? row.title : row.subTitle}
+                  </Link>
+                </div>
+                <Text type="secondary" key={2}>
+                  {row.subTitle}
+                </Text>
+              </div>
+            );
+          },
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.summary.label",
+          }),
+          dataIndex: "summary",
+          key: "summary",
+          tip: "过长会自动收缩",
+          ellipsis: true,
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.publicity.label",
+          }),
+          dataIndex: "progress",
+          key: "progress",
+          width: 100,
+          search: false,
+          render: (text, row, index, action) => {
+            const per = Math.round(row.progress * 100);
+            return <Progress percent={per} size="small" key={index} />;
+          },
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.publicity.label",
+          }),
+          dataIndex: "view",
+          key: "view",
+          width: 100,
+          search: false,
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.created-at.label",
+          }),
+          key: "created-at",
+          width: 100,
+          search: false,
+          dataIndex: "createdAt",
+          valueType: "date",
+          sorter: (a, b) => a.createdAt - b.createdAt,
+        },
+        {
+          title: intl.formatMessage({ id: "buttons.option" }),
+          key: "option",
+          width: 120,
+          valueType: "option",
+          render: (text, row, index, action) => {
+            return [
+              <Dropdown.Button
+                key={index}
+                type="link"
+                menu={{
+                  items: [
+                    {
+                      key: "remove",
+                      disabled: true,
+                      danger: true,
+                      label: intl.formatMessage({
+                        id: "buttons.delete",
+                      }),
+                      icon: <DeleteOutlined />,
+                    },
+                  ],
+                  onClick: (e) => {
+                    switch (e.key) {
+                      case "remove":
+                        break;
+                      default:
+                        break;
+                    }
+                  },
+                }}
+              >
+                <Link
+                  to={
+                    `/article/chapter/${row.book}-${row.paragraph}/edit` +
+                    channelId
+                      ? `?channel=${channelId}`
+                      : ""
+                  }
+                >
+                  {intl.formatMessage({
+                    id: "buttons.edit",
+                  })}
+                </Link>
+              </Dropdown.Button>,
+            ];
+          },
+        },
+      ]}
+      rowSelection={{
+        // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+        // 注释该行则默认不显示下拉选项
+        selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+      }}
+      tableAlertRender={({
+        selectedRowKeys,
+        selectedRows,
+        onCleanSelected,
+      }) => (
+        <Space size={24}>
+          <span>
+            {intl.formatMessage({ id: "buttons.selected" })}
+            {selectedRowKeys.length}
+            <Button
+              type="link"
+              style={{ marginInlineStart: 8 }}
+              onClick={onCleanSelected}
+            >
+              {intl.formatMessage({ id: "buttons.unselect" })}
+            </Button>
+          </span>
+        </Space>
+      )}
+      tableAlertOptionRender={() => {
+        return (
+          <Space size={16}>
+            <Button type="link">
+              {intl.formatMessage({
+                id: "buttons.delete.all",
+              })}
+            </Button>
+          </Space>
+        );
+      }}
+      request={async (params = {}, sorter, filter) => {
+        // TODO
+        console.log(params, sorter, filter);
+        const offset = (params.current || 1 - 1) * (params.pageSize || 20);
+        const res = await get<IChapterListResponse>(
+          `/v2/progress?view=chapter&channel=${channelId}&progress=0.01&offset=${offset}`
+        );
+        console.log(res.data.rows);
+        const items: IItem[] = res.data.rows.map((item, id) => {
+          const createdAt = new Date(item.created_at);
+          const updatedAt = new Date(item.updated_at);
+          return {
+            sn: id + offset + 1,
+            book: item.book,
+            paragraph: item.para,
+            view: item.view,
+            title: item.title,
+            subTitle: item.toc,
+            summary: item.summary,
+            path: item.path,
+            progress: item.progress,
+            createdAt: createdAt.getTime(),
+            updatedAt: updatedAt.getTime(),
+          };
+        });
+        return {
+          total: res.data.count,
+          succcess: true,
+          data: items,
+        };
+      }}
+      rowKey="id"
+      bordered
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+      }}
+      search={false}
+      options={{
+        search: true,
+      }}
+    />
+  );
+};
+
+export default Widget;

+ 52 - 45
dashboard/src/components/channel/CopyToStep.tsx

@@ -4,8 +4,6 @@ import { useEffect, useState } from "react";
 import { ArticleType } from "../article/Article";
 import { IChannel } from "./Channel";
 import ChannelPickerTable from "./ChannelPickerTable";
-import { useAppSelector } from "../../hooks";
-import { sentenceList } from "../../reducers/sentence";
 import ChannelSentDiff from "./ChannelSentDiff";
 import CopyToResult from "./CopyToResult";
 
@@ -31,8 +29,13 @@ const Widget = ({
   const [destChannel, setDestChannel] = useState<IChannel>();
   const [copyPercent, setCopyPercent] = useState<number>();
 
-  const sentences = useAppSelector(sentenceList);
-
+  let sentList: string[] = [];
+  const sentElement = document.querySelectorAll(".pcd_sent");
+  for (let index = 0; index < sentElement.length; index++) {
+    const element = sentElement[index];
+    const id = element.id.split("_")[1];
+    sentList.push(id);
+  }
   useEffect(() => {
     setCurrent(initStep);
   }, [initStep]);
@@ -44,72 +47,76 @@ const Widget = ({
   const prev = () => {
     setCurrent(current - 1);
   };
+  const contentStyle: React.CSSProperties = {
+    borderRadius: 5,
+    border: `1px dashed gray`,
+    marginTop: 16,
+    height: 400,
+    overflowY: "scroll",
+  };
   const steps = [
     {
       title: "选择目标版本",
       key: "channel",
       content: (
-        <ChannelPickerTable
-          type="editable"
-          multiSelect={false}
-          onSelect={(e: IChannel) => {
-            console.log(e);
-            setDestChannel(e);
-            next();
-          }}
-        />
+        <div style={contentStyle}>
+          <ChannelPickerTable
+            type="editable"
+            multiSelect={false}
+            onSelect={(e: IChannel[]) => {
+              console.log("channel", e);
+              if (e.length > 0) {
+                setDestChannel(e[0]);
+                setCopyPercent(100);
+                next();
+              }
+            }}
+          />
+        </div>
       ),
     },
     {
       title: "文本比对",
       key: "diff",
       content: (
-        <div>
-          <ChannelSentDiff
-            srcChannel={channel}
-            destChannel={destChannel}
-            sentences={sentences}
-            goPrev={() => {
-              prev();
-            }}
-            onSubmit={() => {
-              next();
-            }}
-          />
-        </div>
+        <ChannelSentDiff
+          srcChannel={channel}
+          destChannel={destChannel}
+          sentences={sentList}
+          goPrev={() => {
+            prev();
+          }}
+          onSubmit={() => {
+            next();
+          }}
+        />
       ),
     },
     {
       title: "完成",
       key: "finish",
       content: (
-        <CopyToResult
-          onClose={() => {
-            if (typeof onClose !== "undefined") {
-              onClose();
-            }
-          }}
-          onInit={() => {
-            setCurrent(0);
-          }}
-        />
+        <div style={contentStyle}>
+          <CopyToResult
+            onClose={() => {
+              if (typeof onClose !== "undefined") {
+                onClose();
+              }
+            }}
+            onInit={() => {
+              setCurrent(0);
+            }}
+          />
+        </div>
       ),
     },
   ];
   const items = steps.map((item) => ({ key: item.key, title: item.title }));
 
-  const contentStyle: React.CSSProperties = {
-    backgroundColor: "#fafafa",
-    borderRadius: 5,
-    border: `1px dashed gray`,
-    marginTop: 16,
-    height: 400,
-    overflowY: "scroll",
-  };
   return (
     <div>
       <Steps current={current} items={items} percent={copyPercent} />
-      <div style={contentStyle}>{steps[current].content}</div>
+      {steps[current].content}
     </div>
   );
 };

+ 56 - 0
dashboard/src/components/channel/StudioSelect.tsx

@@ -0,0 +1,56 @@
+import { Select } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import { IStudio } from "../auth/StudioName";
+
+interface IStudioListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IStudio[];
+    count: number;
+  };
+}
+
+interface IOptions {
+  value: string;
+  label: string;
+}
+interface IWidget {
+  studioName?: string;
+  onSelect?: Function;
+}
+const Widget = ({ studioName, onSelect }: IWidget) => {
+  const [anthology, setAnthology] = useState<IOptions[]>([
+    { value: "all", label: "全部" },
+  ]);
+  useEffect(() => {
+    let url = `/v2/studio?view=collaboration-channel&studio_name=${studioName}`;
+    get<IStudioListResponse>(url).then((json) => {
+      if (json.ok) {
+        const data = json.data.rows.map((item) => {
+          return {
+            value: item.id,
+            label: item.nickName,
+          };
+        });
+        setAnthology([{ value: "all", label: "全部" }, ...data]);
+      }
+    });
+  }, [studioName]);
+  return (
+    <Select
+      defaultValue="all"
+      style={{ width: 180 }}
+      onChange={(value: string) => {
+        console.log(`selected ${value}`);
+        if (typeof onSelect !== "undefined") {
+          onSelect(value);
+        }
+      }}
+      options={anthology}
+    />
+  );
+};
+
+export default Widget;

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

@@ -26,6 +26,9 @@ const Widget = ({ trigger, resId, resType, onCommentCountChange }: IWidget) => {
 
   const onClose = () => {
     setOpen(false);
+    if (document.getElementsByTagName("body")[0].hasAttribute("style")) {
+      document.getElementsByTagName("body")[0].removeAttribute("style");
+    }
   };
 
   const showChildrenDrawer = (

+ 34 - 7
dashboard/src/components/comment/CommentCreate.tsx

@@ -16,6 +16,7 @@ import { ICommentRequest, ICommentResponse } from "../api/Comment";
 import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 import { useRef } from "react";
+import MDEditor from "@uiw/react-md-editor";
 
 export type TContentType = "text" | "markdown" | "html";
 
@@ -24,12 +25,12 @@ interface IWidget {
   resType?: string;
   parent?: string;
   onCreated?: Function;
-  editor?: TContentType;
+  contentType?: TContentType;
 }
 const Widget = ({
-  resId = "",
-  resType = "",
-  editor = "html",
+  resId,
+  resType,
+  contentType = "html",
   parent,
   onCreated,
 }: IWidget) => {
@@ -72,6 +73,7 @@ const Widget = ({
                 parent: parent,
                 title: values.title,
                 content: values.content,
+                content_type: contentType,
               })
                 .then((json) => {
                   console.log("new discussion", json);
@@ -126,7 +128,7 @@ const Widget = ({
                 rules={[{ required: true, message: "这是必填项" }]}
               />
             )}
-            {editor === "text" ? (
+            {contentType === "text" ? (
               <ProFormTextArea
                 name="content"
                 label={intl.formatMessage({ id: "forms.fields.content.label" })}
@@ -134,13 +136,38 @@ const Widget = ({
                   id: "forms.fields.content.placeholder",
                 })}
               />
-            ) : editor === "html" ? (
+            ) : contentType === "html" ? (
               <Form.Item
                 name="content"
                 label={intl.formatMessage({ id: "forms.fields.content.label" })}
                 tooltip="可以直接粘贴屏幕截图"
               >
-                <ReactQuill theme="snow" style={{ height: 220 }} />
+                <ReactQuill
+                  theme="snow"
+                  style={{ height: 180 }}
+                  modules={{
+                    toolbar: [
+                      ["bold", "italic", "underline", "strike"],
+                      ["blockquote", "code-block"],
+                      [{ header: 1 }, { header: 2 }],
+                      [{ list: "ordered" }, { list: "bullet" }],
+                      [{ indent: "-1" }, { indent: "+1" }],
+                      [{ size: ["small", false, "large", "huge"] }],
+                      [{ header: [1, 2, 3, 4, 5, 6, false] }],
+                      ["link", "image", "video"],
+                      [{ color: [] }, { background: [] }],
+                      [{ font: [] }],
+                      [{ align: [] }],
+                    ],
+                  }}
+                />
+              </Form.Item>
+            ) : contentType === "markdown" ? (
+              <Form.Item
+                name="content"
+                label={intl.formatMessage({ id: "forms.fields.content.label" })}
+              >
+                <MDEditor />
               </Form.Item>
             ) : (
               <></>

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

@@ -1,14 +1,12 @@
 import { useIntl } from "react-intl";
 import { Button, Card } from "antd";
 import { message } from "antd";
-
 import { ProForm, ProFormTextArea } from "@ant-design/pro-components";
 import { Col, Row, Space } from "antd";
 
 import { IComment } from "./CommentItem";
 import { put } from "../../request";
 import { ICommentRequest, ICommentResponse } from "../api/Comment";
-import Editor from "@uiw/react-md-editor/lib/Editor";
 
 interface IWidget {
   data: IComment;

+ 8 - 8
dashboard/src/components/comment/CommentItem.tsx

@@ -1,4 +1,4 @@
-import { Avatar, Col, Row } from "antd";
+import { Avatar } from "antd";
 import { useState } from "react";
 import { IUser } from "../auth/User";
 import CommentShow from "./CommentShow";
@@ -26,11 +26,11 @@ const Widget = ({ data, onSelect, onCreated }: IWidget) => {
   const [edit, setEdit] = useState(false);
   console.log(data);
   return (
-    <Row>
-      <Col flex={"2em"} style={{ padding: 8 }}>
-        <Avatar>{data.user?.nickName?.slice(0, 1)}</Avatar>
-      </Col>
-      <Col flex={"auto"}>
+    <div style={{ display: "flex" }}>
+      <div style={{ width: "2em" }}>
+        <Avatar size="small">{data.user?.nickName?.slice(0, 1)}</Avatar>
+      </div>
+      <div style={{ width: "100%" }}>
         {edit ? (
           <CommentEdit
             data={data}
@@ -48,8 +48,8 @@ const Widget = ({ data, onSelect, onCreated }: IWidget) => {
             }}
           />
         )}
-      </Col>
-    </Row>
+      </div>
+    </div>
   );
 };
 

+ 1 - 1
dashboard/src/components/comment/CommentList.tsx

@@ -1,4 +1,4 @@
-import { List, Avatar, Space } from "antd";
+import { List, Space } from "antd";
 import { MessageOutlined } from "@ant-design/icons";
 
 import { IComment } from "./CommentItem";

+ 4 - 3
dashboard/src/components/comment/CommentListCard.tsx

@@ -30,7 +30,7 @@ const Widget = ({
   const [data, setData] = useState<IComment[]>([]);
   useEffect(() => {
     console.log("changedAnswerCount", changedAnswerCount);
-    const newData = data.map((item) => {
+    const newData = [...data].map((item) => {
       const newItem = item;
       if (newItem.id && changedAnswerCount?.id === newItem.id) {
         newItem.childrenCount = changedAnswerCount.count;
@@ -77,7 +77,7 @@ const Widget = ({
       .catch((e) => {
         message.error(e.message);
       });
-  }, [resId, topicId]);
+  }, [intl, resId, topicId]);
 
   if (typeof resId === "undefined" && typeof topicId === "undefined") {
     return <div>该资源尚未创建,不能发表讨论。</div>;
@@ -85,7 +85,7 @@ const Widget = ({
 
   return (
     <div>
-      <Card title="问答" extra={<a href="#">More</a>}>
+      <Card title="讨论" extra={"More"}>
         {data.length > 0 ? (
           <CommentList
             onSelect={(
@@ -102,6 +102,7 @@ const Widget = ({
 
         {resId && resType ? (
           <CommentCreate
+            contentType="markdown"
             resId={resId}
             resType={resType}
             onCreated={(e: IComment) => {

+ 2 - 1
dashboard/src/components/comment/CommentShow.tsx

@@ -55,6 +55,7 @@ const Widget = ({ data, onEdit, onSelect }: IWidget) => {
   return (
     <div>
       <Card
+        size="small"
         title={
           <Space>
             {data.user.nickName}
@@ -70,7 +71,7 @@ const Widget = ({ data, onEdit, onSelect }: IWidget) => {
             ></Button>
           </Dropdown>
         }
-        style={{ width: "auto" }}
+        style={{ width: "100%" }}
       >
         <span
           onClick={(e) => {

+ 2 - 1
dashboard/src/components/comment/CommentTopicChildren.tsx

@@ -43,7 +43,7 @@ const Widget = ({ topicId, onItemCountChange }: IWidget) => {
       .catch((e) => {
         message.error(e.message);
       });
-  }, [topicId]);
+  }, [intl, topicId]);
   return (
     <div>
       <List
@@ -62,6 +62,7 @@ const Widget = ({ topicId, onItemCountChange }: IWidget) => {
         )}
       />
       <CommentCreate
+        contentType="markdown"
         parent={topicId}
         onCreated={(e: IComment) => {
           console.log("create", e);

+ 1 - 1
dashboard/src/components/comment/CommentTopicInfo.tsx

@@ -44,7 +44,7 @@ const Widget = ({ topicId }: IWidget) => {
   }, [topicId]);
   return (
     <div>
-      <Title editable level={3} style={{ margin: 0 }}>
+      <Title editable level={5} style={{ margin: 0 }}>
         {data?.title}
       </Title>
       <div>

+ 97 - 30
dashboard/src/components/corpus/BookTree.tsx

@@ -1,42 +1,57 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, Key } from "react";
 import { DownOutlined } from "@ant-design/icons";
-import { Layout, Space, Tree } from "antd";
+import { Button, Space, Switch, Tree } from "antd";
 import { Typography } from "antd";
-import type { TreeProps } from "antd/es/tree";
 
 import { get } from "../../request";
-import { useNavigate } from "react-router-dom";
 import TocStyleSelect from "./TocStyleSelect";
 import { IPaliBookListResponse } from "../api/Corpus";
 import { ITocTree } from "./BookTreeList";
 import { PaliToEn } from "../../utils";
+import PaliText from "../template/Wbw/PaliText";
 
 const { Text } = Typography;
 
 interface IWidgetBookTree {
   root?: string;
   path?: string[];
+  multiSelect?: boolean;
+  multiSelectable?: boolean;
   onChange?: Function;
+  onSelect?: Function;
+  onRootChange?: Function;
 }
-const Widget = ({ root = "default", path, onChange }: IWidgetBookTree) => {
-  //Library foot bar
-  //const intl = useIntl(); //i18n
-  const navigate = useNavigate();
-
+const Widget = ({
+  root,
+  path,
+  multiSelect = false,
+  multiSelectable = true,
+  onChange,
+  onSelect,
+  onRootChange,
+}: IWidgetBookTree) => {
   const [treeData, setTreeData] = useState<ITocTree[]>([]);
+  const [selectedKeys, setSelectedKeys] = useState<Key[]>([]);
+  const [isMultiSelect, setIsMultiSelect] = useState(multiSelect);
+  const [currTocStyle, setCurrTocStyle] = useState<string>();
 
   useEffect(() => {
-    if (typeof root !== "undefined") fetchBookTree(root);
-  }, [root]);
+    setIsMultiSelect(multiSelect);
+  }, [multiSelect]);
 
-  const onSelect: TreeProps["onSelect"] = (selectedKeys, info) => {
-    //let aaa: NewTree = info.node;
-    const node: ITocTree = info.node as unknown as ITocTree;
-    console.log("tree selected", selectedKeys, node.path);
-    if (typeof onChange !== "undefined" && selectedKeys.length > 0) {
-      onChange(selectedKeys[0], node.path);
+  useEffect(() => {
+    let tocStyle = "default";
+    if (typeof root !== "undefined") {
+      tocStyle = root;
+    } else {
+      const store = localStorage.getItem("pali_path_root");
+      if (store) {
+        tocStyle = store;
+      }
     }
-  };
+    fetchBookTree(tocStyle);
+    setCurrTocStyle(tocStyle);
+  }, [root]);
 
   function fetchBookTree(value: string) {
     function treeMap(params: IPaliBookListResponse): ITocTree {
@@ -63,28 +78,80 @@ const Widget = ({ root = "default", path, onChange }: IWidgetBookTree) => {
       setTreeData(newTree);
     });
   }
-  const handleChange = (value: string) => {
-    console.log(`selected ${value}`);
-    localStorage.setItem("pali_path_root", value);
-    navigate("/palicanon/list/" + value);
-    fetchBookTree(value);
-  };
 
   // TODO
   return (
-    <Layout>
-      <Space>
-        <Text>目录风格</Text>
-        <TocStyleSelect style={root} onChange={handleChange} />
+    <Space direction="vertical" style={{ padding: 10, width: "100%" }}>
+      <Space style={{ display: "flex", justifyContent: "space-between" }}>
+        <Text>目录</Text>
+        <TocStyleSelect
+          style={currTocStyle}
+          onChange={(value: string) => {
+            console.log(`selected ${value}`);
+            localStorage.setItem("pali_path_root", value);
+            if (typeof onRootChange !== "undefined") {
+              onRootChange(value);
+            }
+            setCurrTocStyle(value);
+            fetchBookTree(value);
+          }}
+        />
       </Space>
+
+      <Space style={{ display: "flex", justifyContent: "space-between" }}>
+        <Button
+          onClick={() => {
+            setSelectedKeys([]);
+            if (typeof onChange !== "undefined") {
+              onChange([], []);
+            }
+          }}
+        >
+          清除选择
+        </Button>
+        {multiSelectable ? (
+          <Space>
+            {"多选"}
+            <Switch
+              size="small"
+              defaultChecked={multiSelect}
+              onChange={(checked) => {
+                setIsMultiSelect(checked);
+              }}
+            />
+          </Space>
+        ) : undefined}
+      </Space>
+
       <Tree
+        selectedKeys={selectedKeys}
+        multiple={isMultiSelect}
         showLine
         switcherIcon={<DownOutlined />}
         defaultExpandedKeys={["sutta"]}
-        onSelect={onSelect}
+        onCheck={(checkedKeysValue, info) => {
+          console.log("onCheck", checkedKeysValue);
+          //setCheckedKeys(checkedKeysValue);
+        }}
+        onSelect={(selectedKeys, info) => {
+          console.log("tree selected", selectedKeys, info);
+          setSelectedKeys(selectedKeys);
+          //let aaa: NewTree = info.node;
+          const node: ITocTree = info.node as unknown as ITocTree;
+
+          if (typeof onChange !== "undefined") {
+            onChange(selectedKeys, node.path);
+          }
+          if (typeof onSelect !== "undefined") {
+            onSelect(selectedKeys.length > 0 ? selectedKeys[0] : undefined);
+          }
+        }}
         treeData={treeData}
+        titleRender={(node: ITocTree) => {
+          return <PaliText text={node.title} />;
+        }}
       />
-    </Layout>
+    </Space>
   );
 };
 

+ 91 - 55
dashboard/src/components/corpus/BookTreeList.tsx

@@ -1,12 +1,13 @@
-import { Link } from "react-router-dom";
+import { Link, useNavigate } from "react-router-dom";
 import { useState, useEffect } from "react";
 import { List, Breadcrumb, Card, Row, Col } from "antd";
 import { HomeOutlined } from "@ant-design/icons";
 
-import { PaliToEn } from "../../utils";
 import { get } from "../../request";
 import { IPaliBookListResponse } from "../api/Corpus";
 import TocStyleSelect from "./TocStyleSelect";
+import FullSearchInput from "../fts/FullSearchInput";
+import PaliText from "../template/Wbw/PaliText";
 
 export interface IEventBookTreeOnchange {
   path: string[];
@@ -27,37 +28,76 @@ interface pathData {
 interface IWidgetBookTreeList {
   root?: string;
   path?: string[];
+  tags?: string[];
   onChange?: Function;
+  onTocLoad?: Function;
 }
-const Widget = ({ root, path, onChange }: IWidgetBookTreeList) => {
-  console.log("path", path);
-  let currRoot = root;
+const Widget = ({
+  root = "default",
+  path,
+  tags,
+  onChange,
+  onTocLoad,
+}: IWidgetBookTreeList) => {
   const [tocData, setTocData] = useState<ITocTree[]>([]);
   const [currData, setCurrData] = useState<ITocTree[]>([]);
   const [bookPath, setBookPath] = useState<pathData[]>([]);
+  const [currRoot, setCurrRoot] = useState<string>();
+  const navigate = useNavigate();
 
   useEffect(() => {
+    setCurrRoot(root);
+  }, [root]);
+
+  useEffect(() => {
+    let mPath: string[] = [];
     const newPath: pathData[] = path
       ? path.map((item) => {
-          return { to: item, title: item };
+          mPath.push(item);
+          return { to: mPath.join("_"), title: item };
         })
       : [];
+
     setBookPath(newPath);
-    //TODO 找到路径
-    const currPath = getListCurrRoot(tocData, newPath);
-    console.log("curr path", currPath);
-    setCurrData(currPath);
-  }, [path]);
+    const currDir = getListCurrRoot(tocData, mPath);
+    console.log("currDir", currDir);
+    setCurrData(currDir);
+  }, [path, tocData]);
 
   useEffect(() => {
-    if (root) {
-      fetchBookTree(root);
+    function treeMap(params: IPaliBookListResponse): ITocTree {
+      return {
+        title: params.name,
+        dir: params.name.toLowerCase(),
+        key: params.tag.join(),
+        tag: params.tag,
+        children: Array.isArray(params.children)
+          ? params.children.map(treeMap)
+          : [],
+      };
     }
-  }, [root]);
+    if (currRoot) {
+      get<IPaliBookListResponse[]>(`/v2/palibook/${currRoot}`).then((json) => {
+        console.log("Book List ajax", json);
+        const treeData = json.map(treeMap);
+        setTocData(treeData);
+        if (typeof onTocLoad !== "undefined") {
+          onTocLoad(json);
+        }
+      });
+    }
+  }, [currRoot]);
+
+  useEffect(() => {
+    const currPath =
+      bookPath.length > 0 ? bookPath[bookPath.length - 1].to.split("_") : [];
+    const currDir = getListCurrRoot(tocData, currPath);
+    setCurrData(currDir);
+  }, [bookPath, tocData]);
 
   function getListCurrRoot(
     allTocData: ITocTree[],
-    currPath: pathData[]
+    currPath: string[]
   ): ITocTree[] {
     let curr: ITocTree[];
     if (allTocData.length > 0) {
@@ -69,7 +109,7 @@ const Widget = ({ root, path, onChange }: IWidgetBookTreeList) => {
     for (const itPath of currPath) {
       let isFound = false;
       for (const itAll of curr) {
-        if (itPath.to === itAll.dir) {
+        if (itPath === itAll.dir) {
           curr = itAll.children;
           isFound = true;
           break;
@@ -81,52 +121,34 @@ const Widget = ({ root, path, onChange }: IWidgetBookTreeList) => {
     }
     return curr;
   }
-  function fetchBookTree(category: string) {
-    function treeMap(params: IPaliBookListResponse): ITocTree {
-      return {
-        title: params.name,
-        dir: PaliToEn(params.name),
-        key: params.tag.join(),
-        tag: params.tag,
-        children: Array.isArray(params.children)
-          ? params.children.map(treeMap)
-          : [],
-      };
-    }
-
-    get<IPaliBookListResponse[]>(`/v2/palibook/${category}`).then((json) => {
-      console.log("ajax", json);
-      const treeData = json.map(treeMap);
-      setTocData(treeData);
-      const currPath = getListCurrRoot(treeData, bookPath);
-      console.log("curr path", currPath);
-      setCurrData(currPath);
-    });
-  }
 
   function pushDir(dir: string, title: string, tag: string[]): void {
-    const newPath: string =
-      bookPath.length > 0 ? bookPath.slice(-1)[0].to + "-" + dir : dir;
+    console.log("push dir", dir, title);
+
+    const newPath: string = [...bookPath.map((item) => item.title), dir].join(
+      "_"
+    );
     bookPath.push({ to: newPath, title: title });
+    console.log("newPath", newPath);
+    console.log("book Path", bookPath);
+
     setBookPath(bookPath);
     if (typeof onChange !== "undefined") {
       onChange({
-        path: newPath.split("-"),
+        path: newPath.split("_"),
         tag: tag,
       });
     }
   }
-  const handleChange = (value: string) => {
-    console.log(`selected ${value}`);
-    fetchBookTree(value);
-    currRoot = value;
-    setBookPath([]);
-  };
-  // TODO
+
   return (
     <>
       <Row style={{ padding: 10 }}>
-        <Col xs={18} sm={24}>
+        <Col
+          xs={18}
+          sm={24}
+          style={{ display: "flex", justifyContent: "space-between" }}
+        >
           <Breadcrumb>
             <Breadcrumb.Item>
               <Link to={`/palicanon/list/${currRoot}`}>
@@ -137,29 +159,43 @@ const Widget = ({ root, path, onChange }: IWidgetBookTreeList) => {
               return (
                 <Breadcrumb.Item key={id}>
                   <Link to={`/palicanon/list/${currRoot}/${item.to}`}>
-                    {item.title}
+                    <PaliText text={item.title} />
                   </Link>
                 </Breadcrumb.Item>
               );
             })}
           </Breadcrumb>
+          <FullSearchInput
+            tags={tags}
+            onSearch={(value: string) => {
+              navigate(`/search/key/${value}?tags=${tags}`);
+            }}
+          />
         </Col>
         <Col xs={6} sm={0} style={{ textAlign: "right" }}>
-          <TocStyleSelect style={root} onChange={handleChange} />
+          <TocStyleSelect
+            style={currRoot}
+            onChange={(value: string) => {
+              setCurrRoot(value);
+            }}
+          />
         </Col>
       </Row>
-      <Card>
+      <Card style={{ display: currData.length === 0 ? "none" : "block" }}>
         <List
           dataSource={currData}
           renderItem={(item) => (
             <List.Item
               onClick={() => {
-                console.log("click", item.title);
                 setCurrData(item.children);
-                pushDir(item.dir, item.title, item.tag);
+                pushDir(
+                  item.title.toLowerCase(),
+                  item.title.toLowerCase(),
+                  item.tag
+                );
               }}
             >
-              {item.title}
+              <PaliText text={item.title} />
             </List.Item>
           )}
         />

+ 27 - 11
dashboard/src/components/corpus/BookViewer.tsx

@@ -4,6 +4,7 @@ import PaliChapterChannelList from "./PaliChapterChannelList";
 import PaliChapterListByPara from "./PaliChapterListByPara";
 import PaliChapterHead from "./PaliChapterHead";
 import { IChapterClickEvent } from "./PaliChapterList";
+import { Tabs } from "antd";
 
 export interface IChapter {
   book: number;
@@ -15,31 +16,46 @@ interface IWidget {
   onChange?: Function;
 }
 const Widget = ({ chapter, onChange }: IWidget) => {
-  const [currChapter, setCurrChpater] = useState(chapter);
+  const [currChapter, setCurrChapter] = useState(chapter);
   useEffect(() => {
     if (typeof onChange !== "undefined") {
       onChange(currChapter);
     }
-  }, [currChapter]);
+  }, [currChapter, onChange]);
 
   useEffect(() => {
-    setCurrChpater(chapter);
+    setCurrChapter(chapter);
   }, [chapter]);
   return (
     <>
       <PaliChapterHead
         onChange={(e: IChapter) => {
-          setCurrChpater(e);
+          setCurrChapter(e);
         }}
         para={currChapter}
       />
-      <PaliChapterChannelList para={currChapter} />
-      <PaliChapterListByPara
-        chapter={currChapter}
-        onChapterClick={(e: IChapterClickEvent) => {
-          setCurrChpater({ book: e.para.Book, para: e.para.Paragraph });
-          console.log("PaliChapterListByPara", "onchange", e);
-        }}
+      <Tabs
+        size="small"
+        items={[
+          {
+            label: `目录`,
+            key: "toc",
+            children: (
+              <PaliChapterListByPara
+                chapter={currChapter}
+                onChapterClick={(e: IChapterClickEvent) => {
+                  setCurrChapter({ book: e.para.Book, para: e.para.Paragraph });
+                  console.log("PaliChapterListByPara", "onchange", e);
+                }}
+              />
+            ),
+          },
+          {
+            label: `资源`,
+            key: "res",
+            children: <PaliChapterChannelList para={currChapter} />,
+          },
+        ]}
       />
     </>
   );

+ 49 - 0
dashboard/src/components/corpus/ChapterAppendTag.tsx

@@ -0,0 +1,49 @@
+import { Button, Popover } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+
+import type { ChannelFilterProps } from "../channel/ChannelList";
+import ChapterTagList from "./ChapterTagList";
+
+interface IWidget {
+  filter?: ChannelFilterProps;
+  progress?: number;
+  lang?: string;
+  type?: string;
+  tags?: string[];
+  onTagClick?: Function;
+}
+const Widget = ({
+  progress = 0.9,
+  lang = "zh",
+  type = "translation",
+  tags = [],
+  onTagClick,
+}: IWidget) => {
+  return (
+    <Popover
+      content={
+        <div style={{ width: 600 }}>
+          <ChapterTagList
+            tags={tags}
+            progress={progress}
+            lang={lang}
+            type={type}
+            onTagClick={(tag: string) => {
+              if (typeof onTagClick !== "undefined") {
+                onTagClick(tag);
+              }
+            }}
+          />
+        </div>
+      }
+      placement="bottom"
+      trigger="hover"
+    >
+      <Button type="dashed" icon={<PlusOutlined />}>
+        添加标签
+      </Button>
+    </Popover>
+  );
+};
+
+export default Widget;

+ 5 - 5
dashboard/src/components/corpus/ChapterCard.tsx

@@ -8,7 +8,7 @@ import TagArea from "../tag/TagArea";
 import type { IChannelApiData } from "../api/Channel";
 import ChannelListItem from "../channel/ChannelListItem";
 import { IStudio } from "../auth/StudioName";
-import { ITagData } from "./ChapterTagList";
+import { ITagData } from "./ChapterTag";
 
 const { Title, Paragraph, Text } = Typography;
 
@@ -23,6 +23,7 @@ export interface ChapterData {
   channel: IChannelApiData;
   studio: IStudio;
   progress: number;
+  progressLine?: number[];
   createdAt: string;
   updatedAt: string;
   hit: number;
@@ -36,6 +37,8 @@ interface IWidgetChapterCard {
 
 const Widget = ({ data, onTagClick }: IWidgetChapterCard) => {
   const path = JSON.parse(data.path);
+  let url = `/article/chapter/${data.book}-${data.paragraph}`;
+  url += data.channel.id ? `?channel=${data.channel.id}` : "";
   return (
     <>
       <Row>
@@ -43,10 +46,7 @@ const Widget = ({ data, onTagClick }: IWidgetChapterCard) => {
           <Row>
             <Col span={16}>
               <Title level={5}>
-                <Link
-                  to={`/article/chapter/${data.book}-${data.paragraph}_${data.channel.id}`}
-                  target="_blank"
-                >
+                <Link to={url} target="_blank">
                   {data.title ? data.title : data.paliTitle}
                 </Link>
               </Title>

+ 4 - 4
dashboard/src/components/corpus/ChapterFilter.tsx

@@ -10,21 +10,21 @@ interface IWidget {
   onTypeChange?: Function;
   onLangChange?: Function;
   onProgressChange?: Function;
-  onSearchChange?: Function;
+  onSearch?: Function;
 }
 const Widget = ({
   onTypeChange,
   onLangChange,
   onProgressChange,
-  onSearchChange,
+  onSearch,
 }: IWidget) => {
   return (
     <Space style={{ margin: 8 }}>
       <Search
         placeholder="标题搜索"
         onSearch={(value: string) => {
-          if (typeof onSearchChange !== "undefined") {
-            onSearchChange(value);
+          if (typeof onSearch !== "undefined") {
+            onSearch(value);
           }
         }}
         style={{ width: 200 }}

+ 8 - 6
dashboard/src/components/corpus/ChapterFilterLang.tsx

@@ -13,12 +13,14 @@ const Widget = ({ onSelect }: IWidget) => {
   useEffect(() => {
     get<IChapterLangListResponse>(`/v2/progress?view=lang`).then((json) => {
       if (json.ok) {
-        const langs = json.data.rows.map((item) => {
-          return {
-            value: item.lang,
-            label: `${item.lang}-${item.count}`,
-          };
-        });
+        const langs = json.data.rows
+          .filter((value) => value.lang !== "")
+          .map((item) => {
+            return {
+              value: item.lang,
+              label: `${item.lang}-${item.count}`,
+            };
+          });
         setLang(langs);
       }
     });

+ 48 - 30
dashboard/src/components/corpus/ChapterInChannel.tsx

@@ -1,6 +1,7 @@
-import { Button, Col, List, Modal, Progress, Row, Space, Tabs } from "antd";
+import { Button, Col, List, Modal, Row, Space, Tabs } from "antd";
 import { Typography } from "antd";
 import { LikeOutlined, EyeOutlined } from "@ant-design/icons";
+import { TinyLine } from "@ant-design/plots";
 
 import { IChannelApiData } from "../api/Channel";
 import ChannelListItem from "../channel/ChannelListItem";
@@ -16,6 +17,7 @@ export interface IChapterChannelData {
   channel: IChannelApiData;
   studio: IStudio;
   progress: number;
+  progressLine?: number[];
   hit: number;
   like: number;
   updatedAt: string;
@@ -57,35 +59,51 @@ const Widget = ({
                 },
               }
         }
-        renderItem={(item, id) => (
-          <List.Item key={id}>
-            <Row>
-              <Col span={12}>
-                <Link
-                  to={`/article/chapter/${book}-${para}_${item.channel.id}`}
-                  target={openTarget}
-                >
-                  <ChannelListItem
-                    channel={item.channel}
-                    studio={item.studio}
-                  />
-                </Link>
-              </Col>
-              <Col span={12}>
-                <Progress percent={item.progress} size="small" />
-              </Col>
-            </Row>
-
-            <Text type="secondary">
-              <Space style={{ paddingLeft: "2em" }}>
-                <EyeOutlined />
-                {item.hit} | <LikeOutlined />
-                {item.like} |
-                <TimeShow time={item.updatedAt} title={item.updatedAt} />
-              </Space>
-            </Text>
-          </List.Item>
-        )}
+        renderItem={(item, id) => {
+          const channel = item.channel.id ? `?channel=${item.channel.id}` : "";
+          return (
+            <List.Item key={id}>
+              <Row>
+                <Col span={12}>
+                  <Link
+                    to={`/article/chapter/${book}-${para}${channel}`}
+                    target={openTarget}
+                  >
+                    <ChannelListItem
+                      channel={item.channel}
+                      studio={item.studio}
+                    />
+                  </Link>
+                </Col>
+                <Col span={12}>
+                  {item.progressLine ? (
+                    <TinyLine
+                      height={32}
+                      width={150}
+                      autoFit={false}
+                      data={item.progressLine}
+                      smooth={true}
+                    />
+                  ) : (
+                    <></>
+                  )}
+                </Col>
+              </Row>
+              <div style={{ display: "flex", justifyContent: "space-between" }}>
+                <Text type="secondary">
+                  <Space style={{ paddingLeft: "2em" }}>
+                    <EyeOutlined />
+                    {item.hit} | <LikeOutlined />
+                    {item.like} |
+                    <TimeShow time={item.updatedAt} /> |
+                    <EyeOutlined />
+                    {`${item.progress}%`}
+                  </Space>
+                </Text>
+              </div>
+            </List.Item>
+          );
+        }}
       />
     ) : (
       <></>

+ 13 - 15
dashboard/src/components/corpus/ChapterList.tsx

@@ -13,10 +13,12 @@ interface IWidget {
   lang?: string;
   type?: string;
   tags?: string[];
+  searchKey?: string;
   onTagClick?: Function;
 }
 
 const Widget = ({
+  searchKey,
   progress = 0.9,
   lang = "zh",
   type = "translation",
@@ -27,19 +29,16 @@ const Widget = ({
   const [total, setTotal] = useState<number>();
   const [currPage, setCurrPage] = useState<number>(1);
   useEffect(() => {
-    fetchData(
-      { chapterProgress: progress, lang: lang, channelType: type },
-      tags,
-      currPage
-    );
-  }, [progress, lang, type, tags, currPage]);
-
-  function fetchData(filter: ChannelFilterProps, tags: string[], page = 1) {
-    const strTags = tags.length > 0 ? "&tags=" + tags.join() : "";
-    const offset = (page - 1) * 20;
-    get<IChapterListResponse>(
-      `/v2/progress?view=chapter${strTags}&offset=${offset}&progress=${filter.chapterProgress}&lang=${filter.lang}&channel_type=${filter.channelType}`
-    ).then((json) => {
+    let url: string;
+    if (typeof searchKey === "string" && searchKey.length > 0) {
+      url = `/v2/progress?view=search&key=${searchKey}`;
+    } else {
+      const strTags = tags.length > 0 ? "&tags=" + tags.join() : "";
+      const offset = (currPage - 1) * 20;
+      url = `/v2/progress?view=chapter${strTags}&offset=${offset}&progress=${progress}&lang=${lang}&channel_type=${type}`;
+    }
+    console.log("url", url);
+    get<IChapterListResponse>(url).then((json) => {
       console.log("chapter list ajax", json);
       if (json.ok) {
         let newTree: ChapterData[] = json.data.rows.map(
@@ -76,7 +75,7 @@ const Widget = ({
         setTableData([]);
       }
     });
-  }
+  }, [progress, lang, type, tags, currPage, searchKey]);
 
   return (
     <List
@@ -85,7 +84,6 @@ const Widget = ({
       dataSource={tableData}
       pagination={{
         onChange: (page) => {
-          console.log(page);
           setCurrPage(page);
         },
         showQuickJumper: true,

+ 64 - 0
dashboard/src/components/corpus/ChapterTag.tsx

@@ -0,0 +1,64 @@
+import { Tag, Tooltip } from "antd";
+import PaliText from "../template/Wbw/PaliText";
+
+export interface ITagData {
+  title: string;
+  key: string;
+  color?: string;
+  count?: number;
+  description?: string;
+}
+
+interface IWidget {
+  data?: ITagData;
+  color?: string;
+  closable?: boolean;
+  onTagClose?: Function;
+  onTagClick?: Function;
+}
+const Widget = ({
+  data,
+  color,
+  closable = false,
+  onTagClick,
+  onTagClose,
+}: IWidget) => {
+  return (
+    <Tooltip placement="top" title={data?.title}>
+      <Tag
+        color={
+          data?.title === "sutta"
+            ? "gold"
+            : data?.title === "vinaya"
+            ? "green"
+            : data?.title === "abhidhamma"
+            ? "blue"
+            : data?.title === "mūla"
+            ? "#c4b30c"
+            : data?.title === "aṭṭhakathā"
+            ? "#79bb5c"
+            : data?.title === "ṭīkā"
+            ? "#2db7f5"
+            : color
+        }
+        closable={closable}
+        onClose={() => {
+          if (typeof onTagClose !== "undefined") {
+            onTagClose(data?.key);
+          }
+        }}
+        style={{ cursor: "pointer", borderRadius: 6 }}
+        onClick={() => {
+          if (typeof onTagClick !== "undefined") {
+            onTagClick(data?.key);
+          }
+        }}
+      >
+        <PaliText text={data?.title} />
+        {data?.count ? `(${data.count})` : undefined}
+      </Tag>
+    </Tooltip>
+  );
+};
+
+export default Widget;

+ 71 - 50
dashboard/src/components/corpus/ChapterTagList.tsx

@@ -1,66 +1,87 @@
-import { message, Tag } from "antd";
 import { useState, useEffect } from "react";
 
 import { get } from "../../request";
-import { IApiChapterTag, IApiResponseChapterTagList } from "../api/Corpus";
+import type { ChannelFilterProps } from "../channel/ChannelList";
+import { ITagData } from "./ChapterTag";
+import TagArea from "../tag/TagArea";
 
-export interface ITagData {
-  title: string;
-  key: string;
-  color?: string;
-  description?: string;
+interface IAppendTagData {
+  id: string;
+  name: string;
+  count: number;
 }
+interface IChapterTagResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IAppendTagData[];
+    count: number;
+  };
+}
+
 interface IWidget {
+  filter?: ChannelFilterProps;
+  progress?: number;
+  lang?: string;
+  type?: string;
+  tags?: string[];
   max?: number;
   onTagClick?: Function;
 }
-const Widget = ({ max, onTagClick }: IWidget) => {
-  const [tableData, setTableData] = useState<ITagData[]>([]);
+
+const Widget = ({
+  progress = 0.9,
+  lang = "zh",
+  type = "translation",
+  tags = [],
+  max,
+  onTagClick,
+}: IWidget) => {
+  const [tag, setTag] = useState<ITagData[]>([]);
 
   useEffect(() => {
-    console.log("useEffect");
-    fetchData();
-  }, []);
+    const strTags = tags.length > 0 ? "&tags=" + tags.join() : "";
+    const url = `/v2/tag?view=chapter${strTags}&progress=${progress}&lang=${lang}&channel_type=${type}`;
+    console.log("tag list ajax", url);
+    get<IChapterTagResponse>(url).then((json) => {
+      if (json.ok) {
+        if (json.data.count === 0) {
+          setTag([]);
+        } else {
+          const max = json.data.rows.sort((a, b) => b.count - a.count)[0].count;
+          const data: ITagData[] = json.data.rows
+            .filter((value) => value.count < max)
+            .map((item) => {
+              return {
+                key: item.name,
+                title: item.name,
+                count: item.count,
+              };
+            });
+          setTag(data);
+        }
+      } else {
+        setTag([]);
+      }
+    });
+  }, [progress, lang, type, tags]);
 
-  function fetchData() {
-    get(`/v2/progress?view=chapter-tag`)
-      .then((response) => {
-        const json = response as unknown as IApiResponseChapterTagList;
-        const tags: IApiChapterTag[] = json.data.rows;
-        let newTags: ITagData[] = tags.map((item) => {
-          return {
-            key: item.name,
-            title: `${item.name}(${item.count})`,
-          };
-        });
-        setTableData(newTags);
-      })
-      .catch((error) => {
-        message.error(error);
-      });
-  }
-  let iTag = max ? max : tableData.length;
-  if (iTag > tableData.length) {
-    iTag = tableData.length;
-  }
   return (
-    <>
-      {tableData.map((item, id) => {
-        return (
-          <Tag
-            key={id}
-            style={{ cursor: "pointer" }}
-            onClick={() => {
-              if (typeof onTagClick !== "undefined") {
-                onTagClick(item.key);
-              }
-            }}
-          >
-            {item.title}
-          </Tag>
-        );
-      })}
-    </>
+    <div>
+      {tag.length === 0 ? (
+        "无"
+      ) : (
+        <TagArea
+          max={max}
+          data={tag}
+          onTagClick={(tag: string) => {
+            if (typeof onTagClick !== "undefined") {
+              onTagClick(tag);
+            }
+          }}
+        />
+      )}
+    </div>
   );
 };
 

+ 13 - 7
dashboard/src/components/corpus/PaliChapterCard.tsx

@@ -1,7 +1,6 @@
 import { Row, Col } from "antd";
 import { Typography } from "antd";
-import { API_HOST } from "../../request";
-
+import { TinyLine } from "@ant-design/plots";
 import TocPath from "./TocPath";
 
 const { Title, Link } = Typography;
@@ -13,6 +12,7 @@ export interface IPaliChapterData {
   Path: string;
   Book: number;
   Paragraph: number;
+  progressLine?: number[];
 }
 
 interface IWidget {
@@ -54,11 +54,17 @@ const Widget = ({ data, onTitleClick }: IWidget) => {
               </Row>
             </Col>
             <Col span={8}>
-              <img
-                src={`${API_HOST}/storage/images/chapter_dynamic/${data.Book}/${data.Paragraph}/globle.svg`}
-                width={200}
-                alt="章节动态"
-              />
+              {data.progressLine ? (
+                <TinyLine
+                  height={60}
+                  width={200}
+                  autoFit={false}
+                  data={data.progressLine}
+                  smooth={true}
+                />
+              ) : (
+                <></>
+              )}
             </Col>
           </Row>
           <Row>

+ 3 - 2
dashboard/src/components/corpus/PaliChapterChannelList.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect } from "react";
 
 import { get } from "../../request";
-import { IApiResponseChapterChannelList } from "../api/Corpus";
+import { IChapterChannelListResponse } from "../api/Corpus";
 import { IChapter } from "./BookViewer";
 import ChapterInChannel, { IChapterChannelData } from "./ChapterInChannel";
 
@@ -16,7 +16,7 @@ const Widget = ({ para, channelId, openTarget = "_blank" }: IWidget) => {
 
   useEffect(() => {
     let url = `/v2/progress?view=chapter_channels&book=${para.book}&par=${para.para}`;
-    get<IApiResponseChapterChannelList>(url).then(function (json) {
+    get<IChapterChannelListResponse>(url).then(function (json) {
       const newData: IChapterChannelData[] = json.data.rows.map((item) => {
         return {
           channel: {
@@ -26,6 +26,7 @@ const Widget = ({ para, channelId, openTarget = "_blank" }: IWidget) => {
           },
           studio: item.studio,
           progress: Math.ceil(item.progress * 100),
+          progressLine: item.progress_line,
           hit: item.views,
           like: 0,
           updatedAt: item.updated_at,

+ 3 - 2
dashboard/src/components/corpus/PaliChapterListByPara.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect } from "react";
 
 import { get } from "../../request";
-import { IApiResponsePaliChapterList } from "../api/Corpus";
+import { IPaliChapterListResponse } from "../api/Corpus";
 import { IChapter } from "./BookViewer";
 import { IPaliChapterData } from "./PaliChapterCard";
 import PaliChapterList, { IChapterClickEvent } from "./PaliChapterList";
@@ -16,7 +16,7 @@ const Widget = ({ chapter, onChapterClick }: IWidget) => {
   useEffect(() => {
     console.log("palichapterlist useEffect");
     let url = `/v2/palitext?view=chapter_children&book=${chapter.book}&para=${chapter.para}`;
-    get<IApiResponsePaliChapterList>(url).then(function (json) {
+    get<IPaliChapterListResponse>(url).then(function (json) {
       console.log("chapter ajex", json);
       const newTree: IPaliChapterData[] = json.data.rows.map((item) => {
         return {
@@ -26,6 +26,7 @@ const Widget = ({ chapter, onChapterClick }: IWidget) => {
           Path: item.path,
           Book: item.book,
           Paragraph: item.paragraph,
+          progressLine: item.progress_line,
         };
       });
       setTableData(newTree);

+ 20 - 17
dashboard/src/components/corpus/PaliChapterListByTag.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect } from "react";
 
 import { get } from "../../request";
-import { IApiResponsePaliChapterList } from "../api/Corpus";
+import { IPaliChapterListResponse } from "../api/Corpus";
 import { IPaliChapterData } from "./PaliChapterCard";
 import PaliChapterList, { IChapterClickEvent } from "./PaliChapterList";
 
@@ -9,28 +9,31 @@ interface IWidgetPaliChapterListByTag {
   tag: string[];
   onChapterClick?: Function;
 }
-const defaultData: IPaliChapterData[] = [];
+
 const Widget = (prop: IWidgetPaliChapterListByTag) => {
-  const [tableData, setTableData] = useState(defaultData);
+  const [tableData, setTableData] = useState<IPaliChapterData[]>([]);
 
   useEffect(() => {
     console.log("palichapterlist useEffect");
     let url = `/v2/palitext?view=chapter&tags=${prop.tag.join()}`;
     console.log("tag url", url);
-    get(url).then(function (myJson) {
-      console.log("ajex", myJson);
-      const data = myJson as unknown as IApiResponsePaliChapterList;
-      let newTree: IPaliChapterData[] = data.data.rows.map((item) => {
-        return {
-          Title: item.title,
-          PaliTitle: item.title,
-          level: item.level,
-          Path: item.path,
-          Book: item.book,
-          Paragraph: item.paragraph,
-        };
-      });
-      setTableData(newTree);
+    get<IPaliChapterListResponse>(url).then((json) => {
+      if (json.ok) {
+        let newTree: IPaliChapterData[] = json.data.rows.map((item) => {
+          return {
+            Title: item.title,
+            PaliTitle: item.title,
+            level: item.level,
+            Path: item.path,
+            Book: item.book,
+            Paragraph: item.paragraph,
+            progressLine: item.progress_line,
+          };
+        });
+        setTableData(newTree);
+      } else {
+        console.error(json.message);
+      }
     });
   }, [prop.tag]);
 

+ 59 - 0
dashboard/src/components/corpus/Recent.tsx

@@ -0,0 +1,59 @@
+import { Button, List } from "antd";
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { IView, IViewListResponse } from "../../pages/studio/recent/list";
+import { get } from "../../request";
+
+const Widget = () => {
+  const [listData, setListData] = useState<IView[]>([]);
+  useEffect(() => {
+    let url = `/v2/view?view=user&limit=10`;
+    get<IViewListResponse>(url).then((json) => {
+      if (json.ok) {
+        const items: IView[] = json.data.rows.map((item, id) => {
+          return {
+            sn: id + 1,
+            id: item.id,
+            title: item.title,
+            subtitle: item.org_title,
+            type: item.target_type,
+            meta: JSON.parse(item.meta),
+            updatedAt: item.updated_at,
+          };
+        });
+        setListData(items);
+      }
+    });
+  }, []);
+  return (
+    <div style={{ padding: 6 }}>
+      <List
+        itemLayout="vertical"
+        header="最近打开"
+        size="small"
+        dataSource={listData}
+        renderItem={(item) => {
+          let url = `/article/${item.type}/`;
+          switch (item.type) {
+            case "chapter":
+              url += item.meta.book + "-" + item.meta.para;
+              break;
+
+            default:
+              break;
+          }
+          return (
+            <List.Item>
+              <Link to={url} target="_blank">
+                {item.title ? item.title : item.subtitle}
+              </Link>
+            </List.Item>
+          );
+        }}
+      />
+      <Button type="link">更多</Button>
+    </div>
+  );
+};
+
+export default Widget;

+ 128 - 0
dashboard/src/components/corpus/RelatedPara.tsx

@@ -0,0 +1,128 @@
+import { Link } from "react-router-dom";
+import { Badge, Card, List, message, Modal } from "antd";
+
+import { get } from "../../request";
+import { useEffect, useState } from "react";
+import TocPath, { ITocPathNode } from "./TocPath";
+
+interface ITag {
+  id?: string;
+  name: string;
+  color?: string;
+}
+interface IRelatedParaData {
+  book: number;
+  para: number[];
+  book_title_pali: string;
+  book_title?: string;
+  cs6_para: number;
+  path?: ITocPathNode[];
+  tags?: ITag[];
+}
+interface IRelatedParaResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IRelatedParaData[];
+    count: number;
+  };
+}
+interface IWidget {
+  book?: number;
+  para?: number;
+  trigger?: JSX.Element;
+  onSelect?: Function;
+}
+const Widget = ({ book, para, trigger, onSelect }: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [tableData, setTableData] = useState<IRelatedParaData[]>([]);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+  useEffect(() => {
+    if (typeof book === "number" && typeof para === "number" && isModalOpen) {
+      get<IRelatedParaResponse>(
+        `/v2/related-paragraph?book=${book}&para=${para}`
+      ).then((json) => {
+        console.log("import", json);
+        if (json.ok) {
+          setTableData(json.data.rows);
+        } else {
+          message.error(json.message);
+        }
+      });
+    }
+  }, [book, para, isModalOpen]);
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger ? trigger : "相关段落"}</span>
+      <Modal
+        title="根本和注疏"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <List
+          itemLayout="vertical"
+          size="small"
+          split={false}
+          dataSource={tableData}
+          renderItem={(item) => {
+            const isPali = item.tags?.find((tag) => tag.name === "pāḷi");
+            const isAttha = item.tags?.find((tag) => tag.name === "aṭṭhakathā");
+            const isTika = item.tags?.find((tag) => tag.name === "ṭīkā");
+            return (
+              <List.Item>
+                <Badge.Ribbon
+                  text={
+                    isPali
+                      ? "pāḷi"
+                      : isAttha
+                      ? "aṭṭhakathā"
+                      : isTika
+                      ? "ṭīkā"
+                      : undefined
+                  }
+                  color={
+                    isPali
+                      ? "volcano"
+                      : isAttha
+                      ? "green"
+                      : isTika
+                      ? "cyan"
+                      : undefined
+                  }
+                >
+                  <Card
+                    title={
+                      <Link
+                        to={`/article/para?book=${item.book}&par=${item.para}`}
+                      >
+                        {item.book_title_pali}
+                      </Link>
+                    }
+                    size="small"
+                  >
+                    <TocPath data={item.path} />
+                  </Card>
+                </Badge.Ribbon>
+              </List.Item>
+            );
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default Widget;

+ 24 - 13
dashboard/src/components/corpus/TocPath.tsx

@@ -1,6 +1,7 @@
 import { Link } from "react-router-dom";
-import { Breadcrumb } from "antd";
+import { Breadcrumb, Tooltip } from "antd";
 import PaliText from "../template/Wbw/PaliText";
+import React from "react";
 
 export interface ITocPathNode {
   book: number;
@@ -13,23 +14,24 @@ export interface ITocPathNode {
 export declare type ELinkType = "none" | "blank" | "self";
 
 interface IWidgetTocPath {
-  data: ITocPathNode[];
+  data?: ITocPathNode[];
+  trigger?: React.ReactNode;
   link?: ELinkType;
   channel?: string[];
   onChange?: Function;
 }
 const Widget = ({
-  data,
+  data = [],
+  trigger,
   link = "blank",
   channel,
   onChange,
-}: IWidgetTocPath) => {
-  let sChannel = "";
-  if (typeof channel !== "undefined" && channel.length > 0) {
-    sChannel = "_" + channel.join("_");
-  }
-
+}: IWidgetTocPath): JSX.Element => {
   const path = data.map((item, id) => {
+    let sChannel = "";
+    if (typeof channel !== "undefined" && channel.length > 0) {
+      sChannel = "?channel=" + channel.join("_");
+    }
     const linkChapter = `/article/chapter/${item.book}-${item.paragraph}${sChannel}`;
     let oneItem = <></>;
     const title = <PaliText text={item.title} />;
@@ -61,11 +63,20 @@ const Widget = ({
       </Breadcrumb.Item>
     );
   });
-  return (
-    <>
-      <Breadcrumb>{path}</Breadcrumb>
-    </>
+  const fullPath = (
+    <Breadcrumb style={{ whiteSpace: "nowrap", width: "100%" }}>
+      {path}
+    </Breadcrumb>
   );
+  if (typeof trigger === "undefined") {
+    return fullPath;
+  } else {
+    return (
+      <Tooltip placement="bottom" title={fullPath}>
+        {trigger}
+      </Tooltip>
+    );
+  }
 };
 
 export default Widget;

+ 1 - 1
dashboard/src/components/corpus/TocStyleSelect.tsx

@@ -8,7 +8,7 @@ interface IWidget {
 const Widget = ({ style = "default", onChange }: IWidget) => {
   return (
     <Select
-      defaultValue={style}
+      value={style}
       style={{ width: 90 }}
       loading={false}
       onChange={(value: string) => {

+ 79 - 0
dashboard/src/components/course/AcceptCourse.tsx

@@ -0,0 +1,79 @@
+/**
+ * 学生接受课程管理员的邀请 参加课程
+ */
+import { Button, message, Modal } from "antd";
+import { useIntl } from "react-intl";
+import { ExclamationCircleFilled } from "@ant-design/icons";
+
+import { put } from "../../request";
+import {
+  ICourseMemberData,
+  ICourseMemberResponse,
+  TCourseJoinMode,
+} from "../api/Course";
+
+const { confirm } = Modal;
+
+interface IWidget {
+  joinMode?: TCourseJoinMode;
+  currUser?: ICourseMemberData;
+  onStatusChanged?: Function;
+}
+const AcceptCourseWidget = ({
+  joinMode,
+  currUser,
+  onStatusChanged,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const statusChange = (status: ICourseMemberData | undefined) => {
+    if (typeof onStatusChanged !== "undefined") {
+      onStatusChanged(status);
+    }
+  };
+  return (
+    <>
+      <Button
+        type="primary"
+        onClick={() => {
+          confirm({
+            title: "参加此课程吗?",
+            icon: <ExclamationCircleFilled />,
+            content: intl.formatMessage({
+              id: `course.join.mode.open.message`,
+            }),
+            onOk() {
+              return put<ICourseMemberData, ICourseMemberResponse>(
+                "/v2/course-member/" + currUser?.id,
+                {
+                  user_id: "",
+                  course_id: "",
+                  status: "accepted",
+                }
+              )
+                .then((json) => {
+                  console.log("leave", json);
+                  if (json.ok) {
+                    console.log("accepted", json.data);
+                    statusChange(json.data);
+                    message.success(
+                      intl.formatMessage({ id: "flashes.success" })
+                    );
+                  } else {
+                    message.error(json.message);
+                  }
+                })
+                .catch((error) => {
+                  message.error(error);
+                });
+            },
+          });
+        }}
+      >
+        参加
+      </Button>
+    </>
+  );
+};
+
+export default AcceptCourseWidget;

+ 79 - 0
dashboard/src/components/course/AcceptNotCourse.tsx

@@ -0,0 +1,79 @@
+/**
+ * 学生接受课程管理员的邀请 参加课程
+ */
+import { Button, message, Modal } from "antd";
+import { useIntl } from "react-intl";
+import { ExclamationCircleFilled } from "@ant-design/icons";
+
+import { put } from "../../request";
+import {
+  ICourseMemberData,
+  ICourseMemberResponse,
+  TCourseJoinMode,
+} from "../api/Course";
+
+const { confirm } = Modal;
+
+interface IWidget {
+  joinMode?: TCourseJoinMode;
+  currUser?: ICourseMemberData;
+  onStatusChanged?: Function;
+}
+const AcceptNotCourseWidget = ({
+  joinMode,
+  currUser,
+  onStatusChanged,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const statusChange = (status: ICourseMemberData | undefined) => {
+    if (typeof onStatusChanged !== "undefined") {
+      onStatusChanged(status);
+    }
+  };
+  return (
+    <>
+      <Button
+        type="default"
+        onClick={() => {
+          confirm({
+            title: "拒绝参加此课程吗?",
+            icon: <ExclamationCircleFilled />,
+            content: intl.formatMessage({
+              id: "course.rejected.message",
+            }),
+            onOk() {
+              return put<ICourseMemberData, ICourseMemberResponse>(
+                "/v2/course-member/" + currUser?.id,
+                {
+                  user_id: "",
+                  course_id: "",
+                  status: "rejected",
+                }
+              )
+                .then((json) => {
+                  console.log("leave", json);
+                  if (json.ok) {
+                    console.log("rejected", json.data);
+                    statusChange(json.data);
+                    message.success(
+                      intl.formatMessage({ id: "flashes.success" })
+                    );
+                  } else {
+                    message.error(json.message);
+                  }
+                })
+                .catch((error) => {
+                  message.error(error);
+                });
+            },
+          });
+        }}
+      >
+        拒绝
+      </Button>
+    </>
+  );
+};
+
+export default AcceptNotCourseWidget;

+ 1 - 0
dashboard/src/components/course/AddMember.tsx

@@ -31,6 +31,7 @@ const Widget = ({ courseId, onCreated }: IWidget) => {
             user_id: values.userId,
             role: values.role,
             course_id: courseId,
+            operating: "invite",
           }).then((json) => {
             console.log("add member", json);
             if (json.ok) {

+ 17 - 9
dashboard/src/components/course/CourseHead.tsx

@@ -7,9 +7,9 @@ import { HomeOutlined } from "@ant-design/icons";
 import { IUser } from "../auth/User";
 import { API_HOST } from "../../request";
 import UserName from "../auth/UserName";
-import JoinCourse from "./JoinCourse";
 import { TCourseExpRequest, TCourseJoinMode } from "../api/Course";
 import { useIntl } from "react-intl";
+import Status from "./Status";
 
 const { Title, Text } = Typography;
 
@@ -48,7 +48,9 @@ const Widget = ({
                 <HomeOutlined />
               </Breadcrumb.Item>
               <Breadcrumb.Item>
-                <Link to="/course/list">课程</Link>
+                <Link to="/course/list">
+                  <Text>课程</Text>
+                </Link>
               </Breadcrumb.Item>
               <Breadcrumb.Item>{title}</Breadcrumb.Item>
             </Breadcrumb>
@@ -67,11 +69,13 @@ const Widget = ({
                   {startAt}——{endAt}
                 </Text>
                 <Text>
-                  {intl.formatMessage({
-                    id: `course.join.mode.${join}.message`,
-                  })}
+                  {join
+                    ? intl.formatMessage({
+                        id: `course.join.mode.${join}.message`,
+                      })
+                    : undefined}
                 </Text>
-                <JoinCourse
+                <Status
                   courseId={id ? id : ""}
                   expRequest={exp}
                   joinMode={join}
@@ -79,9 +83,13 @@ const Widget = ({
                 />
               </Space>
             </Space>
-            <div>
-              主讲人: <UserName {...teacher} />
-            </div>
+
+            <Space>
+              <Text>主讲人:</Text>{" "}
+              <Text>
+                <UserName {...teacher} />
+              </Text>
+            </Space>
           </Space>
         </Col>
         <Col flex="auto"></Col>

+ 6 - 7
dashboard/src/components/course/CourseIntro.tsx

@@ -1,7 +1,8 @@
 //课程详情简介
-import { Col, Row } from "antd";
-import { marked } from "marked";
+import { Col, Row, Typography } from "antd";
 
+import Marked from "../general/Marked";
+const { Paragraph } = Typography;
 interface IWidget {
   intro?: string;
 }
@@ -10,11 +11,9 @@ const Widget = ({ intro }: IWidget) => {
     <Row>
       <Col flex="auto"></Col>
       <Col flex="960px">
-        <div
-          dangerouslySetInnerHTML={{
-            __html: marked.parse(intro ? intro : ""),
-          }}
-        />
+        <Paragraph>
+          <Marked text={intro} />
+        </Paragraph>
       </Col>
       <Col flex="auto"></Col>
     </Row>

+ 1 - 1
dashboard/src/components/course/CourseList.tsx

@@ -34,7 +34,7 @@ const Widget = ({ type }: IWidget) => {
         message.error(json.message);
       }
     });
-  }, []);
+  }, [type]);
 
   return (
     <List

+ 1 - 1
dashboard/src/components/course/CourseMember.tsx

@@ -153,7 +153,7 @@ const Widget = ({ courseId }: IWidget) => {
                 <Popconfirm
                   placement="bottomLeft"
                   title={intl.formatMessage({
-                    id: "forms.message.member.delete",
+                    id: "forms.message.member.remove",
                   })}
                   onConfirm={(
                     e?: React.MouseEvent<HTMLElement, MouseEvent>

+ 56 - 9
dashboard/src/components/course/CourseMemberList.tsx

@@ -131,18 +131,31 @@ const Widget = ({ studioName, courseId }: IWidget) => {
             filters: true,
             onFilter: true,
             valueEnum: {
+              /**"success","processing","error","default","warning" */
               all: {
                 text: intl.formatMessage({
                   id: "tables.publicity.all",
                 }),
-                status: "Default",
+                status: "default",
+              },
+              normal: {
+                text: intl.formatMessage({
+                  id: "course.member.status.normal.label",
+                }),
+                status: "success",
               },
-              progressing: {
+              sign_up: {
                 text: intl.formatMessage({
-                  id: "course.member.status.progressing.label",
+                  id: "course.member.status.sign_up.label",
                 }),
                 status: "Processing",
               },
+              invited: {
+                text: intl.formatMessage({
+                  id: "course.member.status.invited.label",
+                }),
+                status: "default",
+              },
               accepted: {
                 text: intl.formatMessage({
                   id: "course.member.status.accepted.label",
@@ -159,13 +172,13 @@ const Widget = ({ studioName, courseId }: IWidget) => {
                 text: intl.formatMessage({
                   id: "course.member.status.left.label",
                 }),
-                status: "warning",
+                status: "error",
               },
               blocked: {
                 text: intl.formatMessage({
                   id: "course.member.status.blocked.label",
                 }),
-                status: "warning",
+                status: "error",
               },
             },
           },
@@ -202,17 +215,18 @@ const Widget = ({ studioName, courseId }: IWidget) => {
                   items = [
                     {
                       key: "exp",
-                      label: "经验值",
+                      label: "查看经验值",
                       icon: <BarChartOutlined />,
                     },
                     {
-                      key: "delete",
-                      label: "删除",
+                      key: "block",
+                      label: "屏蔽",
                       icon: <DeleteOutlined />,
+                      danger: true,
                     },
                   ];
                   break;
-                case "progressing":
+                case "sign_up":
                   items = [
                     {
                       key: "accept",
@@ -223,10 +237,43 @@ const Widget = ({ studioName, courseId }: IWidget) => {
                       key: "reject",
                       label: "拒绝",
                       icon: <DeleteOutlined />,
+                      danger: true,
+                    },
+                  ];
+                  break;
+                case "invited":
+                  items = [
+                    {
+                      key: "delete",
+                      label: "删除",
+                      icon: <DeleteOutlined />,
+                      danger: true,
+                    },
+                  ];
+                  break;
+                case "normal":
+                  items = [
+                    {
+                      key: "exp",
+                      label: "查看经验值",
+                      icon: <BarChartOutlined />,
+                    },
+                    {
+                      key: "block",
+                      label: "屏蔽",
+                      icon: <DeleteOutlined />,
+                      danger: true,
                     },
                   ];
                   break;
                 default:
+                  items = [
+                    {
+                      key: "none",
+                      label: "无操作",
+                      disabled: true,
+                    },
+                  ];
                   break;
               }
 

+ 25 - 6
dashboard/src/components/course/JoinCourse.tsx

@@ -3,7 +3,7 @@
  * 已经报名显示报名状态
  * 未报名显示报名按钮以及必要的提示
  */
-import { Button, message, Modal, Typography } from "antd";
+import { Button, message, Modal, Space, Typography } from "antd";
 import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
 import { ExclamationCircleFilled } from "@ant-design/icons";
@@ -19,6 +19,8 @@ import {
   TCourseJoinMode,
 } from "../api/Course";
 import LeaveCourse from "./LeaveCourse";
+import AcceptCourse from "./AcceptCourse";
+import AcceptNotCourse from "./AcceptNotCourse";
 
 const { confirm } = Modal;
 const { Text } = Typography;
@@ -63,10 +65,7 @@ const Widget = ({ courseId, joinMode, startAt, expRequest }: IWidget) => {
     labelStatus = intl.formatMessage({
       id: `course.member.status.${currMember.status}.label`,
     });
-    if (
-      currMember.status === "accepted" ||
-      currMember.status === "progressing"
-    ) {
+    if (currMember.status === "accepted" || currMember.status === "sign_up") {
       button = (
         <LeaveCourse
           joinMode={joinMode}
@@ -76,6 +75,25 @@ const Widget = ({ courseId, joinMode, startAt, expRequest }: IWidget) => {
           }}
         />
       );
+    } else if (currMember.status === "invited") {
+      button = (
+        <Space>
+          <AcceptCourse
+            joinMode={joinMode}
+            currUser={currMember}
+            onStatusChanged={() => {
+              loadStatus();
+            }}
+          />
+          <AcceptNotCourse
+            joinMode={joinMode}
+            currUser={currMember}
+            onStatusChanged={() => {
+              loadStatus();
+            }}
+          />
+        </Space>
+      );
     }
   } else if (currMember?.role === "assistant") {
     labelStatus = "助理老师";
@@ -109,6 +127,7 @@ const Widget = ({ courseId, joinMode, startAt, expRequest }: IWidget) => {
                     user_id: user?.id ? user?.id : "",
                     role: "student",
                     course_id: courseId ? courseId : "",
+                    operating: "sign_up",
                   }
                 )
                   .then((json) => {
@@ -144,7 +163,7 @@ const Widget = ({ courseId, joinMode, startAt, expRequest }: IWidget) => {
   }
   return (
     <div>
-      <span>{labelStatus}</span>
+      <Text>{labelStatus}</Text>
       {button}
     </div>
   );

+ 15 - 10
dashboard/src/components/course/LeaveCourse.tsx

@@ -8,7 +8,6 @@ import {
   ICourseMemberDeleteResponse,
   ICourseMemberResponse,
   TCourseJoinMode,
-  TCourseMemberStatus,
 } from "../api/Course";
 
 const { confirm } = Modal;
@@ -19,22 +18,29 @@ interface IWidget {
   currUser?: ICourseMemberData;
   onStatusChanged?: Function;
 }
-const Widget = ({ joinMode, currUser, onStatusChanged }: IWidget) => {
+const LeaveCourseWidget = ({
+  joinMode,
+  currUser,
+  onStatusChanged,
+}: IWidget) => {
   const intl = useIntl();
+  console.log("user info", currUser);
   /**
    * 离开课程业务逻辑
    * open 直接删除记录
    * manual,invite
-   *  progressing 直接删除记录
+   *  sign_up 直接删除记录
    *  其他        设置为 left
    */
   let isDelete = false;
   if (joinMode === "open") {
-    isDelete = true;
-  } else if (currUser?.status === "progressing") {
+    if (currUser?.status === "normal") {
+      isDelete = true;
+    }
+  } else if (currUser?.status === "sign_up") {
     isDelete = true;
   }
-  const statusChange = (status: TCourseMemberStatus) => {
+  const statusChange = (status: ICourseMemberData | undefined) => {
     if (typeof onStatusChanged !== "undefined") {
       onStatusChanged(status);
     }
@@ -42,7 +48,6 @@ const Widget = ({ joinMode, currUser, onStatusChanged }: IWidget) => {
   return (
     <>
       <Button
-        type="primary"
         onClick={() => {
           confirm({
             title: "退出已经报名的课程吗?",
@@ -67,7 +72,7 @@ const Widget = ({ joinMode, currUser, onStatusChanged }: IWidget) => {
                       console.log("add member", json);
                       if (json.ok) {
                         console.log("delete", json.data);
-                        statusChange("normal");
+                        statusChange(undefined);
                         message.success(
                           intl.formatMessage({ id: "flashes.success" })
                         );
@@ -90,7 +95,7 @@ const Widget = ({ joinMode, currUser, onStatusChanged }: IWidget) => {
                       console.log("leave", json);
                       if (json.ok) {
                         console.log("leave", json.data);
-                        statusChange("left");
+                        statusChange(json.data);
                         message.success(
                           intl.formatMessage({ id: "flashes.success" })
                         );
@@ -111,4 +116,4 @@ const Widget = ({ joinMode, currUser, onStatusChanged }: IWidget) => {
   );
 };
 
-export default Widget;
+export default LeaveCourseWidget;

+ 2 - 2
dashboard/src/components/course/SelectChannel.tsx

@@ -27,9 +27,9 @@ const Widget = ({
   onOpenChange,
 }: IWidget) => {
   const user = useAppSelector(_currentUser);
-  const { type, id } = useParams(); //url 参数
+  const { id } = useParams(); //url 参数
   const navigate = useNavigate();
-
+  //TODO 从哪里拿到courseId?
   return (
     <ModalForm<{
       channel: string;

+ 98 - 0
dashboard/src/components/course/SignUp.tsx

@@ -0,0 +1,98 @@
+/**
+ * 报名按钮
+ * 已经报名显示报名状态
+ * 未报名显示报名按钮以及必要的提示
+ */
+import { Button, message, Modal, Typography } from "antd";
+
+import { useIntl } from "react-intl";
+import { ExclamationCircleFilled } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import { post } from "../../request";
+import {
+  ICourseMemberData,
+  ICourseMemberResponse,
+  TCourseExpRequest,
+  TCourseJoinMode,
+} from "../api/Course";
+
+const { confirm } = Modal;
+const { Text } = Typography;
+
+interface IWidget {
+  courseId: string;
+  startAt?: string;
+  joinMode?: TCourseJoinMode;
+  expRequest?: TCourseExpRequest;
+  onStatusChanged?: Function;
+}
+const Widget = ({
+  courseId,
+  joinMode,
+  startAt,
+  expRequest,
+  onStatusChanged,
+}: IWidget) => {
+  const user = useAppSelector(_currentUser);
+  const intl = useIntl();
+
+  return (
+    <Button
+      type="primary"
+      onClick={() => {
+        confirm({
+          title: "你想要报名课程吗?",
+          icon: <ExclamationCircleFilled />,
+          content: (
+            <div>
+              <div>
+                {intl.formatMessage({
+                  id: `course.join.mode.${joinMode}.message`,
+                })}
+              </div>
+              <Text type="danger">
+                {intl.formatMessage({
+                  id: `course.exp.request.${expRequest}.message`,
+                })}
+              </Text>
+            </div>
+          ),
+          onOk() {
+            return post<ICourseMemberData, ICourseMemberResponse>(
+              "/v2/course-member",
+              {
+                user_id: user?.id ? user?.id : "",
+                role: "student",
+                course_id: courseId ? courseId : "",
+                operating: "sign_up",
+              }
+            )
+              .then((json) => {
+                console.log("add member", json);
+                if (json.ok) {
+                  console.log("new", json.data);
+                  if (typeof onStatusChanged !== "undefined") {
+                    onStatusChanged(json.data);
+                  }
+                  message.success(
+                    intl.formatMessage({ id: "flashes.success" })
+                  );
+                } else {
+                  message.error(json.message);
+                }
+              })
+              .catch((error) => {
+                message.error(error);
+              });
+          },
+        });
+      }}
+    >
+      报名
+    </Button>
+  );
+};
+
+export default Widget;

+ 159 - 0
dashboard/src/components/course/Status.tsx

@@ -0,0 +1,159 @@
+/**
+ * 报名按钮
+ * 已经报名显示报名状态
+ * 未报名显示报名按钮以及必要的提示
+ */
+import { Space, Typography } from "antd";
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+
+import { get } from "../../request";
+import {
+  ICourseMemberData,
+  ICourseMemberListResponse,
+  TCourseExpRequest,
+  TCourseJoinMode,
+} from "../api/Course";
+import AcceptCourse from "./AcceptCourse";
+import AcceptNotCourse from "./AcceptNotCourse";
+import LeaveCourse from "./LeaveCourse";
+import SignUp from "./SignUp";
+
+const { Paragraph } = Typography;
+
+interface IWidget {
+  courseId: string;
+  startAt?: string;
+  joinMode?: TCourseJoinMode;
+  expRequest?: TCourseExpRequest;
+}
+const Widget = ({ courseId, joinMode, startAt, expRequest }: IWidget) => {
+  const intl = useIntl();
+  const [currMember, setCurrMember] = useState<ICourseMemberData>();
+
+  const today = new Date();
+  const courseStart = new Date(startAt ? startAt : "3000-01-01");
+
+  useEffect(() => {
+    /**
+     * 获取该课程我的报名状态
+     */
+    const url = `/v2/course-member?view=user&course=${courseId}`;
+    console.log(url);
+    get<ICourseMemberListResponse>(url).then((json) => {
+      console.log("course member", json);
+      if (json.ok) {
+        let role: string[] = [];
+        for (const iterator of json.data.rows) {
+          if (typeof iterator.role !== "undefined") {
+            role.push(iterator.role);
+            setCurrMember(iterator);
+          }
+        }
+      }
+    });
+  }, [courseId]);
+
+  let labelStatus = "";
+  let operation: React.ReactNode | undefined;
+  if (currMember?.role === "student" || currMember?.role === "assistant") {
+    labelStatus = intl.formatMessage({
+      id: `course.member.status.${currMember.status}.label`,
+    });
+    switch (currMember.status) {
+      case "normal":
+        operation = (
+          <Space>
+            <LeaveCourse
+              joinMode={joinMode}
+              currUser={currMember}
+              onStatusChanged={(status: ICourseMemberData | undefined) => {
+                setCurrMember(status);
+              }}
+            />
+          </Space>
+        );
+        break;
+      case "sign_up":
+        operation = (
+          <Space>
+            <LeaveCourse
+              joinMode={joinMode}
+              currUser={currMember}
+              onStatusChanged={(status: ICourseMemberData | undefined) => {
+                setCurrMember(status);
+              }}
+            />
+          </Space>
+        );
+        break;
+      case "invited":
+        operation = (
+          <Space>
+            <AcceptCourse
+              joinMode={joinMode}
+              currUser={currMember}
+              onStatusChanged={(status: ICourseMemberData | undefined) => {
+                setCurrMember(status);
+              }}
+            />
+            <AcceptNotCourse
+              joinMode={joinMode}
+              currUser={currMember}
+              onStatusChanged={(status: ICourseMemberData | undefined) => {
+                setCurrMember(status);
+              }}
+            />
+          </Space>
+        );
+        break;
+      case "accepted":
+        operation = (
+          <Space>
+            <LeaveCourse
+              joinMode={joinMode}
+              currUser={currMember}
+              onStatusChanged={(status: ICourseMemberData | undefined) => {
+                setCurrMember(status);
+              }}
+            />
+          </Space>
+        );
+        break;
+      case "rejected":
+        break;
+      case "blocked":
+        break;
+      case "left":
+        break;
+    }
+  } else {
+    if (courseStart < today) {
+      labelStatus = "已经过期";
+    } else {
+      if (joinMode === "manual" || joinMode === "open") {
+        labelStatus = "可报名";
+        operation = (
+          <Space>
+            <SignUp
+              courseId={courseId}
+              joinMode={joinMode}
+              expRequest={expRequest}
+              onStatusChanged={(status: ICourseMemberData | undefined) => {
+                setCurrMember(status);
+              }}
+            />
+          </Space>
+        );
+      }
+    }
+  }
+  return (
+    <div>
+      <Paragraph>{labelStatus}</Paragraph>
+      {operation}
+    </div>
+  );
+};
+
+export default Widget;

+ 1 - 1
dashboard/src/components/dict/CaseList.tsx

@@ -1,4 +1,4 @@
-import { List, Card, Tag, Typography } from "antd";
+import { List, Tag, Typography } from "antd";
 import { useEffect, useState } from "react";
 import { get } from "../../request";
 import { ICaseListResponse } from "../api/Dict";

+ 228 - 0
dashboard/src/components/dict/Community.tsx

@@ -0,0 +1,228 @@
+import { Badge, Card, Popover, Space, Typography } from "antd";
+import { MoreOutlined } from "@ant-design/icons";
+import { useState, useEffect } from "react";
+import { useIntl } from "react-intl";
+import { get } from "../../request";
+import { IApiResponseDictList } from "../api/Dict";
+import { IUser } from "../auth/User";
+import UserName from "../auth/UserName";
+import GrammarPop from "./GrammarPop";
+
+const { Title } = Typography;
+
+interface IItem<R> {
+  value: R;
+  score: number;
+}
+interface IWord {
+  grammar: IItem<string>[];
+  parent: IItem<string>[];
+  meaning: IItem<string>[];
+  factors: IItem<string>[];
+  editor: IItem<IUser>[];
+}
+
+interface IWidget {
+  word: string | undefined;
+}
+const Widget = ({ word }: IWidget) => {
+  const intl = useIntl();
+  const [wordData, setWordData] = useState<IWord>();
+  const minScore = 100; //分数阈值。低于这个分数只显示在弹出菜单中
+
+  useEffect(() => {
+    if (typeof word === "undefined") {
+      return;
+    }
+    const url = `/v2/userdict?view=community&word=${word}`;
+    get<IApiResponseDictList>(url)
+      .then((json) => {
+        console.log("community", json.data.rows);
+        let meaning = new Map<string, number>();
+        let grammar = new Map<string, number>();
+        let parent = new Map<string, number>();
+        let editorId = new Map<string, number>();
+        let editor = new Map<string, IUser>();
+        for (const it of json.data.rows) {
+          let score: number | undefined;
+          if (it.exp) {
+            //分数计算
+            const currScore = Math.floor(
+              (it.exp / 3600) * (it.confidence / 100)
+            );
+
+            score = meaning.get(it.mean);
+            meaning.set(it.mean, score ? score + currScore : currScore);
+
+            if (it.type || it.grammar) {
+              const strCase = it.type + "$" + it.grammar;
+              score = grammar.get(strCase);
+              grammar.set(strCase, score ? score + currScore : currScore);
+            }
+
+            score = parent.get(it.parent);
+            parent.set(it.parent, score ? score + currScore : currScore);
+
+            if (it.editor) {
+              score = editorId.get(it.editor.id);
+              editorId.set(it.editor.id, score ? score + currScore : currScore);
+              editor.set(it.editor.id, it.editor);
+            }
+          }
+        }
+        let _data: IWord = {
+          grammar: [],
+          parent: [],
+          meaning: [],
+          factors: [],
+          editor: [],
+        };
+        meaning.forEach((value, key, map) => {
+          if (key && key.length > 0) {
+            _data.meaning.push({ value: key, score: value });
+          }
+        });
+        _data.meaning.sort((a, b) => b.score - a.score);
+        grammar.forEach((value, key, map) => {
+          if (key && key.length > 0) {
+            _data.grammar.push({ value: key, score: value });
+          }
+        });
+        _data.grammar.sort((a, b) => b.score - a.score);
+
+        parent.forEach((value, key, map) => {
+          if (key && key.length > 0) {
+            _data.parent.push({ value: key, score: value });
+          }
+        });
+        _data.parent.sort((a, b) => b.score - a.score);
+
+        editorId.forEach((value, key, map) => {
+          const currEditor = editor.get(key);
+          if (currEditor) {
+            _data.editor.push({ value: currEditor, score: value });
+          }
+        });
+        _data.editor.sort((a, b) => b.score - a.score);
+        setWordData(_data);
+      })
+      .catch((error) => {
+        console.error(error);
+      });
+  }, [word, setWordData]);
+
+  const isShow = (score: number, index: number) => {
+    const Ms = 500,
+      Rd = 5,
+      minScore = 15;
+    const minOrder = Math.log(score) / Math.log(Math.pow(Ms, 1 / Rd));
+    if (index < minOrder && score > minScore) {
+      return true;
+    } else {
+      return false;
+    }
+  };
+
+  const meaningLow = wordData?.meaning.filter(
+    (value, index: number) => !isShow(value.score, index)
+  );
+  const meaningExtra = meaningLow?.map((item, id) => {
+    return <>{item.value}</>;
+  });
+
+  return (
+    <Card>
+      <Title level={5} id={`community`}>
+        {"社区字典"}
+      </Title>
+      <div>
+        <Space>
+          {"意思:"}
+          {wordData?.meaning
+            .filter((value, index: number) => isShow(value.score, index))
+            .map((item, id) => {
+              return (
+                <Space key={id}>
+                  {item.value}
+                  <Badge color="geekblue" size="small" count={item.score} />
+                </Space>
+              );
+            })}
+          {meaningLow && meaningLow.length > 0 ? (
+            <Popover content={<Space>{meaningExtra}</Space>} placement="bottom">
+              <MoreOutlined />
+            </Popover>
+          ) : undefined}
+        </Space>
+      </div>
+      <div>
+        <Space>
+          {"语法:"}
+          {wordData?.grammar
+            .filter((value) => value.score >= minScore)
+            .map((item, id) => {
+              const grammar = item.value.split("$");
+              const grammarGuide = grammar.map((item, id) => {
+                const strCase = item.replaceAll(".", "");
+
+                return strCase.length > 0 ? (
+                  <GrammarPop
+                    key={id}
+                    gid={strCase}
+                    text={intl.formatMessage({
+                      id: `dict.fields.type.${strCase}.label`,
+                    })}
+                  />
+                ) : undefined;
+              });
+              return (
+                <Space key={id}>
+                  <Space
+                    style={{
+                      backgroundColor: "rgba(0.5,0.5,0.5,0.2)",
+                      borderRadius: 5,
+                      paddingLeft: 5,
+                      paddingRight: 5,
+                    }}
+                  >
+                    {grammarGuide}
+                  </Space>
+                  <Badge color="geekblue" size="small" count={item.score} />
+                </Space>
+              );
+            })}
+        </Space>
+      </div>
+      <div>
+        <Space>
+          {"词干:"}
+          {wordData?.parent
+            .filter((value) => value.score >= minScore)
+            .map((item, id) => {
+              return (
+                <Space key={id}>
+                  {item.value}
+                  <Badge color="geekblue" size="small" count={item.score} />
+                </Space>
+              );
+            })}
+        </Space>
+      </div>
+      <div>
+        <Space>
+          {"贡献者:"}
+          {wordData?.editor.map((item, id) => {
+            return (
+              <Space key={id}>
+                <UserName {...item.value} />
+                <Badge color="geekblue" size="small" count={item.score} />
+              </Space>
+            );
+          })}
+        </Space>
+      </div>
+    </Card>
+  );
+};
+
+export default Widget;

+ 1 - 1
dashboard/src/components/dict/Compound.tsx

@@ -1,4 +1,4 @@
-import { List, Select, Space, Typography } from "antd";
+import { List, Select, Typography } from "antd";
 import { useEffect, useState } from "react";
 import { get } from "../../request";
 import {

+ 4 - 4
dashboard/src/components/dict/DictContent.tsx

@@ -9,7 +9,7 @@ import CaseList from "./CaseList";
 import DictList from "./DictList";
 import MyCreate from "./MyCreate";
 
-export interface IWidgetDictContentData {
+export interface IDictContentData {
   dictlist: IAnchorData[];
   words: IWidgetWordCardData[];
   caselist: ICaseListData[];
@@ -17,12 +17,12 @@ export interface IWidgetDictContentData {
 export interface IApiDictContentData {
   ok: boolean;
   message: string;
-  data: IWidgetDictContentData;
+  data: IDictContentData;
 }
 
 interface IWidget {
   word?: string;
-  data: IWidgetDictContentData;
+  data: IDictContentData;
   compact?: boolean;
 }
 
@@ -49,7 +49,7 @@ const Widget = ({ word, data, compact }: IWidget) => {
                 ),
               },
               {
-                label: `添加`,
+                label: `单词本`,
                 key: "my",
                 children: (
                   <div>

+ 3 - 6
dashboard/src/components/dict/DictSearch.tsx

@@ -1,9 +1,6 @@
 import { useState, useEffect } from "react";
 
-import type {
-  IWidgetDictContentData,
-  IApiDictContentData,
-} from "./DictContent";
+import type { IDictContentData, IApiDictContentData } from "./DictContent";
 import { get } from "../../request";
 
 import DictContent from "./DictContent";
@@ -14,12 +11,12 @@ interface IWidget {
 }
 
 const Widget = ({ word, compact = false }: IWidget) => {
-  const defaultData: IWidgetDictContentData = {
+  const defaultData: IDictContentData = {
     dictlist: [],
     words: [],
     caselist: [],
   };
-  const [tableData, setTableData] = useState(defaultData);
+  const [tableData, setTableData] = useState<IDictContentData>(defaultData);
 
   useEffect(() => {
     if (typeof word === "undefined") {

+ 0 - 2
dashboard/src/components/dict/Dictionary.tsx

@@ -1,5 +1,4 @@
 import { useEffect, useState } from "react";
-import { useNavigate } from "react-router-dom";
 import { Layout, Affix, Col, Row } from "antd";
 
 import DictSearch from "./DictSearch";
@@ -14,7 +13,6 @@ interface IWidget {
   onSearch?: Function;
 }
 const Widget = ({ word, compact = false, onSearch }: IWidget) => {
-  const navigate = useNavigate();
   const [split, setSplit] = useState<string>();
   const [wordSearch, setWordSearch] = useState<string>();
   const [container, setContainer] = useState<HTMLDivElement | null>(null);

+ 15 - 13
dashboard/src/components/dict/GrammarPop.tsx

@@ -1,8 +1,8 @@
 import { useState } from "react";
 import { Popover, Typography } from "antd";
-import { ProCard } from "@ant-design/pro-components";
 
 import { get } from "../../request";
+import { get as getLang } from "../../locales";
 import { IGuideResponse } from "../api/Guide";
 import Marked from "../general/Marked";
 
@@ -14,10 +14,10 @@ interface IWidget {
 }
 const Widget = ({ text, gid }: IWidget) => {
   const [guide, setGuide] = useState("Loading");
-  const grammarProfix = "guide-grammar-";
+  const grammarPrefix = "guide-grammar-";
   const handleMouseMouseEnter = () => {
     //sessionStorage缓存
-    const value = sessionStorage.getItem(grammarProfix + gid);
+    const value = sessionStorage.getItem(grammarPrefix + gid);
     if (value === null) {
       fetchData(gid);
     } else {
@@ -25,24 +25,26 @@ const Widget = ({ text, gid }: IWidget) => {
       setGuide(sGuide);
     }
   };
-  const userCard = (
-    <>
-      <ProCard style={{ maxWidth: 500, minWidth: 300, margin: 0 }}>
-        <Marked text={guide} />
-      </ProCard>
-    </>
-  );
+
   function fetchData(key: string) {
-    const url = `/v2/guide/zh-cn/${key}`;
+    const uiLang = getLang();
+    const url = `/v2/grammar-guide/${key}_${uiLang}`;
     get<IGuideResponse>(url).then((json) => {
       if (json.ok) {
-        sessionStorage.setItem(grammarProfix + key, json.data);
+        sessionStorage.setItem(grammarPrefix + key, json.data);
         setGuide(json.data);
       }
     });
   }
   return (
-    <Popover content={userCard} placement="bottom">
+    <Popover
+      content={
+        <div style={{ maxWidth: 500, minWidth: 300, margin: 0 }}>
+          <Marked text={guide} />
+        </div>
+      }
+      placement="bottom"
+    >
       <Link onMouseEnter={handleMouseMouseEnter}>{text}</Link>
     </Popover>
   );

+ 62 - 19
dashboard/src/components/dict/MyCreate.tsx

@@ -1,13 +1,20 @@
 import { Button, Col, Divider, Input, message, Row } from "antd";
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
 import { SaveOutlined } from "@ant-design/icons";
 
 import WbwDetailBasic from "../template/Wbw/WbwDetailBasic";
 import WbwDetailNote from "../template/Wbw/WbwDetailNote";
 import { IWbw, IWbwField, TFieldName } from "../template/Wbw/WbwWord";
-import { post } from "../../request";
-import { IDictResponse, IUserDictCreate } from "../api/Dict";
+import { get, post } from "../../request";
+import {
+  IApiResponseDictList,
+  IDictResponse,
+  IUserDictCreate,
+} from "../api/Dict";
+import { useAppSelector } from "../../hooks";
+import { add, wordIndex } from "../../reducers/inline-dict";
+import store from "../../store";
 
 interface IWidget {
   word?: string;
@@ -17,12 +24,34 @@ const Widget = ({ word }: IWidget) => {
   const [wordSpell, setWordSpell] = useState(word);
   const [editWord, setEditWord] = useState<IWbw>({
     word: { value: word ? word : "", status: 1 },
+    book: 0,
+    para: 0,
+    sn: [0],
     confidence: 100,
   });
   const [loading, setLoading] = useState(false);
+  const inlineWordIndex = useAppSelector(wordIndex);
+
+  useEffect(() => {
+    //查询这个词在内存字典里是否有
+    if (typeof wordSpell === "undefined") {
+      return;
+    }
+    if (inlineWordIndex.includes(wordSpell)) {
+      //已经有了,退出
+      return;
+    }
+    get<IApiResponseDictList>(`/v2/wbwlookup?word=${wordSpell}`).then(
+      (json) => {
+        console.log("lookup ok", json.data.count);
+        //存储到redux
+        store.dispatch(add(json.data.rows));
+      }
+    );
+  }, [inlineWordIndex, wordSpell]);
 
   function fieldChanged(field: TFieldName, value: string) {
-    let mData = JSON.parse(JSON.stringify(editWord));
+    let mData: IWbw = JSON.parse(JSON.stringify(editWord));
     switch (field) {
       case "note":
         mData.note = { value: value, status: 5 };
@@ -31,7 +60,7 @@ const Widget = ({ word }: IWidget) => {
         mData.word = { value: value, status: 5 };
         break;
       case "meaning":
-        mData.meaning = { value: value.split("$"), status: 5 };
+        mData.meaning = { value: value, status: 5 };
         break;
       case "factors":
         mData.factors = { value: value, status: 5 };
@@ -43,7 +72,18 @@ const Widget = ({ word }: IWidget) => {
         mData.parent = { value: value, status: 5 };
         break;
       case "case":
-        mData.case = { value: value.split("$"), status: 5 };
+        const _case = value
+          .replaceAll("n$base", "n.:.base")
+          .replaceAll("ti$base", "ti.:.base")
+          .split("$");
+        const _type = "." + _case[0] + ".";
+        const _grammar = _case
+          .slice(1)
+          .map((item) => `.${item}.`)
+          .join("$");
+        mData.type = { value: _type, status: 7 };
+        mData.grammar = { value: _grammar, status: 7 };
+        mData.case = { value: value, status: 7 };
         break;
       case "confidence":
         mData.confidence = parseFloat(value);
@@ -85,6 +125,7 @@ const Widget = ({ word }: IWidget) => {
 
       <WbwDetailBasic
         data={editWord}
+        showRelation={false}
         onChange={(e: IWbwField) => {
           console.log("WbwDetailBasic onchange", e);
           fieldChanged(e.field, e.value);
@@ -108,21 +149,23 @@ const Widget = ({ word }: IWidget) => {
           onClick={() => {
             console.log("edit word", editWord);
             setLoading(true);
+            const data = [
+              {
+                word: editWord.word.value,
+                type: editWord.type?.value,
+                grammar: editWord.grammar?.value,
+                mean: editWord.meaning?.value,
+                parent: editWord.parent?.value,
+                note: editWord.note?.value,
+                factors: editWord.factors?.value,
+                factormean: editWord.factorMeaning?.value,
+                confidence: editWord.confidence,
+              },
+            ];
+            console.log("wbw data", data);
             post<IUserDictCreate, IDictResponse>("/v2/userdict", {
               view: "dict",
-              data: JSON.stringify([
-                {
-                  word: editWord.word.value,
-                  type: editWord.type?.value,
-                  grammar: editWord.grammar?.value,
-                  mean: editWord.meaning?.value.join("$"),
-                  parent: editWord.parent?.value,
-                  note: editWord.note?.value,
-                  factors: editWord.factors?.value,
-                  factormean: editWord.factorMeaning?.value,
-                  confidence: editWord.confidence,
-                },
-              ]),
+              data: JSON.stringify(data),
             })
               .finally(() => {
                 setLoading(false);

+ 2 - 8
dashboard/src/components/dict/SearchVocabulary.tsx

@@ -116,14 +116,8 @@ const Widget = ({ value, onSplit, onSearch }: IWidget) => {
         }}
         onSearch={(value: string) => {
           console.log("auto complete on search", value);
-          if (fetching) {
-            console.log("fetching");
-          } else {
-            setFetching(true);
-            search(value);
-            //stopLookup();
-            //startLookup(value);
-          }
+          setFetching(true);
+          search(value);
         }}
         onSelect={(value: string, option: ValueType) => {
           if (typeof onSearch !== "undefined") {

+ 131 - 17
dashboard/src/components/dict/SelectCase.tsx

@@ -1,5 +1,6 @@
 import { useIntl } from "react-intl";
 import { Cascader } from "antd";
+import { useEffect, useState } from "react";
 
 interface CascaderOption {
   value: string | number;
@@ -7,11 +8,20 @@ interface CascaderOption {
   children?: CascaderOption[];
 }
 interface IWidget {
-  defaultValue?: string[];
+  value?: string;
   onCaseChange?: Function;
 }
-const Widget = ({ defaultValue, onCaseChange }: IWidget) => {
+const Widget = ({ value, onCaseChange }: IWidget) => {
   const intl = useIntl();
+  const [currValue, setCurrValue] = useState<(string | number)[]>();
+
+  useEffect(() => {
+    const arrValue = value
+      ?.replaceAll("#", "$")
+      .split("$")
+      .map((item) => item.replaceAll(".", ""));
+    setCurrValue(arrValue);
+  }, [value]);
 
   const case8 = [
     {
@@ -38,6 +48,10 @@ const Widget = ({ defaultValue, onCaseChange }: IWidget) => {
       value: "abl",
       label: intl.formatMessage({ id: "dict.fields.type.abl.label" }),
     },
+    {
+      value: "loc",
+      label: intl.formatMessage({ id: "dict.fields.type.loc.label" }),
+    },
     {
       value: "voc",
       label: intl.formatMessage({ id: "dict.fields.type.voc.label" }),
@@ -54,10 +68,6 @@ const Widget = ({ defaultValue, onCaseChange }: IWidget) => {
       label: intl.formatMessage({ id: "dict.fields.type.pl.label" }),
       children: case8,
     },
-    {
-      value: "base",
-      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
-    },
   ];
   const case3 = [
     {
@@ -76,6 +86,81 @@ const Widget = ({ defaultValue, onCaseChange }: IWidget) => {
       children: case2,
     },
   ];
+  const case3_ti = [
+    ...case3,
+    {
+      value: "base",
+      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+      children: [
+        {
+          value: "base",
+          label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+        },
+        {
+          value: "prp",
+          label: intl.formatMessage({ id: "dict.fields.type.prp.label" }),
+        },
+        {
+          value: "pp",
+          label: intl.formatMessage({ id: "dict.fields.type.pp.label" }),
+        },
+        {
+          value: "fpp",
+          label: intl.formatMessage({ id: "dict.fields.type.fpp.label" }),
+        },
+      ],
+    },
+  ];
+  const case3_pron = [
+    ...case3,
+    {
+      value: "1p",
+      label: intl.formatMessage({ id: "dict.fields.type.1p.label" }),
+      children: case2,
+    },
+    {
+      value: "2p",
+      label: intl.formatMessage({ id: "dict.fields.type.2p.label" }),
+      children: case2,
+    },
+    {
+      value: "3p",
+      label: intl.formatMessage({ id: "dict.fields.type.3p.label" }),
+      children: case2,
+    },
+    {
+      value: "base",
+      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+    },
+  ];
+  const case3_n = [
+    ...case3,
+    {
+      value: "base",
+      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+      children: [
+        {
+          value: "m",
+          label: intl.formatMessage({ id: "dict.fields.type.m.label" }),
+        },
+        {
+          value: "nt",
+          label: intl.formatMessage({ id: "dict.fields.type.nt.label" }),
+        },
+        {
+          value: "f",
+          label: intl.formatMessage({ id: "dict.fields.type.f.label" }),
+        },
+      ],
+    },
+  ];
+  const case3_num = [
+    ...case3,
+    {
+      value: "base",
+      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+    },
+  ];
   const caseVerb3 = [
     {
       value: "pres",
@@ -200,12 +285,12 @@ const Widget = ({ defaultValue, onCaseChange }: IWidget) => {
     {
       value: "n",
       label: intl.formatMessage({ id: "dict.fields.type.n.label" }),
-      children: case3,
+      children: case3_n,
     },
     {
       value: "ti",
       label: intl.formatMessage({ id: "dict.fields.type.ti.label" }),
-      children: case3,
+      children: case3_ti,
     },
     {
       value: "v",
@@ -224,21 +309,50 @@ const Widget = ({ defaultValue, onCaseChange }: IWidget) => {
     {
       value: "adj",
       label: intl.formatMessage({ id: "dict.fields.type.adj.label" }),
-      children: case3,
+      children: case3_ti,
+    },
+    {
+      value: "pron",
+      label: intl.formatMessage({ id: "dict.fields.type.pron.label" }),
+      children: case3_pron,
+    },
+    {
+      value: "num",
+      label: intl.formatMessage({ id: "dict.fields.type.num.label" }),
+      children: case3_num,
     },
   ];
-  const onChange = (value: (string | number)[]) => {
-    console.log("case changed", value);
-    if (typeof onCaseChange !== "undefined") {
-      onCaseChange(value);
-    }
-  };
-  console.log("case", defaultValue);
   return (
     <Cascader
+      value={currValue}
       options={options}
       placeholder="Please select case"
-      onChange={onChange}
+      onChange={(value: (string | number)[]) => {
+        console.log("case changed", value);
+        let newValue: (string | number)[];
+        if (
+          value.length > 1 &&
+          value[value.length - 1] === value[value.length - 2]
+        ) {
+          newValue = value.slice(0, -1);
+        } else {
+          newValue = value;
+        }
+        setCurrValue(newValue);
+        if (typeof onCaseChange !== "undefined") {
+          let output = newValue.map((item) => `.${item}.`).join("$");
+          output = output.replace(".$.base", ":base");
+          if (output.indexOf("$") > 0) {
+            output =
+              output.substring(0, output.indexOf("$")) +
+              "#" +
+              output.substring(output.indexOf("$") + 1);
+          } else {
+            output += "#";
+          }
+          onCaseChange(output);
+        }
+      }}
     />
   );
 };

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.