Ver Fonte

Merge pull request #1089 from visuddhinanda/agile

Agile
visuddhinanda há 3 anos atrás
pai
commit
e1dc6faa94
100 ficheiros alterados com 5010 adições e 1469 exclusões
  1. 2 0
      dashboard/src/App.css
  2. 135 114
      dashboard/src/Router.tsx
  3. 62 0
      dashboard/src/assets/library/images/books.svg
  4. 127 0
      dashboard/src/assets/library/images/teachers.svg
  5. 20 6
      dashboard/src/components/anthology/AnthologyCreate.tsx
  6. 297 0
      dashboard/src/components/anthology/AnthologyList.tsx
  7. 50 0
      dashboard/src/components/anthology/AnthologyModal.tsx
  8. 52 0
      dashboard/src/components/anthology/AnthologyTocTree.tsx
  9. 79 0
      dashboard/src/components/anthology/EditableTocTree.tsx
  10. 37 1
      dashboard/src/components/api/Article.ts
  11. 8 1
      dashboard/src/components/api/Auth.ts
  12. 6 8
      dashboard/src/components/api/Channel.ts
  13. 3 0
      dashboard/src/components/api/Comment.ts
  14. 44 4
      dashboard/src/components/api/Corpus.ts
  15. 45 1
      dashboard/src/components/api/Course.ts
  16. 40 1
      dashboard/src/components/api/Dict.ts
  17. 6 0
      dashboard/src/components/api/Group.ts
  18. 5 0
      dashboard/src/components/api/Guide.ts
  19. 5 0
      dashboard/src/components/api/Tag.ts
  20. 5 0
      dashboard/src/components/api/Term.ts
  21. 23 24
      dashboard/src/components/article/AnthologyDetail.tsx
  22. 129 0
      dashboard/src/components/article/AnthologyInfoEdit.tsx
  23. 1 2
      dashboard/src/components/article/AnthologyList.tsx
  24. 138 24
      dashboard/src/components/article/Article.tsx
  25. 10 42
      dashboard/src/components/article/ArticleCard.tsx
  26. 20 5
      dashboard/src/components/article/ArticleCreate.tsx
  27. 0 102
      dashboard/src/components/article/ArticleTabs.tsx
  28. 115 0
      dashboard/src/components/article/ArticleTplMaker.tsx
  29. 36 8
      dashboard/src/components/article/ArticleView.tsx
  30. 84 10
      dashboard/src/components/article/EditableTree.tsx
  31. 83 0
      dashboard/src/components/article/ExerciseList.tsx
  32. 21 0
      dashboard/src/components/article/MainMenu.tsx
  33. 53 0
      dashboard/src/components/article/ModeSwitch.tsx
  34. 81 0
      dashboard/src/components/article/RightPanel.tsx
  35. 47 0
      dashboard/src/components/article/RightToolsSwitch.tsx
  36. 2 2
      dashboard/src/components/article/TocTree.tsx
  37. 38 0
      dashboard/src/components/article/ToolButton.tsx
  38. 24 0
      dashboard/src/components/article/ToolButtonSearch.tsx
  39. 20 0
      dashboard/src/components/article/ToolButtonSetting.tsx
  40. 22 0
      dashboard/src/components/article/ToolButtonTag.tsx
  41. 27 0
      dashboard/src/components/article/ToolButtonToc.tsx
  42. 32 0
      dashboard/src/components/article/article.css
  43. 5 5
      dashboard/src/components/auth/SignInAvatar.tsx
  44. 4 4
      dashboard/src/components/auth/StudioCard.tsx
  45. 4 4
      dashboard/src/components/auth/StudioName.tsx
  46. 8 1
      dashboard/src/components/auth/ToLibaray.tsx
  47. 8 1
      dashboard/src/components/auth/ToStudio.tsx
  48. 0 2
      dashboard/src/components/auth/UserName.tsx
  49. 9 9
      dashboard/src/components/auth/setting/SettingItem.tsx
  50. 21 9
      dashboard/src/components/channel/ChannelCreate.tsx
  51. 32 19
      dashboard/src/components/channel/ChannelList.tsx
  52. 9 7
      dashboard/src/components/channel/ChannelListItem.tsx
  53. 6 3
      dashboard/src/components/channel/ChannelPicker.tsx
  54. 207 219
      dashboard/src/components/channel/ChannelPickerTable.tsx
  55. 208 0
      dashboard/src/components/channel/ChannelSentDiff.tsx
  56. 53 0
      dashboard/src/components/channel/CopyToModal.tsx
  57. 40 0
      dashboard/src/components/channel/CopyToResult.tsx
  58. 117 0
      dashboard/src/components/channel/CopyToStep.tsx
  59. 3 3
      dashboard/src/components/comment/CommentBox.tsx
  60. 123 93
      dashboard/src/components/comment/CommentCreate.tsx
  61. 1 0
      dashboard/src/components/comment/CommentEdit.tsx
  62. 1 1
      dashboard/src/components/comment/CommentList.tsx
  63. 18 16
      dashboard/src/components/comment/CommentListCard.tsx
  64. 8 3
      dashboard/src/components/comment/CommentTopicInfo.tsx
  65. 32 31
      dashboard/src/components/corpus/BookTree.tsx
  66. 97 59
      dashboard/src/components/corpus/BookTreeList.tsx
  67. 34 34
      dashboard/src/components/corpus/BookViewer.tsx
  68. 31 20
      dashboard/src/components/corpus/ChapterCard.tsx
  69. 49 20
      dashboard/src/components/corpus/ChapterFilter.tsx
  70. 35 22
      dashboard/src/components/corpus/ChapterFilterLang.tsx
  71. 23 19
      dashboard/src/components/corpus/ChapterFilterProgress.tsx
  72. 36 15
      dashboard/src/components/corpus/ChapterFilterType.tsx
  73. 119 47
      dashboard/src/components/corpus/ChapterInChannel.tsx
  74. 61 28
      dashboard/src/components/corpus/ChapterList.tsx
  75. 13 11
      dashboard/src/components/corpus/ChapterTagList.tsx
  76. 17 9
      dashboard/src/components/corpus/PaliChapterCard.tsx
  77. 21 18
      dashboard/src/components/corpus/PaliChapterChannelList.tsx
  78. 11 20
      dashboard/src/components/corpus/PaliChapterHead.tsx
  79. 33 22
      dashboard/src/components/corpus/PaliChapterList.tsx
  80. 13 14
      dashboard/src/components/corpus/PaliChapterListByPara.tsx
  81. 2 0
      dashboard/src/components/corpus/PaliChapterListByTag.tsx
  82. 1 1
      dashboard/src/components/corpus/TocPath.tsx
  83. 26 0
      dashboard/src/components/corpus/TocStyleSelect.tsx
  84. 38 4
      dashboard/src/components/course/CourseHead.tsx
  85. 406 0
      dashboard/src/components/course/CourseInfoEdit.tsx
  86. 15 6
      dashboard/src/components/course/CourseMember.tsx
  87. 406 0
      dashboard/src/components/course/CourseMemberList.tsx
  88. 48 0
      dashboard/src/components/course/ExerciseAnswer.tsx
  89. 153 0
      dashboard/src/components/course/JoinCourse.tsx
  90. 114 0
      dashboard/src/components/course/LeaveCourse.tsx
  91. 0 55
      dashboard/src/components/course/LessonTreeShow.tsx
  92. 114 0
      dashboard/src/components/course/SelectChannel.tsx
  93. 0 95
      dashboard/src/components/course/StudentsSelect.tsx
  94. 0 82
      dashboard/src/components/course/TeacherSelect.tsx
  95. 1 1
      dashboard/src/components/course/TextBook.tsx
  96. 45 24
      dashboard/src/components/dict/CaseList.tsx
  97. 81 0
      dashboard/src/components/dict/Compound.tsx
  98. 2 42
      dashboard/src/components/dict/DictComponent.tsx
  99. 7 5
      dashboard/src/components/dict/DictContent.tsx
  100. 7 11
      dashboard/src/components/dict/DictEdit.tsx

+ 2 - 0
dashboard/src/App.css

@@ -2,6 +2,8 @@
 @import "~react-quill/dist/quill.snow.css";
 @import "~video.js/dist/video-js.css";
 @import "./assets/font/main.css";
+@import "./theme/antd.dark.css";
+@import "./theme/antpro.dark.css";
 
 body {
   margin: 0;

+ 135 - 114
dashboard/src/Router.tsx

@@ -17,6 +17,7 @@ import NutNotFound from "./pages/nut/not-found";
 import NutSwitchLanguage from "./pages/nut/switch-languages";
 import NutHome from "./pages/nut";
 
+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";
@@ -31,7 +32,6 @@ import LibraryLessonShow from "./pages/library/course/lesson";
 import LibraryTerm from "./pages/library/term/show";
 import LibraryDict from "./pages/library/dict";
 import LibraryDictShow from "./pages/library/dict/show";
-import LibraryDictRecent from "./pages/library/dict/recent";
 import LibraryAnthology from "./pages/library/anthology";
 import LibraryAnthologyShow from "./pages/library/anthology/show";
 import LibraryAnthologyList from "./pages/library/anthology/list";
@@ -68,7 +68,6 @@ import StudioGroupShow from "./pages/studio/group/show";
 import StudioCourse from "./pages/studio/course";
 import StudioCourseList from "./pages/studio/course/list";
 import StudioCourseEdit from "./pages/studio/course/edit";
-import StudioCourseShow from "./pages/studio/course/show";
 
 import StudioDict from "./pages/studio/dict";
 import StudioDictList from "./pages/studio/dict/list";
@@ -86,140 +85,162 @@ import StudioAnthologyEdit from "./pages/studio/anthology/edit";
 
 import StudioAnalysis from "./pages/studio/analysis";
 import StudioAnalysisList from "./pages/studio/analysis/list";
+import { ConfigProvider } from "antd";
+import { useAppSelector } from "./hooks";
+import { currTheme } from "./reducers/theme";
 
 const Widget = () => {
+  const theme = useAppSelector(currTheme);
   return (
-    <Routes>
-      <Route path="anonymous" element={<Anonymous />}>
-        <Route path="users">
-          <Route path="sign-in" element={<NutUsersSignIn />} />
-          <Route path="sign-up" element={<NutUsersSignUp />} />
-
-          <Route path="unlock">
-            <Route path="new" element={<NutUsersUnlockNew />} />
-            <Route path="verify/:token" element={<NutUsersUnlockVerify />} />
+    <ConfigProvider prefixCls={theme}>
+      <Routes>
+        <Route path="anonymous" element={<Anonymous />}>
+          <Route path="users">
+            <Route path="sign-in" element={<NutUsersSignIn />} />
+            <Route path="sign-up" element={<NutUsersSignUp />} />
+
+            <Route path="unlock">
+              <Route path="new" element={<NutUsersUnlockNew />} />
+              <Route path="verify/:token" element={<NutUsersUnlockVerify />} />
+            </Route>
+            <Route
+              path="reset-password/:token"
+              element={<NutUsersResetPassword />}
+            />
+            <Route
+              path="forgot-password"
+              element={<NutUsersForgotPassword />}
+            />
           </Route>
-          <Route
-            path="reset-password/:token"
-            element={<NutUsersResetPassword />}
-          />
-          <Route path="forgot-password" element={<NutUsersForgotPassword />} />
         </Route>
-      </Route>
 
-      <Route path="dashboard" element={<Dashboard />}>
-        <Route path="users">
-          <Route path="change-password" element={<NutUsersChangePassword />} />
-          <Route path="logs" element={<NutUsersLogs />} />
-          <Route path="account-info" element={<NutUsersAccountInfo />} />
+        <Route path="dashboard" element={<Dashboard />}>
+          <Route path="users">
+            <Route
+              path="change-password"
+              element={<NutUsersChangePassword />}
+            />
+            <Route path="logs" element={<NutUsersLogs />} />
+            <Route path="account-info" element={<NutUsersAccountInfo />} />
+          </Route>
+        </Route>
+        <Route path="switch-language" element={<NutSwitchLanguage />} />
+        <Route path="forbidden" element={<NutForbidden />} />
+        <Route path="nut" element={<NutHome />} />
+        <Route path="" element={<LibraryHome />} />
+        <Route path="*" element={<NutNotFound />} />
+
+        <Route path="community" element={<LibraryCommunity />}>
+          <Route path="list" element={<LibraryCommunityList />} />
+          <Route path="recent" element={<LibraryCommunityRecent />} />
         </Route>
-      </Route>
-      <Route path="switch-language" element={<NutSwitchLanguage />} />
-      <Route path="forbidden" element={<NutForbidden />} />
-      <Route path="" element={<NutHome />} />
-      <Route path="*" element={<NutNotFound />} />
-
-      <Route path="community" element={<LibraryCommunity />}>
-        <Route path="list" element={<LibraryCommunityList />} />
-        <Route path="recent" element={<LibraryCommunityRecent />} />
-      </Route>
-      <Route path="palicanon" element={<LibraryPalicanon />}>
-        <Route path="list" element={<LibraryPalicanonByPath />} />
-        <Route path="list/:root" element={<LibraryPalicanonByPath />} />
-        <Route path="list/:root/:path" element={<LibraryPalicanonByPath />} />
-        <Route
-          path="list/:root/:path/:tag"
-          element={<LibraryPalicanonByPath />}
-        />
-        <Route path="chapter/:id" element={<LibraryPalicanonChapter />} />
-      </Route>
-      <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 />} />
-
-      <Route path="dict" element={<LibraryDict />}>
-        <Route path=":word" element={<LibraryDictShow />} />
-        <Route path="recent" element={<LibraryDictRecent />} />
-      </Route>
-
-      <Route path="anthology" element={<LibraryAnthology />}>
-        <Route path="list" element={<LibraryAnthologyList />} />
-        <Route path=":id" element={<LibraryAnthologyShow />} />
-        <Route path=":id/by_channel/:tags" element={<LibraryAnthologyShow />} />
-      </Route>
-
-      <Route path="article" element={<LibraryArticle />}>
-        <Route path=":type/:id" element={<LibraryArticleShow />} />
-        <Route path=":type/:id/:mode" element={<LibraryArticleShow />} />
-        <Route path=":type/:id/:mode/:param" element={<LibraryArticleShow />} />
-      </Route>
-
-      <Route path="discussion" element={<LibraryDiscussion />}>
-        <Route path="list" element={<LibraryDiscussionList />} />
-        <Route path="topic/:id" element={<LibraryDiscussionTopic />} />
-        <Route path="discussion/:id" element={<LibraryDiscussion />} />
-      </Route>
-
-      <Route path="blog/:studio" element={<LibraryBlog />}>
-        <Route path="overview" element={<LibraryBlogOverview />} />
-        <Route path="palicanon" element={<LibraryBlogTranslation />} />
-        <Route path="course" element={<LibraryBlogCourse />} />
-        <Route path="anthology" element={<LibraryBlogAnthology />} />
-        <Route path="term" element={<LibraryBlogTerm />} />
-      </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="channel" element={<StudioChannel />}>
-          <Route path="list" element={<StudioChannelList />} />
-          <Route path=":channelid/edit" element={<StudioChannelEdit />} />
-          <Route path=":channelId" element={<StudioChannelShow />} />
+
+        <Route path="palicanon" element={<LibraryPalicanon />}>
+          <Route path="list" element={<LibraryPalicanonByPath />} />
+          <Route path="list/:root" element={<LibraryPalicanonByPath />} />
+          <Route path="list/:root/:path" element={<LibraryPalicanonByPath />} />
+          <Route
+            path="list/:root/:path/:tag"
+            element={<LibraryPalicanonByPath />}
+          />
+          <Route path="chapter/:id" element={<LibraryPalicanonChapter />} />
         </Route>
 
-        <Route path="group" element={<StudioGroup />}>
-          <Route path="list" element={<StudioGroupList />} />
-          <Route path=":groupId" element={<StudioGroupShow />} />
-          <Route path=":groupId/edit" element={<StudioGroupEdit />} />
-          <Route path=":groupId/show" element={<StudioGroupShow />} />
+        <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="course" element={<StudioCourse />}>
-          <Route path="list" element={<StudioCourseList />} />
-          <Route path=":courseId" element={<StudioCourseShow />} />
-          <Route path=":courseId/edit" element={<StudioCourseEdit />} />
-          <Route path=":courseId/show" element={<StudioCourseShow />} />
+        <Route path="term/:word" element={<LibraryTerm />} />
+
+        <Route path="dict" element={<LibraryDict />}>
+          <Route path=":word" element={<LibraryDictShow />} />
+          <Route path="recent" element={<LibraryDictShow />} />
         </Route>
 
-        <Route path="dict" element={<StudioDict />}>
-          <Route path="list" element={<StudioDictList />} />
+        <Route path="anthology" element={<LibraryAnthology />}>
+          <Route path="list" element={<LibraryAnthologyList />} />
+          <Route path=":id" element={<LibraryAnthologyShow />} />
+          <Route
+            path=":id/by_channel/:tags"
+            element={<LibraryAnthologyShow />}
+          />
         </Route>
 
-        <Route path="term" element={<StudioTerm />}>
-          <Route path="list" element={<StudioTermList />} />
+        <Route path="article" element={<LibraryArticle />}>
+          <Route path=":type/:id" element={<LibraryArticleShow />} />
+          <Route path=":type/:id/:mode" element={<LibraryArticleShow />} />
+          <Route
+            path=":type/:id/:mode/:param"
+            element={<LibraryArticleShow />}
+          />
         </Route>
 
-        <Route path="article" element={<StudioArticle />}>
-          <Route path="list" element={<StudioArticleList />} />
-          <Route path=":articleid/edit" element={<StudioArticleEdit />} />
+        <Route path="discussion" element={<LibraryDiscussion />}>
+          <Route path="list" element={<LibraryDiscussionList />} />
+          <Route path="topic/:id" element={<LibraryDiscussionTopic />} />
+          <Route path="discussion/:id" element={<LibraryDiscussion />} />
         </Route>
 
-        <Route path="anthology" element={<StudioAnthology />}>
-          <Route path="list" element={<StudioAnthologyList />}></Route>
-          <Route path=":anthology_id/edit" element={<StudioAnthologyEdit />} />
+        <Route path="blog/:studio" element={<LibraryBlog />}>
+          <Route path="overview" element={<LibraryBlogOverview />} />
+          <Route path="palicanon" element={<LibraryBlogTranslation />} />
+          <Route path="course" element={<LibraryBlogCourse />} />
+          <Route path="anthology" element={<LibraryBlogAnthology />} />
+          <Route path="term" element={<LibraryBlogTerm />} />
         </Route>
 
-        <Route path="analysis" element={<StudioAnalysis />}>
-          <Route path="list" element={<StudioAnalysisList />} />
+        <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="channel" element={<StudioChannel />}>
+            <Route path="list" element={<StudioChannelList />} />
+            <Route path=":channelid/edit" element={<StudioChannelEdit />} />
+            <Route path=":channelId" element={<StudioChannelShow />} />
+          </Route>
+
+          <Route path="group" element={<StudioGroup />}>
+            <Route path="list" element={<StudioGroupList />} />
+            <Route path=":groupId" element={<StudioGroupShow />} />
+            <Route path=":groupId/edit" element={<StudioGroupEdit />} />
+            <Route path=":groupId/show" element={<StudioGroupShow />} />
+          </Route>
+
+          <Route path="course" element={<StudioCourse />}>
+            <Route path="list" element={<StudioCourseList />} />
+            <Route path=":courseId/edit" element={<StudioCourseEdit />} />
+          </Route>
+
+          <Route path="dict" element={<StudioDict />}>
+            <Route path="list" element={<StudioDictList />} />
+          </Route>
+
+          <Route path="term" element={<StudioTerm />}>
+            <Route path="list" element={<StudioTermList />} />
+          </Route>
+
+          <Route path="article" element={<StudioArticle />}>
+            <Route path="list" element={<StudioArticleList />} />
+            <Route path=":articleid/edit" element={<StudioArticleEdit />} />
+          </Route>
+
+          <Route path="anthology" element={<StudioAnthology />}>
+            <Route path="list" element={<StudioAnthologyList />}></Route>
+            <Route
+              path=":anthology_id/edit"
+              element={<StudioAnthologyEdit />}
+            />
+          </Route>
+
+          <Route path="analysis" element={<StudioAnalysis />}>
+            <Route path="list" element={<StudioAnalysisList />} />
+          </Route>
         </Route>
-      </Route>
-    </Routes>
+      </Routes>
+    </ConfigProvider>
   );
 };
 

+ 62 - 0
dashboard/src/assets/library/images/books.svg

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.0" id="books_bg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 610 947" style="enable-background:new 0 0 610 947;" xml:space="preserve">
+<g>
+	<defs>
+		<rect id="SVGID_3_" x="-135.3" y="-0.1" width="1141.1" height="947.4"/>
+	</defs>
+	<clipPath id="SVGID_2_">
+		<use xlink:href="#SVGID_3_"  style="overflow:visible;"/>
+	</clipPath>
+	<g id="Mask_Group_2_1_" style="clip-path:url(#SVGID_2_);">
+		<g id="Group_57_1_" transform="translate(-2443.92 512.47)">
+			<path id="Path_106_1_" style="fill:#EAEAEA;" d="M3010.1,259.9h-514.5l178.3-1001.2h157.9L3010.1,259.9z"/>
+			<path id="Path_128_1_" style="fill:#F0F0F0;" d="M2752.9,168c142.1,0,257.3,43,257.3,96.1s-115.2,96.1-257.3,96.1
+				s-257.3-43-257.3-96.1S2610.8,168,2752.9,168z"/>
+			<g id="Group_56_1_" transform="translate(2703.386 -23.966)">
+				<path id="Path_107_1_" style="fill:#B03C2C;" d="M28.4,261.7l-100.3,23.9c-11.4-0.1-22.1-3-23.3-7.8V114.4L5.2,90.4V99l23.3-0.7
+					L28.4,261.7z"/>
+				<path id="Path_108_1_" style="fill:#782E29;" d="M-71.9,285.6L-71.9,285.6l0-171.2h-23.3v163.4C-94,282.6-83.3,285.5-71.9,285.6
+					z"/>
+				<path id="Path_109_1_" style="fill:#FFFFFF;" d="M-94.8,115.4L4.7,91.6c1.1,4.9,12.3,6.5,23.8,6.6l-100.3,23.9
+					C-82.5,122.1-92.5,119.6-94.8,115.4z"/>
+				<path id="Path_110_1_" style="fill:#B03C2C;" d="M55.2,270.8l-100.3,23.9c-11.4-0.1-22.1-3-23.3-7.8V123.5L32,99.6v8.6l23.3-0.7
+					L55.2,270.8z"/>
+				<path id="Path_111_1_" style="fill:#782E29;" d="M-45.1,294.7L-45.1,294.7l0-171.2h-23.3v163.4
+					C-67.2,291.8-56.5,294.6-45.1,294.7z"/>
+				<path id="Path_112_1_" style="fill:#FFFFFF;" d="M-68,124.5l99.5-23.7c1.1,4.9,12.3,6.5,23.8,6.6l-100.3,23.9
+					C-55.7,131.2-65.7,128.8-68,124.5z"/>
+				<path id="Path_113_1_" style="fill:#B03C2C;" d="M82,279.9l-100.3,23.9c-11.4-0.1-22.1-3-23.3-7.8V132.6l100.3-23.9v8.6
+					l23.3-0.7L82,279.9z"/>
+				<path id="Path_114_1_" style="fill:#782E29;" d="M-18.3,303.8L-18.3,303.8l0-171.2h-23.3V296C-40.4,300.9-29.7,303.7-18.3,303.8
+					z"/>
+				<path id="Path_115_1_" style="fill:#FFFFFF;" d="M-41.2,133.6l99.5-23.7c1.1,4.9,12.3,6.5,23.8,6.6l-100.3,23.9
+					C-28.9,140.4-38.9,137.9-41.2,133.6z"/>
+				<path id="Path_116_1_" style="fill:#B03C2C;" d="M108.8,289L8.5,313c-11.4-0.1-22.1-3-23.3-7.8V141.7l100.3-23.9v8.6l23.3-0.7
+					V289z"/>
+				<path id="Path_117_1_" style="fill:#782E29;" d="M8.5,313L8.5,313l0-171.2h-23.3v163.4C-13.6,310-2.9,312.8,8.5,313z"/>
+				<path id="Path_118_1_" style="fill:#FFFFFF;" d="M-14.4,142.7L85.1,119c1.1,4.9,12.3,6.5,23.8,6.6L8.5,149.6
+					C-2.1,149.5-12.1,147-14.4,142.7z"/>
+				<path id="Path_119_1_" style="fill:#B03C2C;" d="M135.6,298.1L35.3,322.1c-11.4-0.1-22.1-3-23.3-7.8V150.9l100.3-23.9v8.6
+					l23.3-0.7V298.1z"/>
+				<path id="Path_120_1_" style="fill:#782E29;" d="M35.3,322.1L35.3,322.1l0-171.2H12v163.4C13.2,319.1,23.9,322,35.3,322.1z"/>
+				<path id="Path_121_1_" style="fill:#FFFFFF;" d="M12.4,151.9l99.5-23.7c1.1,4.9,12.3,6.5,23.8,6.6L35.3,158.7
+					C24.7,158.6,14.7,156.1,12.4,151.9z"/>
+				<path id="Path_122_1_" style="fill:#B03C2C;" d="M162.4,307.3L62.1,331.2c-11.4-0.1-22.1-3-23.3-7.8V160L139.1,136v8.6l23.3-0.7
+					V307.3z"/>
+				<path id="Path_123_1_" style="fill:#782E29;" d="M62.1,331.2L62.1,331.2l0-171.2H38.8v163.4C39.9,328.2,50.6,331.1,62.1,331.2z"
+					/>
+				<path id="Path_124_1_" style="fill:#FFFFFF;" d="M39.2,161l99.5-23.7c1.1,4.9,12.3,6.5,23.8,6.6L62.1,167.8
+					C51.5,167.7,41.5,165.2,39.2,161z"/>
+				<path id="Path_125_1_" style="fill:#B03C2C;" d="M189.2,316.4L88.9,340.3c-11.4-0.1-22.1-3-23.3-7.8V169.1l100.3-23.9v8.6
+					l23.3-0.7V316.4z"/>
+				<path id="Path_126_1_" style="fill:#782E29;" d="M88.9,340.3L88.9,340.3l0-171.2H65.6v163.4C66.7,337.4,77.4,340.2,88.9,340.3z"
+					/>
+				<path id="Path_127_1_" style="fill:#FFFFFF;" d="M66,170.1l99.5-23.7c1.1,4.9,12.3,6.5,23.8,6.6L88.9,176.9
+					C78.2,176.8,68.2,174.4,66,170.1z"/>
+			</g>
+		</g>
+	</g>
+</g>
+</svg>

+ 127 - 0
dashboard/src/assets/library/images/teachers.svg

@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.0" id="teachers_bg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 763 1491" style="enable-background:new 0 0 763 1491;" xml:space="preserve">
+<g id="Mask_Group_3_1_" transform="translate(0 -32)">
+	<g id="Group_62_1_" transform="translate(-1298.767 1264.372)">
+		<path id="Path_129_1_" style="fill:#545454;" d="M1507.6-1232.2L1361.5-17.1h630.3l-145.6-1215.1"/>
+		<ellipse id="Ellipse_4_1_" style="fill:#686868;" cx="1676.6" cy="-17.1" rx="315.1" ry="104"/>
+		<path id="Path_130_1_" style="fill:#343434;" d="M1724.5-230.8c-0.6-6.1-2.9-11.8-6.5-16.7c0,0,0.5-19.7-0.3-28
+			c-0.7-8.3-3.7-34-5.5-39.1c-1.3-3.9-4.1-38.9-8.3-51.1c-4.5-13.3-10.8-22.8-17.6-27.6c-0.9-0.6-1.8-1.2-2.7-1.8
+			c-0.8-0.8-1.7-1.5-2.6-2c-11.2-5.9-19.8-4.2-20.6-6.5c-0.4-2.7-0.5-5.5-0.3-8.2c5.9-2.3,11.4-5.5,16.3-9.5
+			c17.2-14.2,24.9-43.9,19.5-63.4c-6.3-23.1-28.6-33.7-51.9-32.4c-30.1,1.7-39.1,21.5-42.2,39.8c-2.7,16-1.1,41.2,13.4,59.5
+			c3.1,3.8,7.3,6.7,12,8.2c-0.2,0.8-0.5,1.5-0.9,2.2l0.1,0.1c-0.2,0.5-0.5,0.9-1,1.1c-0.2,0.1-0.6,0.2-1,0.4c-1.7-2.8-3-4.4-4.4-4.4
+			c-0.9,0.1-1.7,0.4-2.5,0.9c-0.3-0.2-0.6-0.4-0.9-0.5c-1.5-0.6-3.5,0.4-5.8,2.3c-14,3.8-27.5,19.8-31.5,28.8
+			c-5.3,11.8-6.3,19-9.7,30.3c-2.8,9.3-6.1,30.9-6.1,30.9s-19.9,50.2-20.9,55.3c-0.4,2.7-0.1,5.5,1.1,8c-3.6,4-6.2,8.8-7.7,14
+			c-2.4,9,6,18.4,9.7,19.8c3.7,1.4,13.7,2.6,17-8.7c0.1-0.3,0.2-0.6,0.2-0.8c0.2,0.2,0.3,0.3,0.5,0.5c-0.8,9.4-1.6,18.8-2.4,28.2
+			c0,0.3,0.2,0.6,0.6,0.8c-3.9,43.5-6.3,84.1-4.4,88.1c2.4,5.2,4.5,10.7,16.8,17.1c-3.4,5.7-7,10.2-15.7,16.8
+			c-9.3,7.1-13.4,13.5-7.1,19.1c6.2,5.6,22.1,8.9,31.2,3.9c9.1-5,13-16.2,15.9-20.6c1-1.6,1.3-5.2,1.3-9.4
+			c20.8,5.9,39.2,5.8,49.2,4.5c0.1,1.4,0.3,2.9,0.6,4.3c1.7,6.8,0.3,21.2,13.9,26.1c8.7,3.1,23.5-1.7,25.4-6.9s-0.5-13.4-7.2-22.4
+			c-2.5-3.6-4.1-7.7-4.7-12c9.7-2.2,13.4-4.7,16.9-9.2c5.1-6.7,3.6-16.3,5.3-41.4c1.5-22.2,0.5-42.4,0-77.2c2.9,6.9,8.4,10,15,9.3
+			C1723-211.7,1725.1-219.7,1724.5-230.8z M1698.8-242.5c-0.1-2.2-0.1-4.5-0.2-6.9c0.7,2.4,1.2,4,1.2,4S1699.3-244.3,1698.8-242.5
+			L1698.8-242.5z"/>
+		<path id="Path_131_1_" style="fill:#434343;" d="M1834.9-183.9c-1.5-5.3-4.1-10.2-7.7-14.3c1.2-2.5,1.5-5.4,1.1-8.1
+			c-1-5.3-20.9-56.5-20.9-56.5s-3.3-22-6.1-31.5c-3.4-11.5-4.4-19-9.7-31c-4-9.2-17.6-25.5-31.6-29.4c-2.3-1.9-4.4-2.9-5.9-2.4
+			c-0.3,0.1-0.7,0.3-0.9,0.5c-0.7-0.5-1.6-0.8-2.5-0.9c-1.4,0-2.8,1.6-4.5,4.4c-0.4-0.1-0.8-0.3-1-0.4c-0.4-0.3-0.8-0.7-1-1.1
+			l0.1-0.1c-0.4-0.7-0.7-1.5-0.9-2.2c4.7-1.5,8.9-4.4,12.1-8.3c14.6-18.7,16.2-44.5,13.5-60.8c-3.1-18.7-12.1-38.9-42.3-40.6
+			c-23.4-1.4-45.7,9.5-52.1,33c-5.4,19.9,2.3,50.2,19.6,64.7c4.9,4.1,10.5,7.4,16.4,9.7c0.2,2.8,0.1,5.6-0.3,8.4
+			c-0.8,2.3-9.5,0.6-20.7,6.6c-1,0.5-1.9,1.2-2.7,2.1c-1,0.6-1.9,1.2-2.8,1.9c-6.8,4.9-13.1,14.6-17.6,28.2
+			c-4.2,12.5-7,48.3-8.3,52.2c-1.8,5.3-4.7,31.4-5.5,39.9c-0.7,8.5-0.3,28.6-0.3,28.6c-3.7,5-5.9,10.9-6.5,17
+			c-0.7,11.4,1.5,19.6,10.4,20.5c6.6,0.7,12.1-2.5,15-9.5c-0.5,35.5-1.5,56.2,0,78.8c1.7,25.6,0.2,35.5,5.3,42.3
+			c3.5,4.6,7.2,7.2,17,9.4c-0.6,4.4-2.2,8.6-4.7,12.3c-6.8,9.2-9.1,17.6-7.2,22.9c1.9,5.3,16.7,10.2,25.4,7
+			c13.7-5,12.3-19.7,14-26.7c0.3-1.4,0.5-2.9,0.6-4.4c10.1,1.3,28.5,1.4,49.4-4.6c-0.1,4.3,0.2,8,1.3,9.6
+			c2.8,4.6,6.8,15.9,15.9,21.1s25,1.7,31.3-4s2.2-12.3-7.2-19.5c-8.7-6.7-12.3-11.3-15.7-17.1c12.3-6.5,14.4-12.1,16.8-17.4
+			c1.9-4.1-0.5-45.5-4.4-90c0.3-0.2,0.6-0.4,0.6-0.8c-0.8-9.6-1.6-19.2-2.4-28.8c0.2-0.2,0.3-0.3,0.5-0.5c0.1,0.3,0.2,0.6,0.2,0.9
+			c3.3,11.6,13.3,10.3,17.1,8.9C1828.9-165,1837.3-174.6,1834.9-183.9z M1671.9-193.2c-0.1,2.4-0.1,4.7-0.2,7c-0.6-1.9-1-3-1-3
+			S1671.1-190.7,1671.9-193.2z"/>
+		<g id="Group_61_1_" transform="translate(2271.497 -30.516)">
+			<path id="Path_132_1_" style="fill:#FFF0DE;" d="M-652.1-290.2c-6.9,3.6-18.3,26.9-20.1,37c-1.7,10.1-5.4,45.3-5.4,45.3
+				s-8,22.3-11,33.2c-2.4,8.7-6.2,31.4-6.2,31.4s-4.2,8.5-1.1,16.6c4.6,12.2,9,17.4,17.9,18.1c5.2,0.4,6.5-11.7,5-15.8
+				c-3.1-8.8-2.5-10.9-2.5-10.9s7.1-19.4,10.1-27.3c3.1-7.9,12-32.4,12.8-37.8c0.6-4.1,9.4-37.9,11.2-50.8c2-14,3.7-31.3,0.8-38.4
+				C-641.9-293.1-645.8-293.5-652.1-290.2z"/>
+			<path id="Path_133_1_" style="fill:#FFF0DE;" d="M-638.2-314.1c0,0,0.8,15,0,17.3s1.1-2.6-8.6,5.5c-10.1,8.4-19.2,34.8-21.1,49.1
+				c-1.4,10.6,1.9,38.3,1.9,38.3l103.8-4.9c0,0-0.8-61.3-12.4-74.1s-26.3-15.4-29-16.5c-2.6-1.1-4.5-17.7-4.5-17.7L-638.2-314.1z"/>
+			<g id="Group_58_1_" transform="translate(44.783 67.382)">
+				<path id="Path_134_1_" style="fill:#F9C8B0;" d="M-653-384.4c0,0,1.7,11.1,2.9,15.4c-2.7,2.5-9.5,5.8-14.6,6.9
+					c-7.3,1.5-13.1-0.6-18.5-2.2c1.3-4.3,0.1-17,0.1-17L-653-384.4z"/>
+			</g>
+			<path id="Path_135_1_" style="fill:#FFF0DE;" d="M-634.9-411.3c-29.8,2.8-38.1,23.2-40.5,41.8c-2.1,16.2,1.7,46.7,17.3,63.6
+				c10.5,11.4,54.2,11.3,66.8-7.1c12.6-18.5,15.1-47.8,9.1-67.3C-589.2-403.6-611.7-413.5-634.9-411.3z"/>
+			<path id="Path_136_1_" style="fill:#FFF0DE;" d="M-576.1,1.1c0,12.3-1.6,26,0,32.8c1.7,6.9,0.3,21.5,13.8,26.4
+				c8.7,3.2,23.3-1.7,25.2-7c1.9-5.3-0.5-13.6-7.2-22.7c-5.4-7.3-5.5-19.1-5.2-23.8c0.4-4.7,0-16.4,0-16.4L-576.1,1.1z"/>
+			<path id="Path_137_1_" style="fill:#FFF0DE;" d="M-626.9,5.2c0,4.5,2.8,24.8,0,29.3s-6.8,15.8-15.8,20.9c-9,5.1-24.8,1.7-31-3.9
+				s-2.2-12.2,7.1-19.3s12.7-11.9,16.3-18.3c3.6-6.4,2.5-16,2.5-16L-626.9,5.2z"/>
+			<path id="Path_138_1_" style="fill:#572A08;" d="M-620.4-349.9c-4.7,0-9.5-1.1-11.2-2.6c-0.4-0.4-0.5-1-0.1-1.4
+				c0.4-0.4,1-0.5,1.4-0.1c2.2,2,14.8,3.7,18.5-0.2c0.4-0.4,1-0.5,1.4-0.1c0.4,0.4,0.5,1,0.1,1.4c0,0-0.1,0.1-0.1,0.1
+				C-612.3-350.7-616.3-349.9-620.4-349.9z"/>
+			<path id="Path_139_1_" style="fill:#572A08;" d="M-660.2-348.4c-3-0.1-6-0.9-8.7-2.3c-0.5-0.2-0.7-0.8-0.5-1.4
+				c0.2-0.5,0.8-0.7,1.4-0.5c6.2,2.8,9.5,3.1,16.9-0.4c0.5-0.2,1.1,0,1.4,0.5c0.2,0.5,0,1.1-0.5,1.4
+				C-653.4-349.5-656.8-348.6-660.2-348.4z"/>
+			<path id="Path_140_1_" style="fill:#572A08;" d="M-638.6-320c-3.2,0.1-6.3-0.4-9.4-1.3c-0.5-0.2-0.8-0.8-0.6-1.3
+				c0.2-0.5,0.8-0.8,1.3-0.6c2.9,0.9,6,1.3,9.1,1.2c3.9-0.1,6.9-0.8,7.9-2.1c0.3-0.4,1-0.5,1.4-0.2c0.4,0.3,0.5,1,0.2,1.4l0,0
+				c-1.8,2.3-6.1,2.9-9.5,2.9C-638.3-320-638.4-320-638.6-320z"/>
+			<path id="Path_141_1_" style="fill:#572A08;" d="M-589.9-333.3c-0.6,0-1-0.4-1-1c0-0.5,0.4-1,0.9-1c3.1-0.4,8.2-3.9,7.7-14.5
+				c-0.1-1.9-1.2-3.6-3-4.5c-2-1.1-4.4-1-6.3,0.2c-0.5,0.3-1.1,0.1-1.4-0.4c-0.3-0.5-0.1-1.1,0.4-1.4l0,0c2.5-1.6,5.7-1.7,8.3-0.3
+				c2.4,1.2,3.9,3.6,4,6.2c0.5,10.5-4.4,15.9-9.5,16.6C-589.8-333.3-589.9-333.3-589.9-333.3z"/>
+			<path id="Path_142_1_" style="fill:#B03C2C;" d="M-626.2-267.4c-15,13.1-43.4,25.9-43.4,25.9s-6.7,71.8-7,115.8
+				c-0.4,44.6-1.9,67.5-0.2,92.9c1.7,25.4,0.2,35.2,5.2,41.9c5.1,6.8,10.7,9,35,12.4s70.5-6.8,76.1-11.3s3.2-150.4-5.5-208.9
+				c-10.7-71.9-23.7-104.6-32.3-104.8C-602.9-303.6-606.9-284.3-626.2-267.4z"/>
+			<path id="Path_143_1_" style="fill:#782E29;" d="M-600.3-288.5c-3.7,0.1-5.9,5.7-7.4,8.4c-4.4,8.3-9.9,16-16.2,23
+				c-11.5,12.1-26.6,22.1-40.5,30.8c-1.1,0.7-0.1,1.9,0.9,1.2c13-7.9,25.3-16.9,36.8-26.9c6.9-6.3,12.8-13.7,17.5-21.8
+				c1.7-2.9,3.3-5.9,5-8.7c0.7-1.1,2.2-4.3,3.8-4.4C-599.3-287-598.9-288.5-600.3-288.5L-600.3-288.5z"/>
+			<path id="Path_144_1_" style="fill:#B03C2C;" d="M-594.8-303.1c-6.5,2.5-17.5,30.7-29.9,107.5S-646.1-89-647.8-52.9
+				c-1.7,36.1-2.3,76.5,2.8,78.8c5.1,2.3,42.3,7.5,72.8-2.6c30.5-10.2,50.8-23.1,54.2-30.5c3.4-7.3,2.3-115.6-9-162.5
+				s-25.9-113.4-36.1-120.7C-573.4-297.7-589.2-305.2-594.8-303.1z"/>
+			<path id="Path_145_1_" style="fill:#782E29;" d="M-595.2-301.9c-2.7,1.1-4.3,4-5.6,6.4c-2.8,5.4-5,11.1-6.7,16.9
+				c-7.8,24.5-12.4,50.2-16.6,75.5c-5,29.8-10.2,59.5-15.9,89.2c-3.5,18.7-7.1,37.4-8.4,56.4c-1.2,16.6-1.6,33.4-1.2,50
+				c0,6.8,0.5,13.5,1.4,20.2c0.4,2.5,0.8,7.1,3.3,8.4c0.5,0.3,2.4-0.5,1.6-0.9c-2.3-1.2-2.6-6.1-3-8.3c-0.8-5.7-1.2-11.5-1.3-17.3
+				c-0.4-16,0-32,0.9-48c1-18,4-35.8,7.3-53.5c5-26.7,10.1-53.3,14.5-80.1c4.6-27.8,9.1-55.7,16.6-82.8c2-7.7,4.5-15.2,7.7-22.5
+				c1.2-2.7,3.1-7.6,6.2-8.8C-593.2-301.7-594.3-302.3-595.2-301.9L-595.2-301.9z"/>
+			<path id="Path_146_1_" style="fill:#FFF0DE;" d="M-588.8-130.7c-4.5,8.3,1.4,19.5,4.6,21.7c3.3,2.2,12.6,5.8,18.5-4.6
+				c5.9-10.4,11.2-29.2,2-32.5C-572.9-149.2-584.3-139.1-588.8-130.7z"/>
+			<path id="Path_147_1_" style="fill:#B03C2C;" d="M-560.7-291.9c15.1,10.6,24.9,30.9,34.1,50.5c9.4,19.9,22.4,79.7,22.1,96
+				c-0.3,16.5-5.2,66.6-11.9,68.3c-6.8,1.7-26.7-18.8-37.9-40.6c-11.4-22.1-20.6-28.6-18.7-32.3c2.4-4.7,15.7-46.6,15.7-46.6
+				s-8.8-22.1-13.6-30.7c-5.4-9.5-10.1-19.3-14-29.5c-4.1-10.6-9.9-38.2-4.7-40.2C-581.4-300.3-565.5-295.3-560.7-291.9z"/>
+			<path id="Path_148_1_" style="fill:#782E29;" d="M-516.6-80.5c-3.1,0.6-7.3-3.1-9.5-4.9c-4.2-3.5-8-7.5-11.5-11.7
+				c-4.1-4.9-7.8-10-11.1-15.4c-3.6-5.7-6.4-11.8-9.9-17.6c-2.7-4.5-5.6-8.8-8.7-13.1c-1.6-2.3-3.8-4.6-4.8-7.3
+				c-0.6-1.7,0-2.7,0.6-4.2c1-2.4,1.8-4.9,2.7-7.4c2.6-7.4,5-14.9,7.4-22.3c1.1-3.3,2.1-6.6,3.2-9.9c0.7-1.6,1.3-3.3,1.6-5.1
+				c-0.1-1.1-0.5-2.2-1-3.2c-1.5-3.8-3.1-7.5-4.7-11.2c-3.9-9-8.4-17.6-12.9-26.3c-5.5-10.2-9.7-21.1-12.6-32.3
+				c-1.3-5.3-2.2-10.6-2.8-16c-0.2-2.1-1.4-9.8,1.2-11c1.2-0.6,0.1-1.4-0.8-0.9c-2.1,1-2.4,3.5-2.6,5.7c-0.2,4.2,0.2,8.3,0.9,12.4
+				c1.6,10.2,4.5,20.1,8.5,29.6c4.4,10.4,10.3,20,15,30.3c2.1,4.6,4.1,9.3,6.1,14c1.1,2.7,3.2,6,3.6,8.9c-0.1,1-0.3,2-0.7,2.9
+				c-0.9,2.9-1.8,5.8-2.8,8.7c-2.5,7.9-5.1,15.8-7.8,23.7c-1,3-2.1,6.1-3.2,9.1c-0.6,1.4-1.5,2.8-1.2,4.4c0.5,2.3,2.4,4.5,3.7,6.3
+				c2.8,3.9,5.6,7.9,8.2,12c4.3,6.8,7.6,14,11.8,20.8c4,6.4,8.5,12.5,13.5,18.1c3.6,4.2,7.7,8,12.2,11.3c2.5,1.7,5.4,3.5,8.6,2.9
+				C-515-79.6-515.3-80.7-516.6-80.5L-516.6-80.5z"/>
+			<path id="Path_149_1_" style="fill:#572A08;" d="M-642.4-330.9L-642.4-330.9c-3.5-0.2-4.8-1.7-5.3-2.9c-0.9-2.1,0.2-5,2.9-8.1
+				c0.4-0.4,1-0.5,1.4-0.1c0.4,0.4,0.5,1,0.1,1.4c-2.2,2.5-3.1,4.7-2.6,6c0.5,1.2,2.1,1.5,3.4,1.6c0.6,0,1,0.5,0.9,1.1
+				C-641.4-331.3-641.9-330.9-642.4-330.9L-642.4-330.9z"/>
+			<path id="Path_150_1_" style="fill:#572A08;" d="M-627.7-363.9c0.2-0.6,0.6-1.2,1.1-1.6c0.5-0.4,1.1-0.7,1.8-0.9
+				c1.2-0.4,2.5-0.5,3.8-0.4c1.2,0.1,2.5,0.3,3.6,0.7c1.2,0.4,2.3,1,3.3,1.7c-1.2,0-2.4,0-3.6-0.1l-3.5-0.3
+				c-1.1-0.1-2.2-0.1-3.3-0.1C-625.6-364.8-626.8-364.5-627.7-363.9z"/>
+			<path id="Path_151_1_" style="fill:#572A08;" d="M-654-362.9c-1-0.6-2.1-0.9-3.2-0.9c-1.1-0.1-2.2,0-3.3,0.1l-3.5,0.3
+				c-1.2,0.1-2.4,0.2-3.6,0.1c1-0.7,2.1-1.3,3.3-1.7c1.2-0.4,2.4-0.7,3.6-0.7c1.3-0.1,2.6,0,3.8,0.4c0.6,0.2,1.2,0.5,1.8,0.9
+				C-654.7-364.1-654.3-363.5-654-362.9z"/>
+			<g id="Group_60_1_" transform="translate(0 95.202)">
+				<g id="Group_59_1_">
+					<path id="Path_152_1_" style="fill:#EDE8E6;" d="M-592.1-323.9c-5.1-35.8-25.6-55.9-56.5-47.4l0,0l0,0
+						c-23.7,6.5-39,27.3-47.2,51c-6.1,17.8-8.3,41.1,0.1,52c6.2,8,22.2,7.5,28.3,9.4c0.1,0,0.3,0.1,0.4,0.1s0.3,0.1,0.4,0.1
+						c6.7,2,26.9,14.8,41.6,11C-599-254.2-587.7-293.4-592.1-323.9z"/>
+					<path id="Path_153_1_" style="fill:#572A08;" d="M-649.4-367L-649.4-367l0.1,0c12.7-3.2,24.1-1.4,33.4,6.3
+						c9.3,7.7,15.7,21.1,18.1,38.6c1.9,14.8,0.2,29.8-5.1,43.7c-6.9,17.7-16.8,23.7-23.5,25.5c-4.2,1.2-9.8,0.7-16.5-1.2
+						c-5.3-1.6-10.5-3.6-15.5-5.9c-2.4-1.1-4.9-2.1-7.5-3c-0.1,0-0.2-0.1-0.4-0.1l-0.1,0l-0.1,0c-0.1,0-0.2-0.1-0.4-0.1
+						c-2.4-0.5-4.7-0.9-7.1-1.1c-4-0.4-7.9-1-11.8-2c-3.3-0.7-6.3-2.5-8.6-4.9c-4.5-5.6-5.1-14.6-4.9-21c0.4-9.2,2.2-18.3,5.2-27
+						c3.5-10.7,9-20.7,16.1-29.4C-670.5-357.7-660.5-364.1-649.4-367 M-648.5-371.3L-648.5-371.3L-648.5-371.3
+						c-23.8,6.5-39.1,27.4-47.3,51c-6.1,17.8-8.3,41.1,0.1,52c2.5,2.9,5.8,4.9,9.5,5.8c6.8,2.1,14.9,2.4,18.9,3.5
+						c0.1,0,0.3,0.1,0.4,0.1c0.1,0,0.3,0.1,0.4,0.1c4.1,1.2,13.1,6.4,22.9,9.4c6.3,2,13,3,18.6,1.6c26-6.6,37.3-45.7,32.9-76.3
+						C-597.2-359.8-617.7-379.8-648.5-371.3L-648.5-371.3z"/>
+				</g>
+				<path id="Path_154_1_" style="fill:#782E29;" d="M-664.5-274.3c-4.1-0.9-8.4,4.8-9.6,12.7c-0.1,0.5-0.1,0.9-0.2,1.4
+					c2.3,0.3,4.6,0.7,6.9,1.2c0.1,0,0.3,0.1,0.4,0.1s0.3,0.1,0.4,0.1c2.5,0.9,4.9,1.9,7.2,3.1c0.1-0.5,0.2-1,0.3-1.5
+					C-657.7-265.7-660.2-273.4-664.5-274.3z"/>
+				<path id="Path_155_1_" style="fill:#782E29;" d="M-670.6-214.3h-7.4l9.1-57.4l7.9,1.8L-670.6-214.3z"/>
+			</g>
+			<path id="Path_156_1_" style="fill:#FFF0DE;" d="M-694.6-142.1c0,0-6.2,7.2-5.2,16.4c1.4,13,11.3,23.3,20.3,20.3
+				c9.3-3.2,10.8-8.7,9.7-17c-1.3-9.6-6.7-13.1-6.7-13.1L-694.6-142.1z"/>
+		</g>
+	</g>
+</g>
+</svg>

+ 20 - 6
dashboard/src/components/anthology/AnthologyCreate.tsx

@@ -1,10 +1,15 @@
-import { ProForm, ProFormText } from "@ant-design/pro-components";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
 import { useIntl } from "react-intl";
 import { message } from "antd";
 
 import LangSelect from "../general/LangSelect";
 import { IAnthologyCreateRequest, IAnthologyResponse } from "../api/Article";
 import { post } from "../../request";
+import { useRef } from "react";
 
 interface IFormData {
   title: string;
@@ -12,17 +17,22 @@ interface IFormData {
   studio: string;
 }
 
-type IWidgetAnthologyCreate = {
+interface IWidget {
   studio?: string;
-};
-const Widget = (prop: IWidgetAnthologyCreate) => {
+  onSuccess?: Function;
+}
+const Widget = ({ studio, onSuccess }: IWidget) => {
   const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
 
   return (
     <ProForm<IFormData>
+      formRef={formRef}
       onFinish={async (values: IFormData) => {
-        // TODO
-        values.studio = prop.studio ? prop.studio : "";
+        if (typeof studio === "undefined") {
+          return;
+        }
+        values.studio = studio;
         console.log(values);
         const res = await post<IAnthologyCreateRequest, IAnthologyResponse>(
           `/v2/anthology`,
@@ -31,6 +41,10 @@ const Widget = (prop: IWidgetAnthologyCreate) => {
         console.log(res);
         if (res.ok) {
           message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+            formRef.current?.resetFields(["title"]);
+          }
         } else {
           message.error(res.message);
         }

+ 297 - 0
dashboard/src/components/anthology/AnthologyList.tsx

@@ -0,0 +1,297 @@
+import { ActionType, ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router-dom";
+import { message, Modal, Typography } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+import { Button, Dropdown, Popover } from "antd";
+import {
+  ExclamationCircleOutlined,
+  TeamOutlined,
+  DeleteOutlined,
+} from "@ant-design/icons";
+
+import AnthologyCreate from "../../components/anthology/AnthologyCreate";
+import {
+  IAnthologyListResponse,
+  IDeleteResponse,
+} from "../../components/api/Article";
+import { delete_, get } from "../../request";
+import { PublicityValueEnum } from "../../components/studio/table";
+import { useRef, useState } from "react";
+
+const { Text } = Typography;
+
+interface IItem {
+  sn: number;
+  id: string;
+  title: string;
+  subtitle: string;
+  publicity: number;
+  articles: number;
+  createdAt: number;
+}
+interface IWidget {
+  studioName?: string;
+  showCol?: string[];
+  showCreate?: boolean;
+  onTitleClick?: Function;
+}
+const Widget = ({
+  studioName,
+  showCol,
+  showCreate = true,
+  onTitleClick,
+}: IWidget) => {
+  const intl = useIntl();
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const showDeleteConfirm = (id: string, title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.sure",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_<IDeleteResponse>(`/v2/anthology/${id}`)
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+  const ref = useRef<ActionType>();
+  return (
+    <>
+      <ProTable<IItem>
+        actionRef={ref}
+        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>
+                    <Typography.Link
+                      onClick={() => {
+                        if (typeof onTitleClick !== "undefined") {
+                          onTitleClick(row.id);
+                        }
+                      }}
+                    >
+                      {row.title}
+                    </Typography.Link>
+                  </div>
+                  <Text type="secondary">{row.subtitle}</Text>
+                </div>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.publicity.label",
+            }),
+            dataIndex: "publicity",
+            key: "publicity",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: PublicityValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "article.fields.article.count.label",
+            }),
+            dataIndex: "articles",
+            key: "articles",
+            width: 100,
+            search: false,
+            sorter: (a, b) => a.articles - b.articles,
+          },
+          {
+            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) => [
+              <Dropdown.Button
+                key={index}
+                type="link"
+                menu={{
+                  items: [
+                    {
+                      key: "open",
+                      label: intl.formatMessage({
+                        id: "buttons.open.in.library",
+                      }),
+                      icon: <TeamOutlined />,
+                      disabled: true,
+                    },
+                    {
+                      key: "share",
+                      label: intl.formatMessage({
+                        id: "buttons.share",
+                      }),
+                      icon: <TeamOutlined />,
+                      disabled: true,
+                    },
+                    {
+                      key: "remove",
+                      label: (
+                        <Text type="danger">
+                          {intl.formatMessage({
+                            id: "buttons.delete",
+                          })}
+                        </Text>
+                      ),
+                      icon: (
+                        <Text type="danger">
+                          <DeleteOutlined />
+                        </Text>
+                      ),
+                    },
+                  ],
+                  onClick: (e) => {
+                    switch (e.key) {
+                      case "open":
+                        window.open(`/anthology/${row.id}`, "_blank");
+                        break;
+                      case "share":
+                        break;
+                      case "remove":
+                        showDeleteConfirm(row.id, row.title);
+                        break;
+                      default:
+                        break;
+                    }
+                  },
+                }}
+              >
+                <Link to={`/anthology/${row.id}`} target="_blank">
+                  {intl.formatMessage({
+                    id: "buttons.view",
+                  })}
+                </Link>
+              </Dropdown.Button>,
+            ],
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          // TODO
+          console.log(params, sorter, filter);
+          let url = `/v2/anthology?view=studio&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 : "");
+          }
+
+          const res = await get<IAnthologyListResponse>(url);
+          const items: IItem[] = res.data.rows.map((item, id) => {
+            const date = new Date(item.created_at);
+            return {
+              sn: id + 1,
+              id: item.uid,
+              title: item.title,
+              subtitle: item.subtitle,
+              publicity: item.status,
+              articles: item.childrenNumber,
+              createdAt: date.getTime(),
+            };
+          });
+          console.log(items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          showCreate ? (
+            <Popover
+              content={
+                <AnthologyCreate
+                  studio={studioName}
+                  onSuccess={() => {
+                    setOpenCreate(false);
+                    ref.current?.reload();
+                  }}
+                />
+              }
+              placement="bottomRight"
+              trigger="click"
+              open={openCreate}
+              onOpenChange={(open: boolean) => {
+                setOpenCreate(open);
+              }}
+            >
+              <Button key="button" icon={<PlusOutlined />} type="primary">
+                {intl.formatMessage({ id: "buttons.create" })}
+              </Button>
+            </Popover>
+          ) : undefined,
+        ]}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 50 - 0
dashboard/src/components/anthology/AnthologyModal.tsx

@@ -0,0 +1,50 @@
+import { useState } from "react";
+import { Modal } from "antd";
+import AnthologyList from "./AnthologyList";
+
+interface IWidget {
+  studioName?: string;
+  trigger?: JSX.Element;
+  onSelect?: Function;
+  onCancel?: Function;
+}
+const Widget = ({ studioName, trigger, onSelect, onCancel }: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"80%"}
+        title="选择文集"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <AnthologyList
+          studioName={studioName}
+          showCreate={false}
+          onTitleClick={(id: string) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect(id);
+            }
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default Widget;

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

@@ -0,0 +1,52 @@
+import { Key } from "antd/lib/table/interface";
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { get } from "../../request";
+import { IArticleMapListResponse } from "../api/Article";
+import { ListNodeData } from "../article/EditableTree";
+import TocTree from "../article/TocTree";
+
+interface IWidget {
+  anthologyId?: string;
+  onSelect?: Function;
+  onArticleSelect?: Function;
+}
+const Widget = ({ anthologyId, onSelect, onArticleSelect }: IWidget) => {
+  const navigate = useNavigate();
+  const [tocData, setTocData] = useState<ListNodeData[]>([]);
+
+  useEffect(() => {
+    get<IArticleMapListResponse>(
+      `/v2/article-map?view=anthology&id=${anthologyId}`
+    ).then((json) => {
+      console.log("文集get", json);
+      if (json.ok) {
+        const toc: ListNodeData[] = json.data.rows.map((item) => {
+          return {
+            key: item.article_id ? item.article_id : item.title,
+            title: item.title,
+            level: item.level,
+          };
+        });
+        setTocData(toc);
+      }
+    });
+  }, [anthologyId]);
+  return (
+    <div>
+      <TocTree
+        treeData={tocData}
+        onSelect={(keys: string[]) => {
+          if (typeof onArticleSelect !== "undefined") {
+            onArticleSelect(keys);
+          } else {
+            navigate(`/article/article/${keys[0]}/read`);
+          }
+        }}
+      />
+    </div>
+  );
+};
+
+export default Widget;

+ 79 - 0
dashboard/src/components/anthology/EditableTocTree.tsx

@@ -0,0 +1,79 @@
+import { message } from "antd";
+import { Key } from "antd/lib/table/interface";
+import { useEffect, useState } from "react";
+
+import { get, post, put } from "../../request";
+import {
+  IArticleMapAddResponse,
+  IArticleMapListResponse,
+  IArticleMapUpdateRequest,
+} from "../api/Article";
+import EditableTree, { ListNodeData } from "../article/EditableTree";
+
+interface IWidget {
+  anthologyId?: string;
+  onSelect?: Function;
+}
+const Widget = ({ anthologyId, onSelect }: IWidget) => {
+  const [tocData, setTocData] = useState<ListNodeData[]>([]);
+  const [keys, setKeys] = useState<Key[]>();
+
+  useEffect(() => {
+    get<IArticleMapListResponse>(
+      `/v2/article-map?view=anthology&id=${anthologyId}`
+    ).then((json) => {
+      console.log("文集get", json);
+      if (json.ok) {
+        const toc: ListNodeData[] = json.data.rows.map((item) => {
+          return {
+            key: item.article_id ? item.article_id : item.title,
+            title: item.title,
+            level: item.level,
+          };
+        });
+        setTocData(toc);
+      }
+    });
+  }, [anthologyId]);
+  return (
+    <div>
+      <EditableTree
+        treeData={tocData}
+        onChange={(data: ListNodeData[]) => {
+          console.log("onChange", data);
+        }}
+        onSave={(data: ListNodeData[]) => {
+          console.log("onSave", data);
+          put<IArticleMapUpdateRequest, IArticleMapAddResponse>(
+            `/v2/article-map/${anthologyId}`,
+            {
+              data: data.map((item) => {
+                return {
+                  article_id: item.key,
+                  level: item.level,
+                  title: item.title,
+                  children: item.children,
+                };
+              }),
+              operation: "anthology",
+            }
+          )
+            .finally(() => {})
+            .then((json) => {
+              if (json.ok) {
+                message.success(json.data);
+              } else {
+                message.error(json.message);
+              }
+            })
+            .catch((e) => console.error(e));
+        }}
+        onSelect={(selectedKeys: Key[]) => {
+          setKeys(selectedKeys);
+        }}
+      />
+    </div>
+  );
+};
+
+export default Widget;

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

@@ -11,7 +11,7 @@ export interface IAnthologyDataRequest {
   title: string;
   subtitle: string;
   summary: string;
-  article_list: IArticleListApiResponse[];
+  article_list?: IArticleListApiResponse[];
   lang: string;
   status: number;
 }
@@ -104,3 +104,39 @@ export interface IAnthologyCreateRequest {
   lang: string;
   studio: string;
 }
+
+export interface IArticleMapRequest {
+  id?: string;
+  collect_id?: string;
+  article_id?: string;
+  level: number;
+  title: string;
+  children?: number;
+}
+export interface IArticleMapListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IArticleMapRequest[];
+    count: number;
+  };
+}
+export interface IArticleMapAddRequest {
+  anthology_id: string;
+  article_id: string[];
+  operation: string;
+}
+export interface IArticleMapUpdateRequest {
+  data: IArticleMapRequest[];
+  operation: string;
+}
+export interface IArticleMapAddResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+export interface IDeleteResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}

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

@@ -1,4 +1,11 @@
-export type Role = "owner" | "manager" | "editor" | "member" | "unknown";
+export type Role =
+  | "owner"
+  | "manager"
+  | "editor"
+  | "member"
+  | "student"
+  | "assistant"
+  | "unknown";
 
 export interface IUserRequest {
   id?: string;

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

@@ -1,3 +1,4 @@
+import { IStudio } from "../auth/StudioName";
 import { IStudioApiResponse, Role } from "./Auth";
 export type TChannelType =
   | "translation"
@@ -8,16 +9,13 @@ export type TChannelType =
 export interface IChannelApiData {
   id: string;
   name: string;
-  type: TChannelType;
+  type?: TChannelType;
 }
 
 export interface ChannelInfoProps {
-  channelName: string;
-  channelId: string;
-  channelType: string;
-  studioName: string;
-  studioId: string;
-  studioType: string;
+  channel: IChannelApiData;
+  studio: IStudio;
+  count?: number;
 }
 
 export type IFinal = [number, boolean];
@@ -25,7 +23,7 @@ export interface IApiResponseChannelData {
   uid: string;
   name: string;
   summary: string;
-  type: string;
+  type: TChannelType;
   studio: IStudioApiResponse;
   lang: string;
   status: number;

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

@@ -1,3 +1,4 @@
+import { TContentType } from "../comment/CommentCreate";
 import { IUserApiData } from "./Auth";
 
 export interface ICommentRequest {
@@ -6,6 +7,7 @@ export interface ICommentRequest {
   res_type?: string;
   title?: string;
   content?: string;
+  content_type?: TContentType;
   parent?: string;
   editor?: IUserApiData;
   created_at?: string;
@@ -18,6 +20,7 @@ export interface ICommentApiData {
   res_type: string;
   title?: string;
   content?: string;
+  content_type?: TContentType;
   parent?: string;
   children_count: number;
   editor: IUserApiData;

+ 44 - 4
dashboard/src/components/api/Corpus.ts

@@ -1,6 +1,8 @@
+import { IStudio } from "../auth/StudioName";
 import { IUser } from "../auth/User";
 import { IChannel } from "../channel/Channel";
-import { TagNode } from "../tag/TagArea";
+import { TChannelType } from "./Channel";
+import { TagNode } from "./Tag";
 
 export interface IApiPaliChapterList {
   id: string;
@@ -59,7 +61,7 @@ export interface IApiChapterChannels {
   views: number;
   likes: number[];
   channel: {
-    type: string;
+    type: TChannelType;
     owner_uid: string;
     editor_id: number;
     name: string;
@@ -70,6 +72,7 @@ export interface IApiChapterChannels {
     updated_at: string;
     uid: string;
   };
+  studio: IStudio;
 }
 
 export interface IApiResponseChapterChannelList {
@@ -94,7 +97,7 @@ export interface IApiResponseChannelListData {
   count: number;
   channel: {
     id: number;
-    type: string;
+    type: TChannelType;
     owner_uid: string;
     editor_id: number;
     name: string;
@@ -106,6 +109,7 @@ export interface IApiResponseChannelListData {
     updated_at: string;
     uid: string;
   };
+  studio: IStudio;
 }
 export interface IApiResponseChannelList {
   ok: boolean;
@@ -113,6 +117,23 @@ export interface IApiResponseChannelList {
   data: { rows: IApiResponseChannelListData[]; count: number };
 }
 
+export interface ISentenceDiffRequest {
+  sentences: string[];
+  channel: string;
+}
+export interface ISentenceDiffData {
+  book_id: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  content: string;
+}
+export interface ISentenceDiffResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentenceDiffData[]; count: number };
+}
+
 export interface ISentenceRequest {
   book: number;
   para: number;
@@ -139,7 +160,15 @@ export interface ISentenceResponse {
   message: string;
   data: ISentenceData;
 }
-
+export interface ISentenceNewRequest {
+  sentences: ISentenceDiffData[];
+  channel?: string;
+}
+export interface ISentenceNewMultiResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
 export interface IPaliToc {
   book: number;
   paragraph: number;
@@ -168,6 +197,7 @@ export interface IChapterData {
   path: string;
   tags: TagNode[];
   channel: { name: string; owner_uid: string };
+  studio: IStudio;
   channel_id: string;
   summary: string;
   view: number;
@@ -182,3 +212,13 @@ export interface IChapterListResponse {
   message: string;
   data: { rows: IChapterData[]; count: number };
 }
+
+export interface ILangList {
+  lang: string;
+  count: number;
+}
+export interface IChapterLangListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ILangList[]; count: number };
+}

+ 45 - 1
dashboard/src/components/api/Course.ts

@@ -13,6 +13,7 @@ export interface ICourseDataRequest {
   id?: string; //课程ID
   title: string; //标题
   subtitle?: string; //副标题
+  summary?: string; //副标题
   content?: string;
   cover?: string; //封面图片文件名
   teacher_id?: string; //UserID
@@ -21,11 +22,16 @@ export interface ICourseDataRequest {
   channel_id?: string; //标准答案channel
   start_at?: string; //课程开始时间
   end_at?: string; //课程结束时间
+  join: string;
+  request_exp: string;
 }
+export type TCourseJoinMode = "invite" | "manual" | "open";
+export type TCourseExpRequest = "none" | "begin-end" | "daily";
 export interface ICourseDataResponse {
   id: string; //课程ID
   title: string; //标题
   subtitle: string; //副标题
+  summary?: string; //副标题
   teacher?: IUser; //UserID
   course_count?: number; //课程数
   publicity: number; //类型-公开/内部
@@ -40,6 +46,10 @@ export interface ICourseDataResponse {
   content: string; //简介
   cover: string; //封面图片文件名
   member_count: number;
+  join: TCourseJoinMode;
+  request_exp: TCourseExpRequest;
+  my_status: TCourseMemberStatus;
+  count_progressing?: number;
   created_at: string; //创建时间
   updated_at: string; //修改时间
 }
@@ -78,12 +88,21 @@ export interface ICourseNumberResponse {
   };
 }
 
+export type TCourseMemberStatus =
+  | "normal"
+  | "progressing"
+  | "accepted"
+  | "rejected"
+  | "left"
+  | "blocked";
 export interface ICourseMemberData {
-  id?: number;
+  id?: string;
   user_id: string;
   course_id: string;
+  channel_id?: string;
   role?: string;
   user?: IUserRequest;
+  status?: TCourseMemberStatus;
   created_at?: string;
   updated_at?: string;
 }
@@ -107,3 +126,28 @@ export interface ICourseMemberDeleteResponse {
   message: string;
   data: boolean;
 }
+
+export interface ICourseCurrUserResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    role: string;
+    channel_id: string;
+  };
+}
+
+export interface IExerciseListData {
+  user: IUser;
+  wbw: number;
+  translation: number;
+  question: number;
+  html: string;
+}
+export interface ICourseExerciseResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IExerciseListData[];
+    count: number;
+  };
+}

+ 40 - 1
dashboard/src/components/api/Dict.ts

@@ -1,3 +1,5 @@
+import { ICaseListData } from "../dict/CaseList";
+
 export interface IDictDataRequest {
   id: number;
   word: string;
@@ -14,7 +16,7 @@ export interface IDictDataRequest {
   confidence: number;
 }
 export interface IApiResponseDictData {
-  id: number;
+  id: string;
   word: string;
   type: string;
   grammar: string;
@@ -44,3 +46,40 @@ export interface IApiResponseDictList {
     count: number;
   };
 }
+
+export interface IVocabularyData {
+  word: string;
+  count: number;
+  meaning?: string;
+}
+export interface IVocabularyListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IVocabularyData[];
+    count: number;
+  };
+}
+
+export interface IUserDictDeleteRequest {
+  id: string;
+}
+
+export interface ICaseListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ICaseListData[];
+    count: number;
+  };
+}
+
+export interface IFirstMeaning {
+  word?: string;
+  meaning?: string;
+}
+export interface IDictFirstMeaningResponse {
+  ok: boolean;
+  message: string;
+  data: IFirstMeaning[];
+}

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

@@ -1,5 +1,11 @@
+import { IStudio } from "../auth/StudioName";
 import { IStudioApiResponse, IUserRequest, Role } from "./Auth";
 
+export interface IGroupRequest {
+  name: string;
+  studio_name: string;
+}
+
 export interface IGroupDataRequest {
   uid: string;
   name: string;

+ 5 - 0
dashboard/src/components/api/Guide.ts

@@ -0,0 +1,5 @@
+export interface IGuideResponse {
+  ok: boolean;
+  message: string;
+  data: string;
+}

+ 5 - 0
dashboard/src/components/api/Tag.ts

@@ -0,0 +1,5 @@
+export interface TagNode {
+  id: string;
+  name: string;
+  description?: string;
+}

+ 5 - 0
dashboard/src/components/api/Term.ts

@@ -54,3 +54,8 @@ export interface ITermCreateResponse {
   message: string;
   data: ITermCreate;
 }
+
+export interface ITermDeleteRequest {
+  uuid: boolean;
+  id: string[];
+}

+ 23 - 24
dashboard/src/components/article/AnthologyDetail.tsx

@@ -1,6 +1,6 @@
+import { useNavigate } from "react-router-dom";
 import { useState, useEffect } from "react";
-import { Typography } from "antd";
-import MDEditor from "@uiw/react-md-editor";
+import { Space, Typography } from "antd";
 
 import { get } from "../../request";
 import type {
@@ -8,32 +8,21 @@ import type {
   IAnthologyResponse,
 } from "../api/Article";
 import type { IAnthologyData } from "./AnthologyCard";
-import TocTree from "./TocTree";
+import StudioName from "../auth/StudioName";
+import TimeShow from "../general/TimeShow";
+import Marked from "../general/Marked";
+import AnthologyTocTree from "../anthology/AnthologyTocTree";
 
 const { Title, Text } = Typography;
 
-const defaultData: IAnthologyData = {
-  id: "",
-  title: "",
-  subTitle: "",
-  summary: "",
-  articles: [],
-  studio: {
-    id: "",
-    studioName: "",
-    nickName: "",
-    avatar: "",
-  },
-  created_at: "",
-  updated_at: "",
-};
 interface IWidgetAnthologyDetail {
   aid?: string;
   channels?: string[];
   onArticleSelect?: Function;
 }
 const Widget = ({ aid, channels, onArticleSelect }: IWidgetAnthologyDetail) => {
-  const [tableData, setTableData] = useState(defaultData);
+  const [tableData, setTableData] = useState<IAnthologyData>();
+  const navigate = useNavigate();
 
   useEffect(() => {
     console.log("useEffect");
@@ -69,20 +58,30 @@ const Widget = ({ aid, channels, onArticleSelect }: IWidgetAnthologyDetail) => {
   }
   return (
     <>
-      <Title level={4}>{tableData.title}</Title>
+      <Title level={4}>{tableData?.title}</Title>
       <div>
-        <Text type="secondary">{tableData.subTitle}</Text>
+        <Text type="secondary">{tableData?.subTitle}</Text>
       </div>
+      <Space>
+        <StudioName data={tableData?.studio} />
+        <TimeShow
+          time={tableData?.updated_at}
+          title="updated"
+          showTitle={true}
+        />
+      </Space>
       <div>
-        <MDEditor.Markdown source={tableData.summary} />
+        <Marked text={tableData?.summary} />
       </div>
       <Title level={5}>目录</Title>
 
-      <TocTree
-        treeData={tableData.articles}
+      <AnthologyTocTree
+        anthologyId={aid}
         onSelect={(keys: string[]) => {
           if (typeof onArticleSelect !== "undefined") {
             onArticleSelect(keys);
+          } else {
+            navigate(`/article/article/${keys[0]}/read`);
           }
         }}
       />

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

@@ -0,0 +1,129 @@
+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";
+
+import { get, put } from "../../request";
+import { IAnthologyDataRequest, IAnthologyResponse } from "../api/Article";
+import LangSelect from "../general/LangSelect";
+import PublicitySelect from "../studio/PublicitySelect";
+
+interface IFormData {
+  title: string;
+  subtitle: string;
+  summary: string;
+  lang: string;
+  status: number;
+}
+
+interface IWidget {
+  anthologyId?: string;
+  onTitleChange?: Function;
+}
+const Widget = ({ anthologyId, onTitleChange }: IWidget) => {
+  const intl = useIntl();
+
+  return anthologyId ? (
+    <ProForm<IFormData>
+      onFinish={async (values: IFormData) => {
+        // TODO
+        console.log(values);
+
+        const res = await put<IAnthologyDataRequest, IAnthologyResponse>(
+          `/v2/anthology/${anthologyId}`,
+          {
+            title: values.title,
+            subtitle: values.subtitle,
+            summary: values.summary,
+            status: values.status,
+            lang: values.lang,
+          }
+        );
+        console.log(res);
+        if (res.ok) {
+          if (typeof onTitleChange !== "undefined") {
+            onTitleChange(res.data.title);
+          }
+          message.success(
+            intl.formatMessage({
+              id: "flashes.success",
+            })
+          );
+        } else {
+          message.error(res.message);
+        }
+      }}
+      request={async () => {
+        const res = await get<IAnthologyResponse>(
+          `/v2/anthology/${anthologyId}`
+        );
+        console.log("文集get", res);
+        if (res.ok) {
+          if (typeof onTitleChange !== "undefined") {
+            onTitleChange(res.data.title);
+          }
+
+          return {
+            title: res.data.title,
+            subtitle: res.data.subtitle,
+            summary: res.data.summary,
+            lang: res.data.lang,
+            status: res.data.status,
+          };
+        } else {
+          return {
+            title: "",
+            subtitle: "",
+            summary: "",
+            lang: "",
+            status: 0,
+          };
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          required
+          label={intl.formatMessage({
+            id: "forms.fields.title.label",
+          })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "forms.message.title.required",
+              }),
+            },
+          ]}
+        />
+        <ProFormText
+          width="md"
+          name="subtitle"
+          label={intl.formatMessage({
+            id: "forms.fields.subtitle.label",
+          })}
+        />
+      </ProForm.Group>
+
+      <ProForm.Group>
+        <LangSelect width="md" />
+        <PublicitySelect width="md" />
+      </ProForm.Group>
+      <ProForm.Group>
+        <Form.Item
+          name="summary"
+          label={intl.formatMessage({ id: "forms.fields.summary.label" })}
+        >
+          <MDEditor />
+        </Form.Item>
+      </ProForm.Group>
+    </ProForm>
+  ) : (
+    <></>
+  );
+};
+
+export default Widget;

+ 1 - 2
dashboard/src/components/article/AnthologyList.tsx

@@ -6,13 +6,12 @@ import type { IAnthologyListResponse } from "../api/Article";
 import AnthologyCard from "./AnthologyCard";
 import type { IAnthologyData } from "./AnthologyCard";
 
-const defaultData: IAnthologyData[] = [];
 interface IWidgetAnthologyList {
   view: string;
   id?: string;
 }
 const Widget = (prop: IWidgetAnthologyList) => {
-  const [tableData, setTableData] = useState(defaultData);
+  const [tableData, setTableData] = useState<IAnthologyData[]>([]);
 
   useEffect(() => {
     console.log("useEffect", prop);

+ 138 - 24
dashboard/src/components/article/Article.tsx

@@ -1,23 +1,37 @@
-import { message } from "antd";
 import { useEffect, useState } from "react";
-import { modeChange } from "../../reducers/article-mode";
+import { message } from "antd";
 
+import { modeChange } from "../../reducers/article-mode";
 import { get } from "../../request";
 import store from "../../store";
 import { IArticleDataResponse, IArticleResponse } from "../api/Article";
 import ArticleView from "./ArticleView";
+import { ICourseCurrUserResponse } from "../api/Course";
+import { ICourseUser, signIn } from "../../reducers/course-user";
+import { ITextbook, refresh } from "../../reducers/current-course";
+import ExerciseList from "./ExerciseList";
+import ExerciseAnswer from "../course/ExerciseAnswer";
+import "./article.css";
+import CommentListCard from "../comment/CommentListCard";
 
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleType =
   | "article"
   | "chapter"
   | "paragraph"
-  | "cs-paragraph"
-  | "sentence"
+  | "cs-para"
+  | "sent"
   | "sim"
-  | "page";
+  | "page"
+  | "textbook"
+  | "exercise"
+  | "exercise-list"
+  | "corpus_sent/original"
+  | "corpus_sent/commentary"
+  | "corpus_sent/nissaya"
+  | "corpus_sent/translation";
 interface IWidgetArticle {
-  type?: string;
+  type?: ArticleType;
   articleId?: string;
   mode?: ArticleMode;
   active?: boolean;
@@ -30,6 +44,7 @@ const Widget = ({
 }: IWidgetArticle) => {
   const [articleData, setArticleData] = useState<IArticleDataResponse>();
   const [articleMode, setArticleMode] = useState<ArticleMode>(mode);
+  const [extra, setExtra] = useState(<></>);
 
   let channels: string[] = [];
   if (typeof articleId !== "undefined") {
@@ -38,7 +53,40 @@ const Widget = ({
       channels = aId.slice(1);
     }
   }
-
+  useEffect(() => {
+    /**
+     * 由课本进入插叙当前用户的权限和channel
+     */
+    if (
+      type === "textbook" ||
+      type === "exercise" ||
+      type === "exercise-list"
+    ) {
+      if (typeof articleId !== "undefined") {
+        const id = articleId.split("_");
+        get<ICourseCurrUserResponse>(`/v2/course-curr?course_id=${id[0]}`).then(
+          (response) => {
+            console.log("course user", response);
+            if (response.ok) {
+              const it: ICourseUser = {
+                channelId: response.data.channel_id,
+                role: response.data.role,
+              };
+              store.dispatch(signIn(it));
+              /**
+               * redux发布课程信息
+               */
+              const ic: ITextbook = {
+                courseId: id[0],
+                articleId: id[1],
+              };
+              store.dispatch(refresh(ic));
+            }
+          }
+        );
+      }
+    }
+  }, [articleId, type]);
   useEffect(() => {
     console.log("mode", mode, articleMode);
     if (!active) {
@@ -56,16 +104,76 @@ const Widget = ({
     if (typeof type !== "undefined" && typeof articleId !== "undefined") {
       let url = "";
       switch (type) {
-        case "corpus/article":
-          url = `/v2/article/${articleId}?mode=${mode}`;
+        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();
+          }
+          break;
+        case "textbook":
+          /**
+           * 从课本进入
+           * id两部分组成
+           * 课程id_文章id
+           */
+          const id = articleId.split("_");
+          if (id.length < 2) {
+            message.error("文章id期待2个,实际只给了一个");
+            return;
+          }
+          url = `/v2/article/${id[1]}?mode=${mode}&view=textbook&course=${id[0]}`;
           break;
-        case "corpus/textbook":
-          url = `/v2/article/${mode}?mode=read`;
+        case "exercise":
+          /**
+           * 从练习进入
+           * id 由4部分组成
+           * 课程id_文章id_练习id_username
+           */
+          const exerciseId = articleId.split("_");
+          if (exerciseId.length < 3) {
+            message.error("练习id期待3个");
+            return;
+          }
+          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]}`;
+
+          //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]}
+            />
+          );
           break;
         default:
-          url = `/v2/${type}/${articleId}/${mode}`;
+          url = `/v2/corpus/${type}/${articleId}/${mode}`;
           break;
       }
+      console.log("url", url);
       get<IArticleResponse>(url).then((json) => {
         console.log("article", json);
         if (json.ok) {
@@ -78,18 +186,24 @@ const Widget = ({
   }, [active, type, articleId, mode, articleMode]);
 
   return (
-    <ArticleView
-      id={articleData?.uid}
-      title={articleData?.title}
-      subTitle={articleData?.subtitle}
-      summary={articleData?.summary}
-      content={articleData ? articleData.content : ""}
-      html={articleData?.html}
-      path={articleData?.path}
-      created_at={articleData?.created_at}
-      updated_at={articleData?.updated_at}
-      channels={channels}
-    />
+    <div>
+      <ArticleView
+        id={articleData?.uid}
+        title={articleData?.title}
+        subTitle={articleData?.subtitle}
+        summary={articleData?.summary}
+        content={articleData ? articleData.content : ""}
+        html={articleData?.html}
+        path={articleData?.path}
+        created_at={articleData?.created_at}
+        updated_at={articleData?.updated_at}
+        channels={channels}
+        type={type}
+        articleId={articleId}
+      />
+      {extra}
+      <CommentListCard resId={articleData?.uid} resType="article" />
+    </div>
   );
 };
 

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

@@ -1,15 +1,11 @@
 import { useNavigate } from "react-router-dom";
-import { useIntl } from "react-intl";
-import { useState } from "react";
-import { Button, Card, Dropdown, Space, Segmented } from "antd";
+import { Button, Card, Dropdown, Space } from "antd";
 import { MoreOutlined, ReloadOutlined } from "@ant-design/icons";
 import type { MenuProps } from "antd";
 
-import store from "../../store";
-import { modeChange } from "../../reducers/article-mode";
 import { IWidgetArticleData } from "./ArticleView";
 import ArticleCardMainMenu from "./ArticleCardMainMenu";
-import { ArticleMode } from "./Article";
+import ModeSwitch from "./ModeSwitch";
 
 interface IWidgetArticleCard {
   type?: string;
@@ -28,8 +24,6 @@ const Widget = ({
   onModeChange,
   showCol,
 }: IWidgetArticleCard) => {
-  const intl = useIntl();
-  const [mode, setMode] = useState<string>("read");
   const navigate = useNavigate();
 
   const onClick: MenuProps["onClick"] = (e) => {
@@ -52,39 +46,6 @@ const Widget = ({
       label: "显示分栏",
     },
   ];
-  const modeSwitch = (
-    <Segmented
-      size="middle"
-      options={[
-        {
-          label: intl.formatMessage({ id: "buttons.read" }),
-          value: "read",
-        },
-        {
-          label: intl.formatMessage({ id: "buttons.translate" }),
-          value: "edit",
-        },
-        {
-          label: intl.formatMessage({ id: "buttons.wbw" }),
-          value: "wbw",
-        },
-      ]}
-      value={mode}
-      onChange={(value) => {
-        const newMode = value.toString();
-        if (typeof onModeChange !== "undefined") {
-          if (mode === "read" || newMode === "read") {
-            onModeChange(newMode);
-          }
-        }
-        setMode(newMode);
-        //发布mode变更
-        store.dispatch(modeChange(newMode as ArticleMode));
-        //修改url
-        navigate(`/article/${type}/${articleId}/${newMode}`);
-      }}
-    />
-  );
 
   const contextMenu = (
     <Dropdown menu={{ items, onClick }} placement="bottomRight">
@@ -102,7 +63,14 @@ const Widget = ({
       }
       extra={
         <Space>
-          {modeSwitch}
+          <ModeSwitch
+            onModeChange={(mode: string) => {
+              if (typeof onModeChange !== "undefined") {
+                onModeChange(mode);
+              }
+              navigate(`/article/${type}/${articleId}/${mode}`);
+            }}
+          />
           <Button
             shape="circle"
             size="small"

+ 20 - 5
dashboard/src/components/article/ArticleCreate.tsx

@@ -1,10 +1,15 @@
 import { useIntl } from "react-intl";
-import { ProForm, ProFormText } from "@ant-design/pro-components";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
 import { message } from "antd";
 
 import { post } from "../../request";
 import { IArticleCreateRequest, IArticleResponse } from "../api/Article";
 import LangSelect from "../general/LangSelect";
+import { useRef } from "react";
 
 interface IFormData {
   title: string;
@@ -12,17 +17,23 @@ interface IFormData {
   studio: string;
 }
 
-type IWidgetArticleCreate = {
+interface IWidget {
   studio?: string;
-};
-const Widget = (prop: IWidgetArticleCreate) => {
+  onSuccess?: Function;
+}
+const Widget = ({ studio, onSuccess }: IWidget) => {
   const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
 
   return (
     <ProForm<IFormData>
+      formRef={formRef}
       onFinish={async (values: IFormData) => {
         console.log(values);
-        values.studio = prop.studio ? prop.studio : "";
+        if (typeof studio === "undefined") {
+          return;
+        }
+        values.studio = studio;
         const res = await post<IArticleCreateRequest, IArticleResponse>(
           `/v2/article`,
           values
@@ -30,6 +41,10 @@ const Widget = (prop: IWidgetArticleCreate) => {
         console.log(res);
         if (res.ok) {
           message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+            formRef.current?.resetFields(["title"]);
+          }
         } else {
           message.error(res.message);
         }

+ 0 - 102
dashboard/src/components/article/ArticleTabs.tsx

@@ -1,102 +0,0 @@
-import { useState, useRef, useEffect } from "react";
-import { Tabs, Button } from "antd";
-import { CloseOutlined } from "@ant-design/icons";
-
-import { useAppSelector } from "../../hooks";
-import { siteInfo as _siteInfo } from "../../reducers/open-article";
-import Article from "./Article";
-
-const defaultPanes = [{ label: `Tab`, children: <></>, key: "1" }];
-
-interface IWidget {
-  onClose?: Function;
-}
-const Widget = ({ onClose }: IWidget) => {
-  const [activeKey, setActiveKey] = useState("1");
-  const [items, setItems] = useState(defaultPanes);
-  const newTabIndex = useRef(0);
-
-  const newArticle = useAppSelector(_siteInfo);
-
-  useEffect(() => {
-    console.log("open", newArticle);
-    if (typeof newArticle !== "undefined") {
-      add(newArticle?.title, newArticle?.url, newArticle?.id);
-    }
-  }, [newArticle]);
-
-  const onChange = (key: string) => {
-    setActiveKey(key);
-  };
-
-  const add = (title: string, url: string, id: string) => {
-    const newActiveKey = `newTab${newTabIndex.current++}`;
-    setItems([
-      ...items,
-      {
-        label: title,
-        children: (
-          <Article active={true} type={url} articleId={id} mode="edit" />
-        ),
-        key: newActiveKey,
-      },
-    ]);
-    setActiveKey(newActiveKey);
-  };
-
-  const remove = (
-    targetKey:
-      | React.MouseEvent<Element, MouseEvent>
-      | React.KeyboardEvent<Element>
-      | string
-  ) => {
-    const targetIndex = items.findIndex((pane) => pane.key === targetKey);
-    const newPanes = items.filter((pane) => pane.key !== targetKey);
-    if (newPanes.length && targetKey === activeKey) {
-      const { key } =
-        newPanes[
-          targetIndex === newPanes.length ? targetIndex - 1 : targetIndex
-        ];
-      setActiveKey(key);
-    }
-    setItems(newPanes);
-  };
-
-  const onEdit = (
-    targetKey:
-      | React.MouseEvent<Element, MouseEvent>
-      | React.KeyboardEvent<Element>
-      | string,
-    action: "add" | "remove"
-  ) => {
-    if (action === "add") {
-      add("new", "url", "id");
-    } else {
-      remove(targetKey);
-    }
-  };
-  const operations = (
-    <Button
-      icon={<CloseOutlined />}
-      shape="circle"
-      onClick={() => {
-        if (onClose) {
-          onClose(true);
-        }
-      }}
-    />
-  );
-  return (
-    <Tabs
-      hideAdd
-      onChange={onChange}
-      activeKey={activeKey}
-      type="editable-card"
-      onEdit={onEdit}
-      items={items}
-      tabBarExtraContent={operations}
-    />
-  );
-};
-
-export default Widget;

+ 115 - 0
dashboard/src/components/article/ArticleTplMaker.tsx

@@ -0,0 +1,115 @@
+import { useEffect, useState } from "react";
+import { Input, Modal, Select, Space, Typography } from "antd";
+
+import { TDisplayStyle } from "../template/Article";
+const { TextArea } = Input;
+const { Paragraph } = Typography;
+interface IWidget {
+  type?: string;
+  id?: string;
+  title?: string;
+  style?: TDisplayStyle;
+  trigger?: JSX.Element;
+  onSelect?: Function;
+  onCancel?: Function;
+}
+const Widget = ({
+  type,
+  id,
+  title,
+  style = "modal",
+  trigger,
+  onSelect,
+  onCancel,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [titleText, setTitleText] = useState(title);
+  const [styleText, setStyleText] = useState(style);
+  const [tplText, setTplText] = useState("");
+
+  const ids = id?.split("_");
+  const id1 = ids ? ids[0] : undefined;
+  const channels = ids
+    ? ids.length > 1
+      ? ids?.slice(1)
+      : undefined
+    : undefined;
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+  useEffect(() => {
+    setTitleText(title);
+  }, [title]);
+  useEffect(() => {
+    let tplText = `{{article|
+type=${type}|
+id=${id1}|
+title=${titleText}|
+style=${styleText}`;
+    tplText += channels ? `channel=${channels}` : undefined;
+    tplText += "}}";
+
+    setTplText(tplText);
+  }, [titleText, styleText, type, id1, channels]);
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"80%"}
+        title="生成模版"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <Space direction="vertical" style={{ width: 500 }}>
+          <Space style={{ width: 500 }}>
+            {"标题:"}
+            <Input
+              width={400}
+              value={titleText}
+              placeholder="Basic usage"
+              onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                setTitleText(event.target.value);
+              }}
+            />
+          </Space>
+          <Space>
+            {"显示为:"}
+            <Select
+              defaultValue={style}
+              style={{ width: 120 }}
+              onChange={(value: string) => {
+                console.log(`selected ${value}`);
+                setStyleText(value as TDisplayStyle);
+              }}
+              options={[
+                { value: "modal", label: "对话框" },
+                { value: "card", label: "卡片" },
+              ]}
+            />
+          </Space>
+          <div>
+            <TextArea
+              value={tplText}
+              rows={4}
+              placeholder="maxLength is 6"
+              maxLength={6}
+            />
+            <Paragraph copyable={{ text: tplText }}>复制</Paragraph>
+          </div>
+        </Space>
+      </Modal>
+    </>
+  );
+};
+
+export default Widget;

+ 36 - 8
dashboard/src/components/article/ArticleView.tsx

@@ -3,8 +3,10 @@ import { ReloadOutlined } from "@ant-design/icons";
 
 import MdView from "../template/MdView";
 import TocPath, { ITocPathNode } from "../corpus/TocPath";
+import PaliChapterChannelList from "../corpus/PaliChapterChannelList";
+import { ArticleType } from "./Article";
 
-const { Paragraph, Title } = Typography;
+const { Paragraph, Title, Text } = Typography;
 
 export interface IWidgetArticleData {
   id?: string;
@@ -17,6 +19,8 @@ export interface IWidgetArticleData {
   created_at?: string;
   updated_at?: string;
   channels?: string[];
+  type?: ArticleType;
+  articleId?: string;
 }
 
 const Widget = ({
@@ -30,8 +34,33 @@ const Widget = ({
   created_at,
   updated_at,
   channels,
+  type,
+  articleId,
 }: IWidgetArticleData) => {
-  console.log("path", path);
+  let currChannelList = <></>;
+  switch (type) {
+    case "chapter":
+      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"
+            />
+          );
+        }
+      }
+
+      break;
+
+    default:
+      break;
+  }
   return (
     <>
       <Button shape="round" size="small" icon={<ReloadOutlined />}>
@@ -39,17 +68,16 @@ const Widget = ({
       </Button>
       <div>
         <TocPath data={path} channel={channels} />
-        <Title type="secondary" level={5}>
-          {subTitle}
-        </Title>
-        <Title level={3}>
+
+        <Title level={4}>
           <div
             dangerouslySetInnerHTML={{
               __html: title ? title : "",
             }}
-          ></div>
+          />
         </Title>
-
+        <Text type="secondary">{subTitle}</Text>
+        {currChannelList}
         <Paragraph ellipsis={{ rows: 2, expandable: true, symbol: "more" }}>
           {summary}
         </Paragraph>

+ 84 - 10
dashboard/src/components/article/EditableTree.tsx

@@ -2,17 +2,25 @@ import React, { useState } from "react";
 import { useEffect } from "react";
 import { Tree } from "antd";
 import type { DataNode, TreeProps } from "antd/es/tree";
+import { Key } from "antd/lib/table/interface";
+import {
+  FileAddOutlined,
+  DeleteOutlined,
+  SaveOutlined,
+} from "@ant-design/icons";
+import { Button, Divider, Space } from "antd";
 
-type TreeNodeData = {
+interface TreeNodeData {
   key: string;
   title: string;
   children: TreeNodeData[];
   level: number;
-};
+}
 export type ListNodeData = {
   key: string;
   title: string;
   level: number;
+  children?: number;
 };
 
 var tocActivePath: TreeNodeData[] = [];
@@ -92,6 +100,7 @@ function treeToList(treeNode: TreeNodeData[]): ListNodeData[] {
       key: node.key,
       title: node.title,
       level: iTocTreeCurrLevel,
+      children: children,
     });
     if (children > 0) {
       iTocTreeCurrLevel++;
@@ -107,15 +116,24 @@ function treeToList(treeNode: TreeNodeData[]): ListNodeData[] {
 interface IWidgetEditableTree {
   treeData: ListNodeData[];
   onChange?: Function;
+  onSelect?: Function;
+  onSave?: Function;
 }
-const Widget = (prop: IWidgetEditableTree) => {
-  const data = tocGetTreeData(prop.treeData);
-  console.log("treedata", data);
-  const [gData, setGData] = useState(data);
+const Widget = ({
+  treeData,
+  onChange,
+  onSelect,
+  onSave,
+}: IWidgetEditableTree) => {
+  const [gData, setGData] = useState<TreeNodeData[]>([]);
+  const [listTreeData, setListTreeData] = useState<ListNodeData[]>();
+
+  const [keys, setKeys] = useState<Key>("");
   useEffect(() => {
-    const data = tocGetTreeData(prop.treeData);
+    const data = tocGetTreeData(treeData);
+    console.log("tree data", data);
     setGData(data);
-  }, [prop]);
+  }, [treeData]);
 
   const onDragEnter: TreeProps["onDragEnter"] = (info) => {
     console.log(info);
@@ -187,19 +205,75 @@ const Widget = (prop: IWidgetEditableTree) => {
       }
     }
     setGData(data);
-    if (typeof prop.onChange !== "undefined") {
-      prop.onChange(treeToList(data));
+    if (typeof onChange !== "undefined") {
+      const list = treeToList(data);
+      onChange(list);
+      setListTreeData(list);
     }
   };
 
   return (
     <>
+      <Space>
+        <Button icon={<FileAddOutlined />}>添加</Button>
+        <Button
+          icon={<DeleteOutlined />}
+          danger
+          onClick={() => {
+            const delTree = (node: TreeNodeData[]): boolean => {
+              for (let index = 0; index < node.length; index++) {
+                if (node[index].key === keys) {
+                  node.splice(index, 1);
+                  return true;
+                } else {
+                  const cf = delTree(node[index].children);
+                  if (cf) {
+                    return cf;
+                  }
+                }
+              }
+              return false;
+            };
+            const tmp = [...gData];
+            const find = delTree(tmp);
+
+            console.log("delete", keys, find, tmp);
+            setGData(tmp);
+          }}
+        >
+          删除
+        </Button>
+        <Button
+          icon={<SaveOutlined />}
+          onClick={() => {
+            if (typeof onSave !== "undefined") {
+              onSave(listTreeData);
+            }
+          }}
+          type="primary"
+        >
+          保存
+        </Button>
+      </Space>
+      <Divider></Divider>
       <Tree
         rootClassName="draggable-tree"
         draggable
         blockNode
         onDragEnter={onDragEnter}
         onDrop={onDrop}
+        onSelect={(selectedKeys: Key[]) => {
+          if (selectedKeys.length > 0) {
+            setKeys(selectedKeys[0]);
+          } else {
+            setKeys("");
+          }
+
+          console.log(selectedKeys);
+          if (typeof onSelect !== "undefined") {
+            onSelect(selectedKeys);
+          }
+        }}
         treeData={gData}
       />
     </>

+ 83 - 0
dashboard/src/components/article/ExerciseList.tsx

@@ -0,0 +1,83 @@
+import { useEffect, useState } from "react";
+import { Collapse, Space, Tag } from "antd";
+
+import { ICourseExerciseResponse } from "../api/Course";
+import { get } from "../../request";
+import { IUser } from "../auth/User";
+import MdView from "../template/MdView";
+
+const { Panel } = Collapse;
+
+interface DataItem {
+  sn: number;
+  name: string;
+  user: IUser;
+  wbw: number;
+  translation: number;
+  question: number;
+  html: string;
+}
+interface IWidget {
+  courseId?: string;
+  articleId?: string;
+  exerciseId?: string;
+}
+const Widget = ({ courseId, articleId, exerciseId }: IWidget) => {
+  const [data, setData] = useState<DataItem[]>();
+
+  useEffect(() => {
+    const url = `/v2/exercise?course_id=${courseId}&article_id=${articleId}&exercise_id=${exerciseId}`;
+    console.log(url);
+    get<ICourseExerciseResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          console.log(json.data);
+          const items: DataItem[] = json.data.rows.map((item, id) => {
+            let member: DataItem = {
+              sn: id,
+              name: item.user.nickName,
+              user: item.user,
+              wbw: item.wbw,
+              translation: item.translation,
+              question: item.question,
+              html: item.html,
+            };
+            return member;
+          });
+          setData(items);
+        } else {
+          console.error(json.message);
+        }
+      })
+      .catch((error) => {
+        console.error(error);
+      });
+  }, [courseId, articleId, exerciseId]);
+  return (
+    <>
+      <Collapse>
+        {data?.map((item, id) => {
+          const header = (
+            <Space>
+              <span>{item.name}</span>
+              {item.wbw === 0 ? <></> : <Tag color="blue">wbw-{item.wbw}</Tag>}
+              {item.question === 0 ? (
+                <></>
+              ) : (
+                <Tag color="#5BD8A6">Q-{item.question}</Tag>
+              )}
+            </Space>
+          );
+
+          return (
+            <Panel header={header} key={id}>
+              <MdView html={item.html} />
+            </Panel>
+          );
+        })}
+      </Collapse>
+    </>
+  );
+};
+
+export default Widget;

+ 21 - 0
dashboard/src/components/article/MainMenu.tsx

@@ -0,0 +1,21 @@
+import { Button, Dropdown } from "antd";
+import { MenuOutlined } from "@ant-design/icons";
+import { mainMenuItems } from "../library/HeadBar";
+
+const Widget = () => {
+  return (
+    <Dropdown
+      menu={{ items: mainMenuItems }}
+      placement="bottomLeft"
+      trigger={["click"]}
+    >
+      <Button
+        style={{ display: "block" }}
+        size="small"
+        icon={<MenuOutlined />}
+      ></Button>
+    </Dropdown>
+  );
+};
+
+export default Widget;

+ 53 - 0
dashboard/src/components/article/ModeSwitch.tsx

@@ -0,0 +1,53 @@
+import { Segmented } from "antd";
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import { modeChange } from "../../reducers/article-mode";
+import store from "../../store";
+import { ArticleMode } from "./Article";
+
+interface IWidget {
+  initMode?: string;
+  onModeChange?: Function;
+}
+const Widget = ({ initMode = "read", onModeChange }: IWidget) => {
+  const intl = useIntl();
+  const [mode, setMode] = useState<string>(initMode);
+  return (
+    <Segmented
+      size="middle"
+      style={{
+        color: "rgb(134 134 134 / 90%)",
+        backgroundColor: "rgb(129 129 129 / 17%)",
+        display: "block",
+      }}
+      options={[
+        {
+          label: intl.formatMessage({ id: "buttons.read" }),
+          value: "read",
+        },
+        {
+          label: intl.formatMessage({ id: "buttons.translate" }),
+          value: "edit",
+        },
+        {
+          label: intl.formatMessage({ id: "buttons.wbw" }),
+          value: "wbw",
+        },
+      ]}
+      value={mode}
+      onChange={(value) => {
+        const newMode = value.toString();
+        if (typeof onModeChange !== "undefined") {
+          if (mode === "read" || newMode === "read") {
+            onModeChange(newMode);
+          }
+        }
+        setMode(newMode);
+        //发布mode变更
+        store.dispatch(modeChange(newMode as ArticleMode));
+      }}
+    />
+  );
+};
+
+export default Widget;

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

@@ -0,0 +1,81 @@
+import { Affix } from "antd";
+import { useEffect, useState } from "react";
+import { IChannel } from "../channel/Channel";
+import ChannelPickerTable from "../channel/ChannelPickerTable";
+
+import DictComponent from "../dict/DictComponent";
+import { ArticleType } from "./Article";
+
+export type TPanelName = "dict" | "channel" | "close";
+interface IWidget {
+  curr?: TPanelName;
+  type: ArticleType;
+  articleId: string;
+  selectedChannelKeys?: string[];
+  onChannelSelect?: Function;
+}
+const Widget = ({
+  curr = "close",
+  type,
+  articleId,
+  onChannelSelect,
+  selectedChannelKeys,
+}: IWidget) => {
+  const [dict, setDict] = useState("none");
+  const [channel, setChannel] = useState("none");
+
+  useEffect(() => {
+    switch (curr) {
+      case "dict":
+        setDict("block");
+        setChannel("none");
+        break;
+      case "channel":
+        setDict("none");
+        setChannel("block");
+        break;
+      default:
+        setDict("none");
+        setChannel("none");
+        break;
+    }
+  }, [curr]);
+  return (
+    <Affix offsetTop={44}>
+      <div>
+        <div
+          style={{
+            width: 350,
+            height: `calc(100vh - 44px)`,
+            overflowY: "scroll",
+            display: dict,
+          }}
+        >
+          <DictComponent />
+        </div>
+        <div
+          style={{
+            width: 350,
+            height: `calc(100vh - 44px)`,
+            overflowY: "scroll",
+            display: channel,
+          }}
+        >
+          <ChannelPickerTable
+            type={type}
+            articleId={articleId}
+            selectedKeys={selectedChannelKeys}
+            onSelect={(e: IChannel[]) => {
+              console.log(e);
+              if (typeof onChannelSelect !== "undefined") {
+                onChannelSelect(e);
+              }
+            }}
+          />
+        </div>
+      </div>
+    </Affix>
+  );
+};
+
+export default Widget;

+ 47 - 0
dashboard/src/components/article/RightToolsSwitch.tsx

@@ -0,0 +1,47 @@
+import { Segmented } from "antd";
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import { TPanelName } from "./RightPanel";
+
+interface IWidget {
+  initMode?: string;
+  onModeChange?: Function;
+}
+const Widget = ({ initMode = "close", onModeChange }: IWidget) => {
+  const intl = useIntl();
+  const [mode, setMode] = useState<string>(initMode);
+  return (
+    <Segmented
+      size="middle"
+      style={{
+        color: "rgb(134 134 134 / 90%)",
+        backgroundColor: "rgb(129 129 129 / 17%)",
+        display: "block",
+      }}
+      options={[
+        {
+          label: intl.formatMessage({ id: "columns.library.dict.title" }),
+          value: "dict",
+        },
+        {
+          label: intl.formatMessage({ id: "columns.studio.channel.title" }),
+          value: "channel",
+        },
+        {
+          label: intl.formatMessage({ id: "buttons.close" }),
+          value: "close",
+        },
+      ]}
+      value={mode}
+      onChange={(value) => {
+        const newMode: TPanelName = value.toString() as TPanelName;
+        if (typeof onModeChange !== "undefined") {
+          onModeChange(newMode);
+        }
+        setMode(newMode);
+      }}
+    />
+  );
+};
+
+export default Widget;

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

@@ -86,7 +86,7 @@ function tocGetTreeData(
 }
 
 interface IWidgetTocTree {
-  treeData: ListNodeData[];
+  treeData?: ListNodeData[];
   expandedKey?: string[];
   onSelect?: Function;
 }
@@ -96,7 +96,7 @@ const Widget = ({ treeData, expandedKey, onSelect }: IWidgetTocTree) => {
   const [expanded, setExpanded] = useState(expandedKey);
 
   useEffect(() => {
-    if (treeData.length > 0) {
+    if (treeData && treeData.length > 0) {
       const data = tocGetTreeData(treeData);
       setTree(data);
       setExpanded(expandedKey);

+ 38 - 0
dashboard/src/components/article/ToolButton.tsx

@@ -0,0 +1,38 @@
+import { Button, Drawer, Tooltip } from "antd";
+import { useState } from "react";
+
+interface IWidget {
+  icon?: JSX.Element;
+  content?: JSX.Element;
+  title?: string;
+}
+const Widget = ({ icon, content, title }: IWidget) => {
+  const [open, setOpen] = useState(false);
+
+  return (
+    <>
+      <Tooltip placement="right" title={title}>
+        <Button
+          size="middle"
+          icon={icon}
+          onClick={() => {
+            setOpen(true);
+          }}
+        />
+      </Tooltip>
+      <Drawer
+        title={title}
+        width={350}
+        placement="left"
+        onClose={() => {
+          setOpen(false);
+        }}
+        open={open}
+      >
+        {content}
+      </Drawer>
+    </>
+  );
+};
+
+export default Widget;

+ 24 - 0
dashboard/src/components/article/ToolButtonSearch.tsx

@@ -0,0 +1,24 @@
+import { SearchOutlined } from "@ant-design/icons";
+
+import ToolButton from "./ToolButton";
+
+interface IWidget {
+  type?: string;
+  articleId?: string;
+}
+const Widget = ({ type, articleId }: IWidget) => {
+  const id = articleId?.split("_");
+  let tocWidget = <></>;
+  if (id && id.length > 0) {
+    const sentId = id[0].split("-");
+    if (sentId.length > 1) {
+      tocWidget = <></>;
+    }
+  }
+
+  return (
+    <ToolButton title="搜索" icon={<SearchOutlined />} content={tocWidget} />
+  );
+};
+
+export default Widget;

+ 20 - 0
dashboard/src/components/article/ToolButtonSetting.tsx

@@ -0,0 +1,20 @@
+import { SettingOutlined } from "@ant-design/icons";
+import SettingArticle from "../auth/setting/SettingArticle";
+
+import ToolButton from "./ToolButton";
+
+interface IWidget {
+  type?: string;
+  articleId?: string;
+}
+const Widget = ({ type, articleId }: IWidget) => {
+  return (
+    <ToolButton
+      title="设置"
+      icon={<SettingOutlined />}
+      content={<SettingArticle />}
+    />
+  );
+};
+
+export default Widget;

+ 22 - 0
dashboard/src/components/article/ToolButtonTag.tsx

@@ -0,0 +1,22 @@
+import { TagOutlined } from "@ant-design/icons";
+
+import ToolButton from "./ToolButton";
+
+interface IWidget {
+  type?: string;
+  articleId?: string;
+}
+const Widget = ({ type, articleId }: IWidget) => {
+  const id = articleId?.split("_");
+  let tocWidget = <></>;
+  if (id && id.length > 0) {
+    const sentId = id[0].split("-");
+    if (sentId.length > 1) {
+      tocWidget = <></>;
+    }
+  }
+
+  return <ToolButton title="标签" icon={<TagOutlined />} content={tocWidget} />;
+};
+
+export default Widget;

+ 27 - 0
dashboard/src/components/article/ToolButtonToc.tsx

@@ -0,0 +1,27 @@
+import { MenuOutlined } from "@ant-design/icons";
+
+import PaliTextToc from "./PaliTextToc";
+import ToolButton from "./ToolButton";
+
+interface IWidget {
+  type?: string;
+  articleId?: string;
+}
+const Widget = ({ type, articleId }: IWidget) => {
+  const id = articleId?.split("_");
+  let tocWidget = <></>;
+  if (id && id.length > 0) {
+    const sentId = id[0].split("-");
+    if (sentId.length > 1) {
+      tocWidget = (
+        <PaliTextToc book={parseInt(sentId[0])} para={parseInt(sentId[1])} />
+      );
+    }
+  }
+
+  return (
+    <ToolButton title="目录" icon={<MenuOutlined />} content={tocWidget} />
+  );
+};
+
+export default Widget;

+ 32 - 0
dashboard/src/components/article/article.css

@@ -0,0 +1,32 @@
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  font-weight: 700;
+}
+h2 {
+  margin-top: 1em;
+  font-size: 180%;
+  border-bottom: 1px solid gray;
+}
+h3 {
+  margin-top: 0.5em;
+  font-size: 150%;
+}
+h4 {
+  font-size: 120%;
+}
+h5 {
+  font-size: 110%;
+}
+h6 {
+  font-size: 100%;
+}
+blockquote {
+  margin-left: 1em;
+  border-left: 4px solid #dad9d9;
+  padding-left: 0.5em;
+  color: gray;
+}

+ 5 - 5
dashboard/src/components/auth/SignInAvatar.tsx

@@ -19,12 +19,12 @@ const Widget = () => {
   // TODO
   const intl = useIntl();
   const navigate = useNavigate();
-  const [userName, setUserName] = useState("");
-  const [nickName, setNickName] = useState("");
+  const [userName, setUserName] = useState<string>();
+  const [nickName, setNickName] = useState<string>();
   const user = useAppSelector(_currentUser);
   useEffect(() => {
-    setUserName(user ? user.realName : "");
-    setNickName(user ? user.nickName : "");
+    setUserName(user?.realName);
+    setNickName(user?.nickName);
   }, [user]);
 
   const userCard = (
@@ -87,7 +87,7 @@ const Widget = () => {
             icon={<UserOutlined />}
             size="small"
           >
-            {nickName.slice(0, 1)}
+            {nickName?.slice(0, 1)}
           </Avatar>
         </Popover>
       </>

+ 4 - 4
dashboard/src/components/auth/StudioCard.tsx

@@ -4,7 +4,7 @@ import { IStudio } from "./StudioName";
 import { Link } from "react-router-dom";
 
 interface IWidget {
-  studio: IStudio;
+  studio?: IStudio;
   children?: JSX.Element;
 }
 const Widget = ({ studio, children }: IWidget) => {
@@ -18,15 +18,15 @@ const Widget = ({ studio, children }: IWidget) => {
             <div style={{ display: "flex" }}>
               <div style={{ paddingRight: 8 }}>
                 <Avatar style={{ backgroundColor: "#87d068" }} size="small">
-                  {studio.nickName.slice(0, 1)}
+                  {studio?.nickName.slice(0, 1)}
                 </Avatar>
               </div>
               <div>
-                <div>{studio.nickName}</div>
+                <div>{studio?.nickName}</div>
                 <div>译文(2) | 课程(3)</div>
                 <div>
                   <Link
-                    to={`/blog/${studio.studioName}/overview`}
+                    to={`/blog/${studio?.studioName}/overview`}
                     target="_blank"
                   >
                     {intl.formatMessage({

+ 4 - 4
dashboard/src/components/auth/StudioName.tsx

@@ -9,7 +9,7 @@ export interface IStudio {
   avatar: string;
 }
 interface IWidghtStudio {
-  data: IStudio;
+  data?: IStudio;
   showAvatar?: boolean;
   showName?: boolean;
   onClick?: Function;
@@ -21,18 +21,18 @@ const Widget = ({
   onClick,
 }: IWidghtStudio) => {
   // TODO
-  const avatar = <Avatar size="small">{data.nickName.slice(0, 1)}</Avatar>;
+  const avatar = <Avatar size="small">{data?.nickName.slice(0, 1)}</Avatar>;
   return (
     <StudioCard studio={data}>
       <Space
         onClick={() => {
           if (typeof onClick !== "undefined") {
-            onClick(data.studioName);
+            onClick(data?.studioName);
           }
         }}
       >
         {showAvatar ? avatar : ""}
-        {showName ? data.nickName : ""}
+        {showName ? data?.nickName : ""}
       </Space>
     </StudioCard>
   );

+ 8 - 1
dashboard/src/components/auth/ToLibaray.tsx

@@ -8,7 +8,14 @@ const Widget = () => {
   return (
     <>
       <Link to="/palicanon/list">
-        <Button type="primary">
+        <Button
+          type="primary"
+          style={{
+            paddingLeft: 18,
+            paddingRight: 18,
+            backgroundColor: "#52974e",
+          }}
+        >
           {intl.formatMessage({
             id: "columns.library.title",
           })}

+ 8 - 1
dashboard/src/components/auth/ToStudio.tsx

@@ -14,7 +14,14 @@ const Widget = () => {
     return (
       <>
         <Link to={`/studio/${user.realName}/home`}>
-          <Button type="primary" style={{ paddingLeft: 18, paddingRight: 18 }}>
+          <Button
+            type="primary"
+            style={{
+              paddingLeft: 18,
+              paddingRight: 18,
+              backgroundColor: "#52974e",
+            }}
+          >
             {intl.formatMessage({
               id: "columns.studio.title",
             })}

+ 0 - 2
dashboard/src/components/auth/UserName.tsx

@@ -1,5 +1,3 @@
-import { Avatar } from "antd";
-
 export interface IUser {
   id?: string;
   nickName?: string;

+ 9 - 9
dashboard/src/components/auth/setting/SettingItem.tsx

@@ -34,9 +34,11 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
     }
   }, [data?.key, settings]);
   let content: JSX.Element = <></>;
+
   if (typeof data === "undefined") {
     return content;
   } else {
+    const description: string = intl.formatMessage({ id: data.description });
     switch (typeof data.defaultValue) {
       case "number":
         break;
@@ -44,12 +46,8 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
         switch (data.widget) {
           case "radio-button":
             if (typeof data.options !== "undefined") {
-              return (
+              content = (
                 <>
-                  {title}
-                  <div>
-                    <Text>{intl.formatMessage({ id: data.description })}</Text>
-                  </div>
                   <Radio.Group
                     value={value}
                     buttonStyle="solid"
@@ -121,7 +119,6 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
                 );
               }}
             />
-            <Text>{intl.formatMessage({ id: data.description })}</Text>
           </div>
         );
         break;
@@ -130,9 +127,12 @@ const Widget = ({ data, onChange }: IWidgetSettingItem) => {
     }
 
     return (
-      <div>
-        <Title level={5}>{intl.formatMessage({ id: data.label })}</Title>
-        {content}
+      <div style={{ marginBottom: 10 }}>
+        <div style={{ display: "flex", justifyContent: "space-between" }}>
+          <Text>{intl.formatMessage({ id: data.label })}</Text>
+          {content}
+        </div>
+        <Text type="secondary">{description}</Text>
       </div>
     );
   }

+ 21 - 9
dashboard/src/components/channel/ChannelCreate.tsx

@@ -1,11 +1,16 @@
 import { useIntl } from "react-intl";
-import { ProForm, ProFormText } from "@ant-design/pro-components";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
 import { message } from "antd";
 
 import { post } from "../../request";
 import { IApiResponseChannel } from "../api/Channel";
 import ChannelTypeSelect from "./ChannelTypeSelect";
 import LangSelect from "../general/LangSelect";
+import { useRef } from "react";
 
 interface IFormData {
   name: string;
@@ -14,22 +19,29 @@ interface IFormData {
   studio: string;
 }
 
-type IWidgetChannelCreate = {
-  studio: string | undefined;
-};
-const Widget = (prop: IWidgetChannelCreate) => {
+interface IWidget {
+  studio?: string;
+  onSuccess?: Function;
+}
+const Widget = ({ studio, onSuccess }: IWidget) => {
   const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
 
   return (
     <ProForm<IFormData>
+      formRef={formRef}
       onFinish={async (values: IFormData) => {
-        // TODO
-        console.log(values);
-        values.studio = prop.studio ? prop.studio : "";
+        if (typeof studio === "undefined") {
+          return;
+        }
+        values.studio = studio;
         const res: IApiResponseChannel = await post(`/v2/channel`, values);
-        console.log(res);
         if (res.ok) {
           message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+            formRef.current?.resetFields(["name"]);
+          }
         } else {
           message.error(res.message);
         }

+ 32 - 19
dashboard/src/components/channel/ChannelList.tsx

@@ -1,16 +1,22 @@
 import { useState, useEffect } from "react";
-import { List } from "antd";
+import { List, message, Space, Tag } from "antd";
 
-import type { ChannelInfoProps } from "../api/Channel";
+import type { IChannelApiData } from "../api/Channel";
 import { IApiResponseChannelList } from "../api/Corpus";
 import { get } from "../../request";
 import ChannelListItem from "./ChannelListItem";
+import { IStudio } from "../auth/StudioName";
 
 export interface ChannelFilterProps {
   chapterProgress: number;
   lang: string;
   channelType: string;
 }
+interface IChannelList {
+  channel: IChannelApiData;
+  studio: IStudio;
+  count: number;
+}
 interface IWidgetChannelList {
   filter?: ChannelFilterProps;
 }
@@ -21,25 +27,29 @@ const defaultChannelFilterProps: ChannelFilterProps = {
 };
 
 const Widget = ({ filter = defaultChannelFilterProps }: IWidgetChannelList) => {
-  const [tableData, setTableData] = useState<ChannelInfoProps[]>([]);
+  const [tableData, setTableData] = useState<IChannelList[]>([]);
 
   useEffect(() => {
     console.log("palichapterlist useEffect");
     let url = `/v2/progress?view=channel&channel_type=${filter.channelType}&lang=${filter.lang}&progress=${filter.chapterProgress}`;
-    get(url).then(function (myJson) {
-      console.log("ajex", myJson);
-      const data = myJson as unknown as IApiResponseChannelList;
-      const newData: ChannelInfoProps[] = data.data.rows.map((item) => {
-        return {
-          channelName: item.channel.name,
-          channelId: item.channel.uid,
-          channelType: item.channel.type,
-          studioName: "V",
-          studioId: "123",
-          studioType: "p",
-        };
-      });
-      setTableData(newData);
+    get<IApiResponseChannelList>(url).then(function (json) {
+      if (json.ok) {
+        console.log("channel", json.data);
+        const newData: IChannelList[] = json.data.rows.map((item) => {
+          return {
+            channel: {
+              name: item.channel.name,
+              id: item.channel.uid,
+              type: item.channel.type,
+            },
+            studio: item.studio,
+            count: item.count,
+          };
+        });
+        setTableData(newData);
+      } else {
+        message.error(json.message);
+      }
     });
   }, [filter]);
   return (
@@ -47,11 +57,14 @@ const Widget = ({ filter = defaultChannelFilterProps }: IWidgetChannelList) => {
       <h3>Channel</h3>
       <List
         itemLayout="vertical"
-        size="large"
+        size="small"
         dataSource={tableData}
         renderItem={(item) => (
           <List.Item>
-            <ChannelListItem data={item} />
+            <Space>
+              <ChannelListItem channel={item.channel} studio={item.studio} />
+              <Tag>{item.count}</Tag>
+            </Space>
           </List.Item>
         )}
       />

+ 9 - 7
dashboard/src/components/channel/ChannelListItem.tsx

@@ -1,21 +1,23 @@
 import { Space } from "antd";
 import { Avatar } from "antd";
 
-import type { ChannelInfoProps } from "../api/Channel";
+import type { IChannelApiData } from "../api/Channel";
+import { IStudio } from "../auth/StudioName";
 
-type IWidgetChannelListItem = {
-  data: ChannelInfoProps;
+interface IWidget {
+  channel: IChannelApiData;
+  studio: IStudio;
   showProgress?: boolean;
   showLike?: boolean;
-};
+}
 
-const Widget = ({ data, showProgress, showLike }: IWidgetChannelListItem) => {
-  const studioName = data.studioName.slice(0, 2);
+const Widget = ({ channel, studio, showProgress, showLike }: IWidget) => {
+  const studioName = studio.nickName.slice(0, 2);
   return (
     <>
       <Space>
         <Avatar size="small">{studioName}</Avatar>
-        {data.channelName}@{data.studioName}
+        {channel.name}
       </Space>
     </>
   );

+ 6 - 3
dashboard/src/components/channel/ChannelPicker.tsx

@@ -3,12 +3,14 @@ import { Button, Modal } from "antd";
 
 import ChannelPickerTable from "./ChannelPickerTable";
 import { IChannel } from "./Channel";
+import { ArticleType } from "../article/Article";
 
 interface IWidget {
-  type: string;
-  articleId: string;
+  type?: ArticleType | "editable";
+  articleId?: string;
+  multiSelect?: boolean;
 }
-const Widget = ({ type, articleId }: IWidget) => {
+const Widget = ({ type, articleId, multiSelect }: IWidget) => {
   const [isModalOpen, setIsModalOpen] = useState(false);
 
   const showModal = () => {
@@ -38,6 +40,7 @@ const Widget = ({ type, articleId }: IWidget) => {
         <ChannelPickerTable
           type={type}
           articleId={articleId}
+          multiSelect={multiSelect}
           onSelect={(e: IChannel) => {
             console.log(e);
             handleCancel();

+ 207 - 219
dashboard/src/components/channel/ChannelPickerTable.tsx

@@ -1,23 +1,30 @@
-import { ProTable } from "@ant-design/pro-components";
+import { ProList } from "@ant-design/pro-components";
 import { useIntl } from "react-intl";
-import { Space, Table } from "antd";
-import { GlobalOutlined } from "@ant-design/icons";
+import { Dropdown, Space, Table } from "antd";
+import {
+  GlobalOutlined,
+  EditOutlined,
+  MoreOutlined,
+  CopyOutlined,
+} from "@ant-design/icons";
 import { Button } from "antd";
 
-import { PublicityValueEnum } from "../studio/table";
-import { IApiResponseChannelList, IFinal } from "../api/Channel";
+import { IApiResponseChannelList, IFinal, TChannelType } from "../api/Channel";
 import { get } from "../../request";
 import { LockIcon } from "../../assets/icon";
 import StudioName, { IStudio } from "../auth/StudioName";
 import ProgressSvg from "./ProgressSvg";
 import { IChannel } from "./Channel";
+import { ArticleType } from "../article/Article";
+import CopyToModal from "./CopyToModal";
+import { useState } from "react";
 
 export interface IItem {
   id: number;
   uid: string;
   title: string;
   summary: string;
-  type: string;
+  type: TChannelType;
   studio: IStudio;
   shareType: string;
   role?: string;
@@ -26,229 +33,104 @@ export interface IItem {
   final?: IFinal[];
 }
 interface IWidget {
-  type: string;
-  articleId: string;
+  type?: ArticleType | "editable";
+  articleId?: string;
+  multiSelect?: boolean;
+  selectedKeys?: string[];
   onSelect?: Function;
 }
-const Widget = ({ type, articleId, onSelect }: IWidget) => {
+const Widget = ({
+  type,
+  articleId,
+  multiSelect = true,
+  selectedKeys = [],
+  onSelect,
+}: IWidget) => {
   const intl = useIntl();
-
+  const [selectedRowKeys, setSelectedRowKeys] =
+    useState<React.Key[]>(selectedKeys);
   return (
     <>
-      <ProTable<IItem>
-        columns={[
-          {
-            title: intl.formatMessage({
-              id: "forms.fields.name.label",
-            }),
-            dataIndex: "title",
-            key: "title",
-            search: false,
-            tip: "过长会自动收缩",
-            ellipsis: true,
-            valueType: "text",
-            render: (text, row, index, action) => {
-              let pIcon = <></>;
-              switch (row.publicity) {
-                case 10:
-                  pIcon = <LockIcon />;
-                  break;
-                case 30:
-                  pIcon = <GlobalOutlined />;
-                  break;
+      <ProList<IItem>
+        rowSelection={
+          multiSelect
+            ? {
+                // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+                // 注释该行则默认不显示下拉选项
+                selectedRowKeys,
+                selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
               }
-              return (
-                <Button
-                  type="link"
-                  onClick={() => {
-                    if (typeof onSelect !== "undefined") {
-                      const e: IChannel = { name: row.title, id: row.uid };
-                      onSelect(e);
-                    }
-                  }}
-                >
-                  <Space>
-                    {pIcon}
-                    {row.title}
+            : undefined
+        }
+        tableAlertRender={
+          multiSelect
+            ? ({ 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>
+              )
+            : undefined
+        }
+        tableAlertOptionRender={
+          multiSelect
+            ? ({ selectedRowKeys, selectedRows, onCleanSelected }) => {
+                return (
+                  <Space size={16}>
+                    <Button
+                      type="link"
+                      onClick={() => {
+                        console.log("select", selectedRowKeys);
+                        if (typeof onSelect !== "undefined") {
+                          onSelect(
+                            selectedRows.map((item) => {
+                              return {
+                                id: item.uid,
+                                name: item.title,
+                              };
+                            })
+                          );
+                        }
+                      }}
+                    >
+                      {intl.formatMessage({
+                        id: "buttons.select",
+                      })}
+                    </Button>
                   </Space>
-                </Button>
-              );
-            },
-          },
-          {
-            title: intl.formatMessage({
-              id: "channel.title",
-            }),
-            render: (text, row, index, action) => {
-              return <StudioName data={row.studio} />;
-            },
-            key: "studio",
-            search: false,
-          },
-          {
-            title: intl.formatMessage({
-              id: "tables.progress.label",
-            }),
-            width: 210,
-            render: (text, row, index, action) => {
-              return <ProgressSvg data={row.final} width={200} />;
-            },
-            key: "progress",
-            search: false,
-          },
-          {
-            title: intl.formatMessage({
-              id: "forms.fields.type.label",
-            }),
-            dataIndex: "type",
-            key: "type",
-            width: 100,
-            search: false,
-            filters: true,
-            onFilter: true,
-            valueEnum: {
-              all: {
-                text: intl.formatMessage({
-                  id: "channel.type.all.title",
-                }),
-                status: "Default",
-              },
-              translation: {
-                text: intl.formatMessage({
-                  id: "channel.type.translation.label",
-                }),
-                status: "Success",
-              },
-              nissaya: {
-                text: intl.formatMessage({
-                  id: "channel.type.nissaya.label",
-                }),
-                status: "Processing",
-              },
-              commentary: {
-                text: intl.formatMessage({
-                  id: "channel.type.commentary.label",
-                }),
-                status: "Default",
-              },
-              original: {
-                text: intl.formatMessage({
-                  id: "channel.type.original.label",
-                }),
-                status: "Default",
-              },
-              general: {
-                text: intl.formatMessage({
-                  id: "channel.type.general.label",
-                }),
-                status: "Default",
-              },
-            },
-          },
-          {
-            title: intl.formatMessage({
-              id: "forms.fields.publicity.label",
-            }),
-            dataIndex: "publicity",
-            key: "publicity",
-            width: 100,
-            search: false,
-            filters: true,
-            hideInTable: true,
-            onFilter: true,
-            valueEnum: PublicityValueEnum(),
-          },
-          {
-            title: intl.formatMessage({ id: "buttons.option" }),
-            key: "option",
-            width: 120,
-            valueType: "option",
-            render: (text, row, index, action) => {
-              return [
-                intl.formatMessage({
-                  id: "buttons.edit",
-                }),
-              ];
-            },
-          },
-          {
-            title: "类型",
-            dataIndex: "shareType",
-            valueType: "select",
-            hideInTable: true,
-            width: 120,
-            valueEnum: {
-              all: { text: "全部" },
-              my: { text: "我的" },
-              share: { text: "协作" },
-              public: { text: "全网公开" },
-            },
-          },
-          {
-            title: intl.formatMessage({ id: "auth.role.label" }),
-            dataIndex: "role",
-            valueType: "select",
-            width: 120,
-            valueEnum: {
-              all: { text: "全部" },
-              owner: { text: intl.formatMessage({ id: "auth.role.owner" }) },
-              manager: {
-                text: intl.formatMessage({ id: "auth.role.manager" }),
-              },
-              editor: { text: intl.formatMessage({ id: "auth.role.editor" }) },
-              member: { text: intl.formatMessage({ id: "auth.role.member" }) },
-            },
-          },
-        ]}
-        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.select",
-                })}
-              </Button>
-            </Space>
-          );
-        }}
+                );
+              }
+            : undefined
+        }
         request={async (params = {}, sorter, filter) => {
           // TODO
           console.log(params, sorter, filter);
           let url: string = "";
           switch (type) {
+            case "editable":
+              url = `/v2/channel?view=user-edit`;
+              break;
             case "chapter":
-              const [book, para] = articleId.split("-");
-              url = `/v2/channel?view=user-in-chapter&book=${book}&para=${para}&progress=sent`;
+              if (typeof articleId !== "undefined") {
+                const id = articleId.split("_");
+                const [book, para] = id[0].split("-");
+                url = `/v2/channel?view=user-in-chapter&book=${book}&para=${para}&progress=sent`;
+              }
+
               break;
           }
           const res: IApiResponseChannelList = await get(url);
           console.log("data", res.data.rows);
           const items: IItem[] = res.data.rows.map((item, id) => {
-            console.log("final", item.final);
             const date = new Date(item.created_at);
             return {
               id: id,
@@ -269,25 +151,131 @@ const Widget = ({ type, articleId, onSelect }: IWidget) => {
               final: item.final,
             };
           });
-
+          setSelectedRowKeys(selectedRowKeys);
           return {
             total: res.data.count,
             succcess: true,
             data: items,
           };
         }}
-        rowKey="id"
+        rowKey="uid"
         bordered
-        pagination={{
-          showQuickJumper: true,
-          showSizeChanger: false,
-          pageSize: 5,
-        }}
         options={false}
         search={{
           filterType: "light",
         }}
-        toolBarRender={() => [<></>]}
+        showActions="hover"
+        metas={{
+          title: {
+            render(dom, entity, index, action, schema) {
+              let pIcon = <></>;
+              switch (entity.publicity) {
+                case 10:
+                  pIcon = <LockIcon />;
+                  break;
+                case 30:
+                  pIcon = <GlobalOutlined />;
+                  break;
+              }
+
+              return (
+                <div key={index}>
+                  <div key="info">
+                    <Space>
+                      {pIcon}
+                      {entity.role !== "member" ? <EditOutlined /> : undefined}
+                    </Space>
+                    <Button
+                      type="link"
+                      onClick={() => {
+                        if (typeof onSelect !== "undefined") {
+                          const e: IChannel = {
+                            name: entity.title,
+                            id: entity.uid,
+                          };
+                          onSelect([e]);
+                        }
+                      }}
+                    >
+                      <Space>
+                        <StudioName data={entity.studio} showName={false} />
+                        {entity.title}
+                      </Space>
+                    </Button>
+                  </div>
+                  <div key="progress">
+                    <ProgressSvg data={entity.final} width={200} />
+                  </div>
+                </div>
+              );
+            },
+            search: false,
+          },
+          actions: {
+            render: (dom, entity, index, action, schema) => {
+              return (
+                <Dropdown
+                  key={index}
+                  trigger={["click"]}
+                  menu={{
+                    items: [
+                      {
+                        key: "copy_to",
+                        label: (
+                          <CopyToModal
+                            trigger={intl.formatMessage({
+                              id: "buttons.copy.to",
+                            })}
+                            channel={{
+                              id: entity.uid,
+                              name: entity.title,
+                              type: entity.type,
+                            }}
+                          />
+                        ),
+                        icon: <CopyOutlined />,
+                      },
+                    ],
+                    onClick: (e) => {
+                      console.log("click ", e);
+                      switch (e.key) {
+                        case "copy_to":
+                          break;
+
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                  placement="bottomRight"
+                >
+                  <Button
+                    type="link"
+                    size="small"
+                    icon={<MoreOutlined />}
+                  ></Button>
+                </Dropdown>
+              );
+            },
+          },
+          status: {
+            // 自己扩展的字段,主要用于筛选,不在列表中显示
+            title: "筛选",
+            valueType: "select",
+            valueEnum: {
+              all: { text: "全部", status: "Default" },
+              my: {
+                text: "我的",
+              },
+              closed: {
+                text: "协作",
+              },
+              processing: {
+                text: "社区公开",
+              },
+            },
+          },
+        }}
       />
     </>
   );

+ 208 - 0
dashboard/src/components/channel/ChannelSentDiff.tsx

@@ -0,0 +1,208 @@
+import { Button, Col, List, message, Row, Space, Typography } from "antd";
+import { diffChars } from "diff";
+import { useEffect, useState } from "react";
+import { SwapRightOutlined } from "@ant-design/icons";
+
+import { post } from "../../request";
+import {
+  ISentenceDiffData,
+  ISentenceDiffRequest,
+  ISentenceDiffResponse,
+  ISentenceNewMultiResponse,
+  ISentenceNewRequest,
+} from "../api/Corpus";
+import { IChannel } from "./Channel";
+
+const { Text } = Typography;
+
+interface IDiffData {
+  id: string;
+  srcContent?: string;
+  destContent?: string | JSX.Element;
+}
+interface ISentenceData {
+  book: number;
+  paragraph: number;
+  wordStart: number;
+  wordEnd: number;
+  content: string;
+}
+
+interface IWidget {
+  srcChannel?: IChannel;
+  destChannel?: IChannel;
+  sentences?: string[];
+  goPrev?: Function;
+  onSubmit?: Function;
+}
+const Widget = ({
+  srcChannel,
+  destChannel,
+  sentences,
+  goPrev,
+  onSubmit,
+}: IWidget) => {
+  const [srcApiData, setSrcApiData] = useState<ISentenceDiffData[]>([]);
+  const [srcData, setSrcData] = useState<ISentenceData[]>([]);
+  const [destData, setDestData] = useState<ISentenceData[]>([]);
+  const [diffData, setDiffData] = useState<IDiffData[]>();
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    if (sentences && srcChannel) {
+      post<ISentenceDiffRequest, ISentenceDiffResponse>(`/v2/sent-in-channel`, {
+        sentences: sentences,
+        channel: srcChannel.id,
+      }).then((json) => {
+        if (json.ok) {
+          console.log("src", srcChannel.id, json.data.rows);
+          setSrcApiData(json.data.rows);
+          const data = json.data.rows.map((item) => {
+            return {
+              book: item.book_id,
+              paragraph: item.paragraph,
+              wordStart: item.word_start,
+              wordEnd: item.word_end,
+              content: item.content,
+            };
+          });
+          setSrcData(data);
+        }
+      });
+    }
+  }, [srcChannel, sentences]);
+
+  useEffect(() => {
+    if (sentences && destChannel) {
+      post<ISentenceDiffRequest, ISentenceDiffResponse>(`/v2/sent-in-channel`, {
+        sentences: sentences,
+        channel: destChannel.id,
+      }).then((json) => {
+        if (json.ok) {
+          console.log("dest", destChannel.id, json.data.rows);
+          const data = json.data.rows.map((item) => {
+            return {
+              book: item.book_id,
+              paragraph: item.paragraph,
+              wordStart: item.word_start,
+              wordEnd: item.word_end,
+              content: item.content,
+            };
+          });
+          setDestData(data);
+        }
+      });
+    }
+  }, [destChannel, sentences]);
+
+  useEffect(() => {
+    const diffList = sentences?.map((item) => {
+      const id = item.split("-");
+      const srcContent = srcData.find(
+        (element) =>
+          element.book === parseInt(id[0]) &&
+          element.paragraph === parseInt(id[1]) &&
+          element.wordStart === parseInt(id[2]) &&
+          element.wordEnd === parseInt(id[3])
+      );
+
+      const destContent = destData.find(
+        (element) =>
+          element.book === parseInt(id[0]) &&
+          element.paragraph === parseInt(id[1]) &&
+          element.wordStart === parseInt(id[2]) &&
+          element.wordEnd === parseInt(id[3])
+      );
+      const diff = diffChars(
+        destContent ? destContent.content : "",
+        srcContent ? srcContent.content : ""
+      );
+      const diffResult = diff.map((item) => {
+        return (
+          <Text
+            type={
+              item.added ? "success" : item.removed ? "danger" : "secondary"
+            }
+            delete={item.removed ? true : undefined}
+          >
+            {item.value}
+          </Text>
+        );
+      });
+      return {
+        id: item,
+        srcContent: srcContent?.content,
+        destContent: <>{diffResult}</>,
+      };
+    });
+    setDiffData(diffList);
+  }, [destData, sentences, srcData]);
+
+  return (
+    <div>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <Button
+          onClick={() => {
+            if (typeof goPrev !== "undefined") {
+              goPrev();
+            }
+          }}
+        >
+          上一步
+        </Button>
+        <Space>
+          {srcChannel?.name}
+          <SwapRightOutlined />
+          {destChannel?.name}
+        </Space>
+        <Button
+          type="primary"
+          loading={loading}
+          onClick={() => {
+            setLoading(true);
+            post<ISentenceNewRequest, ISentenceNewMultiResponse>(
+              `/v2/sentence`,
+              {
+                sentences: srcApiData,
+                channel: destChannel?.id,
+              }
+            )
+              .then((json) => {
+                if (json.ok) {
+                  if (typeof onSubmit !== "undefined") {
+                    onSubmit();
+                  }
+                } else {
+                  message.error(json.message);
+                }
+              })
+              .catch((e) => {
+                console.log(e);
+              })
+              .finally(() => {
+                setLoading(false);
+              });
+          }}
+        >
+          开始复制
+        </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>
+  );
+};
+
+export default Widget;

+ 53 - 0
dashboard/src/components/channel/CopyToModal.tsx

@@ -0,0 +1,53 @@
+import { useState } from "react";
+import { Modal } from "antd";
+
+import CopyToStep from "./CopyToStep";
+import { IChannel } from "./Channel";
+
+interface IWidget {
+  trigger: JSX.Element | string;
+  channel?: IChannel;
+}
+const Widget = ({ trigger, channel }: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [initStep, setInitStep] = useState(0);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+    setInitStep(0);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"80%"}
+        style={{ maxWidth: 1000 }}
+        title="版本间复制"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        footer={[]}
+      >
+        <CopyToStep
+          initStep={initStep}
+          channel={channel}
+          onClose={() => {
+            setIsModalOpen(false);
+            Modal.destroyAll();
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default Widget;

+ 40 - 0
dashboard/src/components/channel/CopyToResult.tsx

@@ -0,0 +1,40 @@
+import { Button, Result } from "antd";
+
+interface IWidget {
+  onClose?: Function;
+  onInit?: Function;
+}
+const Widget = ({ onClose, onInit }: IWidget) => {
+  return (
+    <Result
+      status="success"
+      title="Successfully Copied!"
+      subTitle="Sentence: 23 , Words: 143"
+      extra={[
+        <Button
+          key="init"
+          onClick={() => {
+            if (typeof onInit !== "undefined") {
+              onInit();
+            }
+          }}
+        >
+          从新开始
+        </Button>,
+        <Button
+          key="close"
+          type="primary"
+          onClick={() => {
+            if (typeof onClose !== "undefined") {
+              onClose();
+            }
+          }}
+        >
+          关闭
+        </Button>,
+      ]}
+    />
+  );
+};
+
+export default Widget;

+ 117 - 0
dashboard/src/components/channel/CopyToStep.tsx

@@ -0,0 +1,117 @@
+import { Steps } from "antd";
+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";
+
+interface IWidget {
+  initStep?: number;
+  channel?: IChannel;
+  type?: ArticleType;
+  articleId?: string;
+  sentence?: string[];
+  stepChange?: Function;
+  onClose?: Function;
+}
+const Widget = ({
+  initStep = 0,
+  channel,
+  type,
+  articleId,
+  sentence,
+  stepChange,
+  onClose,
+}: IWidget) => {
+  const [current, setCurrent] = useState(0);
+  const [destChannel, setDestChannel] = useState<IChannel>();
+  const [copyPercent, setCopyPercent] = useState<number>();
+
+  const sentences = useAppSelector(sentenceList);
+
+  useEffect(() => {
+    setCurrent(initStep);
+  }, [initStep]);
+
+  const next = () => {
+    setCurrent(current + 1);
+  };
+
+  const prev = () => {
+    setCurrent(current - 1);
+  };
+  const steps = [
+    {
+      title: "选择目标版本",
+      key: "channel",
+      content: (
+        <ChannelPickerTable
+          type="editable"
+          multiSelect={false}
+          onSelect={(e: IChannel) => {
+            console.log(e);
+            setDestChannel(e);
+            next();
+          }}
+        />
+      ),
+    },
+    {
+      title: "文本比对",
+      key: "diff",
+      content: (
+        <div>
+          <ChannelSentDiff
+            srcChannel={channel}
+            destChannel={destChannel}
+            sentences={sentences}
+            goPrev={() => {
+              prev();
+            }}
+            onSubmit={() => {
+              next();
+            }}
+          />
+        </div>
+      ),
+    },
+    {
+      title: "完成",
+      key: "finish",
+      content: (
+        <CopyToResult
+          onClose={() => {
+            if (typeof onClose !== "undefined") {
+              onClose();
+            }
+          }}
+          onInit={() => {
+            setCurrent(0);
+          }}
+        />
+      ),
+    },
+  ];
+  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>
+    </div>
+  );
+};
+
+export default Widget;

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

@@ -1,7 +1,7 @@
 import { useState } from "react";
 import { Drawer } from "antd";
 import CommentTopic from "./CommentTopic";
-import CommentListCard from "./CommentListCard";
+import CommentListCard, { TResType } from "./CommentListCard";
 import { IComment } from "./CommentItem";
 
 export interface IAnswerCount {
@@ -11,7 +11,7 @@ export interface IAnswerCount {
 interface IWidget {
   trigger?: JSX.Element;
   resId?: string;
-  resType?: string;
+  resType?: TResType;
   onCommentCountChange?: Function;
 }
 const Widget = ({ trigger, resId, resType, onCommentCountChange }: IWidget) => {
@@ -19,7 +19,7 @@ const Widget = ({ trigger, resId, resType, onCommentCountChange }: IWidget) => {
   const [childrenDrawer, setChildrenDrawer] = useState(false);
   const [topicComment, setTopicComment] = useState<IComment>();
   const [answerCount, setAnswerCount] = useState<IAnswerCount>();
-  //console.log(resId, resType);
+
   const showDrawer = () => {
     setOpen(true);
   };

+ 123 - 93
dashboard/src/components/comment/CommentCreate.tsx

@@ -1,5 +1,5 @@
 import { useIntl } from "react-intl";
-import { message } from "antd";
+import { Form, message } from "antd";
 import {
   ProForm,
   ProFormInstance,
@@ -7,6 +7,8 @@ import {
   ProFormTextArea,
 } from "@ant-design/pro-components";
 import { Col, Row, Space } from "antd";
+import ReactQuill from "react-quill";
+import "react-quill/dist/quill.snow.css";
 
 import { IComment } from "./CommentItem";
 import { post } from "../../request";
@@ -15,13 +17,22 @@ import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 import { useRef } from "react";
 
+export type TContentType = "text" | "markdown" | "html";
+
 interface IWidget {
   resId?: string;
   resType?: string;
   parent?: string;
   onCreated?: Function;
+  editor?: TContentType;
 }
-const Widget = ({ resId = "", resType = "", parent, onCreated }: IWidget) => {
+const Widget = ({
+  resId = "",
+  resType = "",
+  editor = "html",
+  parent,
+  onCreated,
+}: IWidget) => {
   const intl = useIntl();
   const formRef = useRef<ProFormInstance>();
   const _currUser = useAppSelector(_currentUser);
@@ -29,97 +40,116 @@ const Widget = ({ resId = "", resType = "", parent, onCreated }: IWidget) => {
     labelCol: { span: 4 },
     wrapperCol: { span: 20 },
   };
-  return (
-    <div>
-      <div>{_currUser?.nickName}:</div>
-      <ProForm<IComment>
-        {...formItemLayout}
-        layout="horizontal"
-        formRef={formRef}
-        submitter={{
-          render: (props, doms) => {
-            return (
-              <Row>
-                <Col span={14} offset={4}>
-                  <Space>{doms}</Space>
-                </Col>
-              </Row>
-            );
-          },
-        }}
-        onFinish={async (values) => {
-          //新建
-          console.log("create", resId, resType, parent);
-
-          post<ICommentRequest, ICommentResponse>(`/v2/discussion`, {
-            res_id: resId,
-            res_type: resType,
-            parent: parent,
-            title: values.title,
-            content: values.content,
-          })
-            .then((json) => {
-              console.log("new discussion", json);
-              if (json.ok) {
-                formRef.current?.resetFields();
-                if (typeof onCreated !== "undefined") {
-                  onCreated({
-                    id: json.data.id,
-                    resId: json.data.res_id,
-                    resType: json.data.res_type,
-                    user: {
-                      id: json.data.editor?.id ? json.data.editor.id : "null",
-                      nickName: json.data.editor?.nickName
-                        ? json.data.editor.nickName
-                        : "null",
-                      realName: json.data.editor?.userName
-                        ? json.data.editor.userName
-                        : "null",
-                      avatar: json.data.editor?.avatar
-                        ? json.data.editor.avatar
-                        : "null",
-                    },
-                    title: json.data.title,
-                    parent: json.data.parent,
-                    content: json.data.content,
-                    createdAt: json.data.created_at,
-                    updatedAt: json.data.updated_at,
-                  });
-                }
-              } else {
-                message.error(json.message);
-              }
-            })
-            .catch((e) => {
-              message.error(e.message);
-            });
-        }}
-        params={{}}
-      >
-        {parent ? (
-          <></>
-        ) : (
-          <ProFormText
-            name="title"
-            label={intl.formatMessage({ id: "forms.fields.title.label" })}
-            tooltip="最长为 24 位"
-            placeholder={intl.formatMessage({
-              id: "forms.message.title.required",
-            })}
-            rules={[{ required: true, message: "这是必填项" }]}
-          />
-        )}
-
-        <ProFormTextArea
-          name="content"
-          label={intl.formatMessage({ id: "forms.fields.content.label" })}
-          placeholder={intl.formatMessage({
-            id: "forms.fields.content.placeholder",
-          })}
-        />
-      </ProForm>
-    </div>
-  );
+  if (typeof _currUser === "undefined") {
+    return <></>;
+  } else {
+    return (
+      <div>
+        <div>{_currUser?.nickName}:</div>
+        <div>
+          <ProForm<IComment>
+            {...formItemLayout}
+            layout="horizontal"
+            formRef={formRef}
+            submitter={{
+              render: (props, doms) => {
+                return (
+                  <Row>
+                    <Col span={14} offset={4}>
+                      <Space>{doms}</Space>
+                    </Col>
+                  </Row>
+                );
+              },
+            }}
+            onFinish={async (values) => {
+              //新建
+              console.log("create", resId, resType, parent);
+              console.log("value", values);
+              post<ICommentRequest, ICommentResponse>(`/v2/discussion`, {
+                res_id: resId,
+                res_type: resType,
+                parent: parent,
+                title: values.title,
+                content: values.content,
+              })
+                .then((json) => {
+                  console.log("new discussion", json);
+                  if (json.ok) {
+                    formRef.current?.resetFields();
+                    if (typeof onCreated !== "undefined") {
+                      onCreated({
+                        id: json.data.id,
+                        resId: json.data.res_id,
+                        resType: json.data.res_type,
+                        user: {
+                          id: json.data.editor?.id
+                            ? json.data.editor.id
+                            : "null",
+                          nickName: json.data.editor?.nickName
+                            ? json.data.editor.nickName
+                            : "null",
+                          realName: json.data.editor?.userName
+                            ? json.data.editor.userName
+                            : "null",
+                          avatar: json.data.editor?.avatar
+                            ? json.data.editor.avatar
+                            : "null",
+                        },
+                        title: json.data.title,
+                        parent: json.data.parent,
+                        content: json.data.content,
+                        createdAt: json.data.created_at,
+                        updatedAt: json.data.updated_at,
+                      });
+                    }
+                  } else {
+                    message.error(json.message);
+                  }
+                })
+                .catch((e) => {
+                  message.error(e.message);
+                });
+            }}
+            params={{}}
+          >
+            {parent ? (
+              <></>
+            ) : (
+              <ProFormText
+                name="title"
+                label={intl.formatMessage({ id: "forms.fields.title.label" })}
+                tooltip="最长为 24 位"
+                placeholder={intl.formatMessage({
+                  id: "forms.message.title.required",
+                })}
+                rules={[{ required: true, message: "这是必填项" }]}
+              />
+            )}
+            {editor === "text" ? (
+              <ProFormTextArea
+                name="content"
+                label={intl.formatMessage({ id: "forms.fields.content.label" })}
+                placeholder={intl.formatMessage({
+                  id: "forms.fields.content.placeholder",
+                })}
+              />
+            ) : editor === "html" ? (
+              <Form.Item
+                name="content"
+                label={intl.formatMessage({ id: "forms.fields.content.label" })}
+                tooltip="可以直接粘贴屏幕截图"
+              >
+                <ReactQuill theme="snow" style={{ height: 220 }} />
+              </Form.Item>
+            ) : (
+              <></>
+            )}
+          </ProForm>
+        </div>
+      </div>
+    );
+  }
 };
 
 export default Widget;

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

@@ -8,6 +8,7 @@ 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;

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

@@ -32,7 +32,7 @@ const Widget = ({ data, onSelect }: IWidget) => {
             ]}
           >
             <List.Item.Meta
-              avatar={<Avatar src="https://joeschmoe.io/api/v1/random" />}
+              avatar={<></>}
               title={
                 <span
                   onClick={(e) => {

+ 18 - 16
dashboard/src/components/comment/CommentListCard.tsx

@@ -9,9 +9,10 @@ import { IComment } from "./CommentItem";
 import CommentList from "./CommentList";
 import { IAnswerCount } from "./CommentBox";
 
+export type TResType = "article" | "channel" | "chapter" | "sentence" | "wbw";
 interface IWidget {
   resId?: string;
-  resType?: string;
+  resType?: TResType;
   topicId?: string;
   changedAnswerCount?: IAnswerCount;
   onSelect?: Function;
@@ -89,18 +90,21 @@ const Widget = ({
 
   return (
     <div>
-      <Card title="问题列表" extra={<a href="#">More</a>}>
-        <CommentList
-          onSelect={(
-            e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
-            comment: IComment
-          ) => {
-            if (typeof onSelect !== "undefined") {
-              onSelect(e, comment);
-            }
-          }}
-          data={data}
-        />
+      <Card title="问答" extra={<a href="#">More</a>}>
+        {data.length > 0 ? (
+          <CommentList
+            onSelect={(
+              e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+              comment: IComment
+            ) => {
+              if (typeof onSelect !== "undefined") {
+                onSelect(e, comment);
+              }
+            }}
+            data={data}
+          />
+        ) : undefined}
+
         {resId && resType ? (
           <CommentCreate
             resId={resId}
@@ -114,9 +118,7 @@ const Widget = ({
               setData([...data, newData]);
             }}
           />
-        ) : (
-          <></>
-        )}
+        ) : undefined}
       </Card>
     </div>
   );

+ 8 - 3
dashboard/src/components/comment/CommentTopicInfo.tsx

@@ -49,18 +49,23 @@ const Widget = ({ topicId }: IWidget) => {
   }, [topicId]);
   return (
     <div>
-      <Title editable level={1} style={{ margin: 0 }}>
+      <Title editable level={3} style={{ margin: 0 }}>
         {data?.title}
       </Title>
       <div>
         <Text type="secondary">
           <Space>
-            {" "}
-            {data?.user.nickName}{" "}
+            {data?.user.nickName}
             <TimeShow time={data?.createdAt} title="创建" />
           </Space>
         </Text>
       </div>
+      <div
+        style={{ maxWidth: 800, overflow: "auto" }}
+        dangerouslySetInnerHTML={{
+          __html: data?.content ? data?.content : "",
+        }}
+      />
     </div>
   );
 };

+ 32 - 31
dashboard/src/components/corpus/BookTree.tsx

@@ -1,49 +1,48 @@
 import { useState, useEffect } from "react";
 import { DownOutlined } from "@ant-design/icons";
 import { Layout, Space, Tree } from "antd";
-import { Select } 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";
 
 const { Text } = Typography;
 
-const { Option } = Select;
 interface IWidgetBookTree {
   root?: string;
   path?: string[];
+  onChange?: Function;
 }
-const Widget = (prop: IWidgetBookTree) => {
+const Widget = ({ root = "default", path, onChange }: IWidgetBookTree) => {
   //Library foot bar
   //const intl = useIntl(); //i18n
-  const defaultTreeData: NewTree[] = [];
-  const [treeData, setTreeData] = useState(defaultTreeData);
+  const navigate = useNavigate();
+
+  const [treeData, setTreeData] = useState<ITocTree[]>([]);
 
   useEffect(() => {
-    if (typeof prop.root !== "undefined") fetchBookTree(prop.root);
-  }, [prop.root]);
+    if (typeof root !== "undefined") fetchBookTree(root);
+  }, [root]);
 
-  type OrgTree = {
-    name: string;
-    tag: string[];
-    children: OrgTree[];
-  };
-  type NewTree = {
-    title: string;
-    key: string;
-    tag: string[];
-    children: NewTree[];
-  };
   const onSelect: TreeProps["onSelect"] = (selectedKeys, info) => {
     //let aaa: NewTree = info.node;
-    //console.log("selected", aaa.tag);
+    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);
+    }
   };
 
   function fetchBookTree(value: string) {
-    function treeMap(params: OrgTree): NewTree {
+    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)
@@ -51,14 +50,23 @@ const Widget = (prop: IWidgetBookTree) => {
           : [],
       };
     }
-    get(`/v2/palibook/${value}`).then((response) => {
-      const myJson = response as unknown as OrgTree[];
-      let newTree = myJson.map(treeMap);
+    function setPathToNode(nodes: ITocTree[], path: string[]) {
+      for (let node of nodes) {
+        node.path = [...path, node.title];
+        setPathToNode(node.children, node.path);
+      }
+    }
+    get<IPaliBookListResponse[]>(`/v2/palibook/${value}`).then((json) => {
+      let newTree: ITocTree[] = json.map(treeMap);
+      setPathToNode(newTree, []);
+      console.log("root", newTree);
       setTreeData(newTree);
     });
   }
   const handleChange = (value: string) => {
     console.log(`selected ${value}`);
+    localStorage.setItem("pali_path_root", value);
+    navigate("/palicanon/list/" + value);
     fetchBookTree(value);
   };
 
@@ -67,14 +75,7 @@ const Widget = (prop: IWidgetBookTree) => {
     <Layout>
       <Space>
         <Text>目录风格</Text>
-        <Select
-          defaultValue={prop.root}
-          loading={false}
-          onChange={handleChange}
-        >
-          <Option value="defualt">Defualt</Option>
-          <Option value="cscd">CSCD</Option>
-        </Select>
+        <TocStyleSelect style={root} onChange={handleChange} />
       </Space>
       <Tree
         showLine

+ 97 - 59
dashboard/src/components/corpus/BookTreeList.tsx

@@ -1,49 +1,88 @@
 import { Link } from "react-router-dom";
 import { useState, useEffect } from "react";
-import { List, Breadcrumb, Card, Select, Space } from "antd";
+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";
 
-const { Option } = Select;
-
+export interface IEventBookTreeOnchange {
+  path: string[];
+  tag: string[];
+}
+export interface ITocTree {
+  title: string;
+  dir: string;
+  key: string;
+  tag: string[];
+  path?: string[];
+  children: ITocTree[];
+}
+interface pathData {
+  to: string;
+  title: string;
+}
 interface IWidgetBookTreeList {
   root?: string;
   path?: string[];
   onChange?: Function;
 }
-export interface IEventBookTreeOnchange {
-  path: string[];
-  tag: string[];
-}
-const Widget = (prop: IWidgetBookTreeList) => {
-  let treeData: NewTree[] = [];
-  let currRoot = prop.root;
-  const defuaultData: NewTree[] = [];
-  const [currData, setCurrData] = useState(defuaultData);
+const Widget = ({ root, path, onChange }: IWidgetBookTreeList) => {
+  console.log("path", path);
+  let currRoot = root;
+  const [tocData, setTocData] = useState<ITocTree[]>([]);
+  const [currData, setCurrData] = useState<ITocTree[]>([]);
+  const [bookPath, setBookPath] = useState<pathData[]>([]);
 
-  const defaultPath: pathData[] = prop.path
-    ? prop.path.map((item) => {
-        return { to: item, title: item };
-      })
-    : [];
-  const [bookPath, setBookPath] = useState(defaultPath);
+  useEffect(() => {
+    const newPath: pathData[] = path
+      ? path.map((item) => {
+          return { to: item, title: item };
+        })
+      : [];
+    setBookPath(newPath);
+    //TODO 找到路径
+    const currPath = getListCurrRoot(tocData, newPath);
+    console.log("curr path", currPath);
+    setCurrData(currPath);
+  }, [path]);
 
   useEffect(() => {
-    if (prop.root) fetchBookTree(prop.root);
-  }, [prop.root]);
+    if (root) {
+      fetchBookTree(root);
+    }
+  }, [root]);
 
-  type NewTree = {
-    title: string;
-    dir: string;
-    key: string;
-    tag: string[];
-    children: NewTree[];
-  };
+  function getListCurrRoot(
+    allTocData: ITocTree[],
+    currPath: pathData[]
+  ): ITocTree[] {
+    let curr: ITocTree[];
+    if (allTocData.length > 0) {
+      curr = allTocData;
+    } else {
+      return [];
+    }
 
+    for (const itPath of currPath) {
+      let isFound = false;
+      for (const itAll of curr) {
+        if (itPath.to === itAll.dir) {
+          curr = itAll.children;
+          isFound = true;
+          break;
+        }
+      }
+      if (!isFound) {
+        return [];
+      }
+    }
+    return curr;
+  }
   function fetchBookTree(category: string) {
-    function treeMap(params: IPaliBookListResponse): NewTree {
+    function treeMap(params: IPaliBookListResponse): ITocTree {
       return {
         title: params.name,
         dir: PaliToEn(params.name),
@@ -57,23 +96,21 @@ const Widget = (prop: IWidgetBookTreeList) => {
 
     get<IPaliBookListResponse[]>(`/v2/palibook/${category}`).then((json) => {
       console.log("ajax", json);
-      treeData = json.map(treeMap);
-      setCurrData(treeData);
+      const treeData = json.map(treeMap);
+      setTocData(treeData);
+      const currPath = getListCurrRoot(treeData, bookPath);
+      console.log("curr path", currPath);
+      setCurrData(currPath);
     });
   }
 
-  interface pathData {
-    to: string;
-    title: string;
-  }
-
   function pushDir(dir: string, title: string, tag: string[]): void {
     const newPath: string =
       bookPath.length > 0 ? bookPath.slice(-1)[0].to + "-" + dir : dir;
     bookPath.push({ to: newPath, title: title });
     setBookPath(bookPath);
-    if (prop.onChange) {
-      prop.onChange({
+    if (typeof onChange !== "undefined") {
+      onChange({
         path: newPath.split("-"),
         tag: tag,
       });
@@ -88,28 +125,29 @@ const Widget = (prop: IWidgetBookTreeList) => {
   // TODO
   return (
     <>
-      <Space>
-        <Select
-          style={{ width: 90 }}
-          defaultValue={prop.root}
-          loading={false}
-          onChange={handleChange}
-        >
-          <Option value="defualt">Defualt</Option>
-          <Option value="cscd">CSCD</Option>
-        </Select>
-        <Breadcrumb>
-          {bookPath.map((item, id) => {
-            return (
-              <Breadcrumb.Item key={id}>
-                <Link to={`/palicanon/list/${currRoot}/${item.to}`}>
-                  {item.title}
-                </Link>
-              </Breadcrumb.Item>
-            );
-          })}
-        </Breadcrumb>
-      </Space>
+      <Row style={{ padding: 10 }}>
+        <Col xs={18} sm={24}>
+          <Breadcrumb>
+            <Breadcrumb.Item>
+              <Link to={`/palicanon/list/${currRoot}`}>
+                <HomeOutlined />
+              </Link>
+            </Breadcrumb.Item>
+            {bookPath.map((item, id) => {
+              return (
+                <Breadcrumb.Item key={id}>
+                  <Link to={`/palicanon/list/${currRoot}/${item.to}`}>
+                    {item.title}
+                  </Link>
+                </Breadcrumb.Item>
+              );
+            })}
+          </Breadcrumb>
+        </Col>
+        <Col xs={6} sm={0} style={{ textAlign: "right" }}>
+          <TocStyleSelect style={root} onChange={handleChange} />
+        </Col>
+      </Row>
       <Card>
         <List
           dataSource={currData}

+ 34 - 34
dashboard/src/components/corpus/BookViewer.tsx

@@ -5,44 +5,44 @@ import PaliChapterListByPara from "./PaliChapterListByPara";
 import PaliChapterHead from "./PaliChapterHead";
 import { IChapterClickEvent } from "./PaliChapterList";
 
-export interface IParagraph {
-	book: number;
-	para: number;
+export interface IChapter {
+  book: number;
+  para: number;
 }
 
-interface IWidgetBookViewer {
-	para: IParagraph;
-	onChange?: Function;
+interface IWidget {
+  chapter: IChapter;
+  onChange?: Function;
 }
-const Widget = (prop: IWidgetBookViewer) => {
-	const [para, setPara] = useState(prop.para);
-	useEffect(() => {
-		if (typeof prop.onChange !== "undefined") {
-			prop.onChange(para);
-		}
-	}, [para]);
+const Widget = ({ chapter, onChange }: IWidget) => {
+  const [currChapter, setCurrChpater] = useState(chapter);
+  useEffect(() => {
+    if (typeof onChange !== "undefined") {
+      onChange(currChapter);
+    }
+  }, [currChapter]);
 
-	useEffect(() => {
-		setPara(prop.para);
-	}, [prop.para]);
-	return (
-		<>
-			<PaliChapterHead
-				onChange={(e: IParagraph) => {
-					setPara(e);
-				}}
-				para={para}
-			/>
-			<PaliChapterChannelList para={para} />
-			<PaliChapterListByPara
-				para={para}
-				onChapterClick={(e: IChapterClickEvent) => {
-					setPara({ book: e.para.Book, para: e.para.Paragraph });
-					console.log("PaliChapterListByPara", "onchange", e);
-				}}
-			/>
-		</>
-	);
+  useEffect(() => {
+    setCurrChpater(chapter);
+  }, [chapter]);
+  return (
+    <>
+      <PaliChapterHead
+        onChange={(e: IChapter) => {
+          setCurrChpater(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);
+        }}
+      />
+    </>
+  );
 };
 
 export default Widget;

+ 31 - 20
dashboard/src/components/corpus/ChapterCard.tsx

@@ -1,13 +1,14 @@
 import { Link } from "react-router-dom";
-import { Row, Col } from "antd";
+import { Row, Col, Progress, Space } from "antd";
 import { Typography } from "antd";
 
 import TimeShow from "../general/TimeShow";
 import TocPath from "../corpus/TocPath";
 import TagArea from "../tag/TagArea";
-import type { TagNode } from "../tag/TagArea";
-import type { ChannelInfoProps } from "../api/Channel";
+import type { IChannelApiData } from "../api/Channel";
 import ChannelListItem from "../channel/ChannelListItem";
+import { IStudio } from "../auth/StudioName";
+import { ITagData } from "./ChapterTagList";
 
 const { Title, Paragraph, Text } = Typography;
 
@@ -18,8 +19,10 @@ export interface ChapterData {
   book: number;
   paragraph: number;
   summary: string;
-  tag: TagNode[];
-  channel: ChannelInfoProps;
+  tag: ITagData[];
+  channel: IChannelApiData;
+  studio: IStudio;
+  progress: number;
   createdAt: string;
   updatedAt: string;
   hit: number;
@@ -28,11 +31,12 @@ export interface ChapterData {
 
 interface IWidgetChapterCard {
   data: ChapterData;
+  onTagClick?: Function;
 }
 
-const Widget = ({ data }: IWidgetChapterCard) => {
+const Widget = ({ data, onTagClick }: IWidgetChapterCard) => {
   const path = JSON.parse(data.path);
-  const tags = data.tag;
+  console.log("path", data.path);
   return (
     <>
       <Row>
@@ -41,16 +45,18 @@ const Widget = ({ data }: IWidgetChapterCard) => {
             <Col span={16}>
               <Title level={5}>
                 <Link
-                  to={`/article/chapter/${data.book}-${data.paragraph}_${data.channel.channelId}`}
+                  to={`/article/chapter/${data.book}-${data.paragraph}_${data.channel.id}`}
                   target="_blank"
                 >
-                  {data.title}
+                  {data.title ? data.title : data.paliTitle}
                 </Link>
               </Title>
               <Text type="secondary">{data.paliTitle}</Text>
               <TocPath data={path} />
             </Col>
-            <Col span={8}>进度条</Col>
+            <Col span={8}>
+              <Progress percent={data.progress} size="small" />
+            </Col>
           </Row>
           <Row>
             <Col>
@@ -65,17 +71,22 @@ const Widget = ({ data }: IWidgetChapterCard) => {
               </Paragraph>
             </Col>
           </Row>
-          <Row>
-            <Col span={16}>
-              <TagArea data={tags} />
-            </Col>
-            <Col span={5}>
-              <ChannelListItem data={data.channel} />
-            </Col>
-            <Col span={3}>
+          <div style={{ display: "flex", justifyContent: "space-between" }}>
+            <div>
+              <TagArea
+                data={data.tag}
+                onTagClick={(tag: string) => {
+                  if (typeof onTagClick !== "undefined") {
+                    onTagClick(tag);
+                  }
+                }}
+              />
+            </div>
+            <Space>
+              <ChannelListItem channel={data.channel} studio={data.studio} />
               <TimeShow time={data.updatedAt} title="UpdatedAt" />
-            </Col>
-          </Row>
+            </Space>
+          </div>
         </Col>
       </Row>
     </>

+ 49 - 20
dashboard/src/components/corpus/ChapterFilter.tsx

@@ -1,29 +1,58 @@
-import { Layout, Row, Col } from "antd";
-import { Button } from "antd";
+import { Space, Input } from "antd";
 
 import ChapterFilterType from "./ChapterFilterType";
 import ChapterFilterLang from "./ChapterFilterLang";
 import ChapterFilterProgress from "./ChapterFilterProgress";
 
-const Widget = () => {
+const { Search } = Input;
+
+interface IWidget {
+  onTypeChange?: Function;
+  onLangChange?: Function;
+  onProgressChange?: Function;
+  onSearchChange?: Function;
+}
+const Widget = ({
+  onTypeChange,
+  onLangChange,
+  onProgressChange,
+  onSearchChange,
+}: IWidget) => {
   return (
-    <Layout>
-      <Row>
-        <Col>
-          <ChapterFilterType />
-        </Col>
-        <Col>
-          <ChapterFilterLang />
-        </Col>
-        <Col>
-          <ChapterFilterProgress />
-        </Col>
-        <Col>
-          <Button>Search</Button>
-          <Button>Reset</Button>
-        </Col>
-      </Row>
-    </Layout>
+    <Space style={{ margin: 8 }}>
+      <Search
+        placeholder="标题搜索"
+        onSearch={(value: string) => {
+          if (typeof onSearchChange !== "undefined") {
+            onSearchChange(value);
+          }
+        }}
+        style={{ width: 200 }}
+      />
+      <ChapterFilterProgress
+        onSelect={(value: string) => {
+          if (typeof onProgressChange !== "undefined") {
+            onProgressChange(value);
+          }
+        }}
+      />
+
+      <ChapterFilterType
+        onSelect={(value: string) => {
+          if (typeof onTypeChange !== "undefined") {
+            onTypeChange(value);
+          }
+        }}
+      />
+
+      <ChapterFilterLang
+        onSelect={(value: string) => {
+          if (typeof onLangChange !== "undefined") {
+            onLangChange(value);
+          }
+        }}
+      />
+    </Space>
   );
 };
 

+ 35 - 22
dashboard/src/components/corpus/ChapterFilterLang.tsx

@@ -1,29 +1,42 @@
 import { Select } from "antd";
-import React from "react";
+import { DefaultOptionType } from "antd/lib/select";
+import { useEffect, useState } from "react";
 
-const { Option } = Select;
+import { get } from "../../request";
+import { IChapterLangListResponse } from "../api/Corpus";
 
-const children: React.ReactNode[] = [];
-for (let i = 10; i < 36; i++) {
-	children.push(<Option key={i.toString(36) + i}>{i.toString(36) + i}</Option>);
+interface IWidget {
+  onSelect?: Function;
 }
-
-const handleChange = (value: string[]) => {
-	console.log(`selected ${value}`);
-};
-const Widget = () => {
-	return (
-		<Select
-			mode="multiple"
-			allowClear
-			style={{ minWidth: 100 }}
-			placeholder="Language"
-			defaultValue={[]}
-			onChange={handleChange}
-		>
-			{children}
-		</Select>
-	);
+const Widget = ({ onSelect }: IWidget) => {
+  const [lang, setLang] = useState<DefaultOptionType[]>([]);
+  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}`,
+          };
+        });
+        setLang(langs);
+      }
+    });
+  }, []);
+  return (
+    <Select
+      style={{ minWidth: 100 }}
+      placeholder="Language"
+      defaultValue={["zh"]}
+      onChange={(value: string[]) => {
+        console.log(`selected ${value}`);
+        if (typeof onSelect !== "undefined") {
+          onSelect(value);
+        }
+      }}
+      options={lang}
+    />
+  );
 };
 
 export default Widget;

+ 23 - 19
dashboard/src/components/corpus/ChapterFilterProgress.tsx

@@ -1,27 +1,31 @@
 import { Select } from "antd";
-import React from "react";
 
 const { Option } = Select;
 
-const children: React.ReactNode[] = [];
-for (let i = 10; i < 36; i++) {
-	children.push(<Option key={i.toString(36) + i}>{i.toString(36) + i}</Option>);
+interface IWidget {
+  onSelect?: Function;
 }
-
-const handleChange = (value: string[]) => {
-	console.log(`selected ${value}`);
-};
-const Widget = () => {
-	return (
-		<Select style={{ width: 100 }} placeholder="完成度" defaultValue={["90"]} onChange={handleChange}>
-			<Option key="0.9">90%</Option>
-			<Option key="0.8">80%</Option>
-			<Option key="0.7">70%</Option>
-			<Option key="0.6">60%</Option>
-			<Option key="0.5">50%</Option>
-			<Option key="0.01">1%</Option>
-		</Select>
-	);
+const Widget = ({ onSelect }: IWidget) => {
+  return (
+    <Select
+      style={{ width: 100 }}
+      placeholder="完成度"
+      defaultValue={["90"]}
+      onChange={(value: string[]) => {
+        console.log(`selected`, value);
+        if (typeof onSelect !== "undefined") {
+          onSelect(value);
+        }
+      }}
+    >
+      <Option key="0.9">90%</Option>
+      <Option key="0.8">80%</Option>
+      <Option key="0.7">70%</Option>
+      <Option key="0.6">60%</Option>
+      <Option key="0.5">50%</Option>
+      <Option key="0.01">1%</Option>
+    </Select>
+  );
 };
 
 export default Widget;

+ 36 - 15
dashboard/src/components/corpus/ChapterFilterType.tsx

@@ -1,22 +1,43 @@
 import { Select } from "antd";
-import React from "react";
+import { useIntl } from "react-intl";
 
-const { Option } = Select;
-
-const children: React.ReactNode[] = [];
-for (let i = 1; i < 5; i++) {
-	children.push(<Option key={i.toString(5) + i}>{i.toString(5) + i}</Option>);
+interface IWidget {
+  onSelect?: Function;
 }
+const Widget = ({ onSelect }: IWidget) => {
+  const intl = useIntl();
 
-const handleChange = (value: string[]) => {
-	console.log(`selected ${value}`);
-};
-const Widget = () => {
-	return (
-		<Select style={{ width: 100 }} allowClear placeholder="Type" defaultValue={[]} onChange={handleChange}>
-			{children}
-		</Select>
-	);
+  return (
+    <Select
+      style={{ minWidth: 100 }}
+      placeholder="Type"
+      defaultValue={["translation"]}
+      onChange={(value: string[]) => {
+        console.log(`selected ${value}`);
+        if (typeof onSelect !== "undefined") {
+          onSelect(value);
+        }
+      }}
+      options={[
+        {
+          value: "translation",
+          label: intl.formatMessage({ id: "channel.type.translation.label" }),
+        },
+        {
+          value: "nissaya",
+          label: intl.formatMessage({ id: "channel.type.nissaya.label" }),
+        },
+        {
+          value: "commentary",
+          label: intl.formatMessage({ id: "channel.type.commentary.label" }),
+        },
+        {
+          value: "original",
+          label: intl.formatMessage({ id: "channel.type.original.label" }),
+        },
+      ]}
+    />
+  );
 };
 
 export default Widget;

+ 119 - 47
dashboard/src/components/corpus/ChapterInChannel.tsx

@@ -1,18 +1,20 @@
-import { Col, Progress, Row, Space, Tabs } from "antd";
+import { Button, Col, List, Modal, Progress, Row, Space, Tabs } from "antd";
 import { Typography } from "antd";
 import { LikeOutlined, EyeOutlined } from "@ant-design/icons";
 
-import { ChannelInfoProps } from "../api/Channel";
+import { IChannelApiData } from "../api/Channel";
 import ChannelListItem from "../channel/ChannelListItem";
 import TimeShow from "../general/TimeShow";
 import { useIntl } from "react-intl";
 import { Link } from "react-router-dom";
+import { IStudio } from "../auth/StudioName";
+import { useState } from "react";
 
 const { Text } = Typography;
 
 export interface IChapterChannelData {
-  channel: ChannelInfoProps;
-
+  channel: IChannelApiData;
+  studio: IStudio;
   progress: number;
   hit: number;
   like: number;
@@ -22,27 +24,56 @@ interface IWidgetChapterInChannel {
   data: IChapterChannelData[];
   book: number;
   para: number;
+  channelId?: string[];
+  openTarget?: React.HTMLAttributeAnchorTarget;
 }
-const Widget = ({ data, book, para }: IWidgetChapterInChannel) => {
+const Widget = ({
+  data,
+  book,
+  para,
+  channelId,
+  openTarget = "_blank",
+}: IWidgetChapterInChannel) => {
   const intl = useIntl(); //i18n
-  function getTab(type: string): JSX.Element[] {
-    const output = data.map((item, id) => {
-      if (item.channel.channelType === type) {
-        return (
-          <div key={id}>
+  const [open, setOpen] = useState(false);
+  const ChannelList = (channels: IChapterChannelData[]): JSX.Element => {
+    return (
+      <List
+        style={{ maxWidth: 500 }}
+        itemLayout="vertical"
+        size="small"
+        dataSource={channels}
+        pagination={
+          channelId
+            ? undefined
+            : {
+                showQuickJumper: false,
+                showSizeChanger: false,
+                pageSize: 5,
+                total: channels.length,
+                position: "bottom",
+                showTotal: (total) => {
+                  return `结果: ${total}`;
+                },
+              }
+        }
+        renderItem={(item, id) => (
+          <List.Item key={id}>
             <Row>
-              <Col span={5}>
+              <Col span={12}>
                 <Link
-                  to={`/article/chapter/${book}-${para}_${item.channel.channelId}`}
-                  target="_blank"
+                  to={`/article/chapter/${book}-${para}_${item.channel.id}`}
+                  target={openTarget}
                 >
-                  <ChannelListItem data={item.channel} />
+                  <ChannelListItem
+                    channel={item.channel}
+                    studio={item.studio}
+                  />
                 </Link>
               </Col>
-              <Col span={5}>
+              <Col span={12}>
                 <Progress percent={item.progress} size="small" />
               </Col>
-              <Col span={8}></Col>
             </Row>
 
             <Text type="secondary">
@@ -53,38 +84,79 @@ const Widget = ({ data, book, para }: IWidgetChapterInChannel) => {
                 <TimeShow time={item.updatedAt} title={item.updatedAt} />
               </Space>
             </Text>
-          </div>
-        );
-      } else {
-        return <></>;
-      }
-    });
-    return output;
-  }
+          </List.Item>
+        )}
+      />
+    );
+  };
 
-  const items = [
-    {
-      label: intl.formatMessage({ id: "channel.type.translation.label" }),
-      key: "translation",
-      children: getTab("translation"),
-    },
-    {
-      label: intl.formatMessage({ id: "channel.type.nissaya.label" }),
-      key: "nissaya",
-      children: getTab("nissaya"),
-    },
-    {
-      label: intl.formatMessage({ id: "channel.type.commentary.label" }),
-      key: "commentary",
-      children: getTab("commentary"),
-    },
-    {
-      label: intl.formatMessage({ id: "channel.type.original.label" }),
-      key: "original",
-      children: getTab("original"),
-    },
-  ];
-  return <Tabs items={items} />;
+  const handleCancel = () => {
+    setOpen(false);
+  };
+
+  if (typeof channelId !== "undefined") {
+    const channelList = ChannelList(
+      data.filter((item) => channelId.includes(item.channel.id))
+    );
+    return (
+      <div>
+        <div>{channelList}</div>
+        <div>
+          <Button
+            type="link"
+            onClick={() => {
+              setOpen(true);
+            }}
+          >
+            更多
+          </Button>
+        </div>
+        <Modal
+          title="版本选择"
+          open={open}
+          onCancel={handleCancel}
+          onOk={handleCancel}
+        >
+          <div>{ChannelList(data)}</div>
+        </Modal>
+      </div>
+    );
+  } else {
+    return (
+      <Tabs
+        items={[
+          {
+            label: intl.formatMessage({ id: "channel.type.translation.label" }),
+            key: "translation",
+            children: ChannelList(
+              data.filter((item) => item.channel.type === "translation")
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "channel.type.nissaya.label" }),
+            key: "nissaya",
+            children: ChannelList(
+              data.filter((item) => item.channel.type === "nissaya")
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "channel.type.commentary.label" }),
+            key: "commentary",
+            children: ChannelList(
+              data.filter((item) => item.channel.type === "commentary")
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "channel.type.original.label" }),
+            key: "original",
+            children: ChannelList(
+              data.filter((item) => item.channel.type === "original")
+            ),
+          },
+        ]}
+      />
+    );
+  }
 };
 
 export default Widget;

+ 61 - 28
dashboard/src/components/corpus/ChapterList.tsx

@@ -7,34 +7,41 @@ import ChapterCard from "./ChapterCard";
 import type { ChapterData } from "./ChapterCard";
 import type { ChannelFilterProps } from "../channel/ChannelList";
 
-const defaultChannelFilterProps: ChannelFilterProps = {
-  chapterProgress: 0.9,
-  lang: "en",
-  channelType: "translation",
-};
-
-interface IWidgetChannelList {
+interface IWidget {
   filter?: ChannelFilterProps;
+  progress?: number;
+  lang?: string;
+  type?: string;
   tags?: string[];
+  onTagClick?: Function;
 }
 
 const Widget = ({
-  filter = defaultChannelFilterProps,
+  progress = 0.9,
+  lang = "zh",
+  type = "translation",
   tags = [],
-}: IWidgetChannelList) => {
+  onTagClick,
+}: IWidget) => {
   const [tableData, setTableData] = useState<ChapterData[]>([]);
-
+  const [total, setTotal] = useState<number>();
+  const [currPage, setCurrPage] = useState<number>(1);
   useEffect(() => {
-    console.log("useEffect");
-
-    fetchData(filter, tags);
-  }, [tags, filter]);
+    fetchData(
+      { chapterProgress: progress, lang: lang, channelType: type },
+      tags,
+      currPage
+    );
+  }, [progress, lang, type, tags, currPage]);
 
-  function fetchData(filter: ChannelFilterProps, tags: string[]) {
+  function fetchData(filter: ChannelFilterProps, tags: string[], page = 1) {
     const strTags = tags.length > 0 ? "&tags=" + tags.join() : "";
-    get<IChapterListResponse>(`/v2/progress?view=chapter${strTags}`).then(
-      (json) => {
-        console.log("chapter list ajax", json);
+    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) => {
+      console.log("chapter list ajax", json);
+      if (json.ok) {
         let newTree: ChapterData[] = json.data.rows.map(
           (item: IChapterData) => {
             return {
@@ -44,15 +51,16 @@ const Widget = ({
               book: item.book,
               paragraph: item.para,
               summary: item.summary,
-              tag: item.tags,
+              tag: item.tags.map((item) => {
+                return { title: item.name, key: item.name };
+              }),
               channel: {
-                channelName: item.channel.name,
-                channelId: item.channel_id,
-                channelType: "translation",
-                studioName: item.channel.name,
-                studioId: item.channel.owner_uid,
-                studioType: "",
+                name: item.channel.name,
+                id: item.channel_id,
+                type: "translation",
               },
+              studio: item.studio,
+              progress: Math.round(item.progress * 100),
               createdAt: item.created_at,
               updatedAt: item.updated_at,
               hit: item.view,
@@ -61,19 +69,44 @@ const Widget = ({
             };
           }
         );
+        setTotal(json.data.count);
         setTableData(newTree);
+      } else {
+        setTotal(0);
+        setTableData([]);
       }
-    );
+    });
   }
 
   return (
     <List
       itemLayout="vertical"
-      size="large"
+      size="small"
       dataSource={tableData}
+      pagination={{
+        onChange: (page) => {
+          console.log(page);
+          setCurrPage(page);
+        },
+        showQuickJumper: true,
+        showSizeChanger: false,
+        pageSize: 20,
+        total: total,
+        position: "both",
+        showTotal: (total) => {
+          return `结果: ${total}`;
+        },
+      }}
       renderItem={(item) => (
         <List.Item>
-          <ChapterCard data={item} />
+          <ChapterCard
+            data={item}
+            onTagClick={(tag: string) => {
+              if (typeof onTagClick !== "undefined") {
+                onTagClick(tag);
+              }
+            }}
+          />
         </List.Item>
       )}
     />

+ 13 - 11
dashboard/src/components/corpus/ChapterTagList.tsx

@@ -1,20 +1,21 @@
-import { message, Tag, Button } from "antd";
+import { message, Tag } from "antd";
 import { useState, useEffect } from "react";
 
 import { get } from "../../request";
 import { IApiChapterTag, IApiResponseChapterTagList } from "../api/Corpus";
 
-interface ITagData {
+export interface ITagData {
   title: string;
   key: string;
+  color?: string;
+  description?: string;
 }
-interface IWidgetChapterTagList {
+interface IWidget {
   max?: number;
-  onTagClick: Function;
+  onTagClick?: Function;
 }
-const Widget = (prop: IWidgetChapterTagList) => {
-  const defaultData: ITagData[] = [];
-  const [tableData, setTableData] = useState(defaultData);
+const Widget = ({ max, onTagClick }: IWidget) => {
+  const [tableData, setTableData] = useState<ITagData[]>([]);
 
   useEffect(() => {
     console.log("useEffect");
@@ -38,7 +39,7 @@ const Widget = (prop: IWidgetChapterTagList) => {
         message.error(error);
       });
   }
-  let iTag = prop.max ? prop.max : tableData.length;
+  let iTag = max ? max : tableData.length;
   if (iTag > tableData.length) {
     iTag = tableData.length;
   }
@@ -48,13 +49,14 @@ const Widget = (prop: IWidgetChapterTagList) => {
         return (
           <Tag
             key={id}
+            style={{ cursor: "pointer" }}
             onClick={() => {
-              if (typeof prop.onTagClick !== "undefined") {
-                prop.onTagClick(item.key);
+              if (typeof onTagClick !== "undefined") {
+                onTagClick(item.key);
               }
             }}
           >
-            <Button type="link">{item.title}</Button>
+            {item.title}
           </Tag>
         );
       })}

+ 17 - 9
dashboard/src/components/corpus/PaliChapterCard.tsx

@@ -1,5 +1,6 @@
 import { Row, Col } from "antd";
 import { Typography } from "antd";
+import { API_HOST } from "../../request";
 
 import TocPath from "./TocPath";
 
@@ -8,23 +9,24 @@ const { Title, Link } = Typography;
 export interface IPaliChapterData {
   Title: string;
   PaliTitle: string;
+  level: number;
   Path: string;
   Book: number;
   Paragraph: number;
 }
 
-interface IWidgetPaliChapterCard {
+interface IWidget {
   data: IPaliChapterData;
   onTitleClick?: Function;
 }
 
-const Widget = (prop: IWidgetPaliChapterCard) => {
-  const path = JSON.parse(prop.data.Path);
+const Widget = ({ data, onTitleClick }: IWidget) => {
+  const path = JSON.parse(data.Path);
 
   return (
     <>
       <Row>
-        <Col span={3}>封面</Col>
+        <Col span={3}></Col>
         <Col span={21}>
           <Row>
             <Col span={16}>
@@ -33,17 +35,17 @@ const Widget = (prop: IWidgetPaliChapterCard) => {
                   <Title
                     level={5}
                     onClick={(e) => {
-                      if (typeof prop.onTitleClick !== "undefined") {
-                        prop.onTitleClick(e);
+                      if (typeof onTitleClick !== "undefined") {
+                        onTitleClick(e);
                       }
                     }}
                   >
-                    <Link>{prop.data.Title}</Link>
+                    <Link>{data.Title}</Link>
                   </Title>
                 </Col>
               </Row>
               <Row>
-                <Col>{prop.data.PaliTitle}</Col>
+                <Col>{data.PaliTitle}</Col>
               </Row>
               <Row>
                 <Col>
@@ -51,7 +53,13 @@ const Widget = (prop: IWidgetPaliChapterCard) => {
                 </Col>
               </Row>
             </Col>
-            <Col span={8}>进度条</Col>
+            <Col span={8}>
+              <img
+                src={`${API_HOST}/storage/images/chapter_dynamic/${data.Book}/${data.Paragraph}/globle.svg`}
+                width={200}
+                alt="章节动态"
+              />
+            </Col>
           </Row>
           <Row>
             <Col></Col>

+ 21 - 18
dashboard/src/components/corpus/PaliChapterChannelList.tsx

@@ -2,32 +2,29 @@ import { useState, useEffect } from "react";
 
 import { get } from "../../request";
 import { IApiResponseChapterChannelList } from "../api/Corpus";
-import { IParagraph } from "./BookViewer";
+import { IChapter } from "./BookViewer";
 import ChapterInChannel, { IChapterChannelData } from "./ChapterInChannel";
 
-interface IWidgetPaliChapterChannelList {
-  para: IParagraph;
+interface IWidget {
+  para: IChapter;
+  channelId?: string[];
+  openTarget?: React.HTMLAttributeAnchorTarget;
 }
-const defaultData: IChapterChannelData[] = [];
-const Widget = ({ para }: IWidgetPaliChapterChannelList) => {
-  const [tableData, setTableData] = useState(defaultData);
+
+const Widget = ({ para, channelId, openTarget = "_blank" }: IWidget) => {
+  const [tableData, setTableData] = useState<IChapterChannelData[]>([]);
 
   useEffect(() => {
-    console.log("palichapterlist useEffect");
     let url = `/v2/progress?view=chapter_channels&book=${para.book}&par=${para.para}`;
-    get(url).then(function (myJson) {
-      console.log("ajex", myJson);
-      const data = myJson as unknown as IApiResponseChapterChannelList;
-      const newData: IChapterChannelData[] = data.data.rows.map((item) => {
+    get<IApiResponseChapterChannelList>(url).then(function (json) {
+      const newData: IChapterChannelData[] = json.data.rows.map((item) => {
         return {
           channel: {
-            channelName: item.channel.name,
-            channelId: item.channel.uid,
-            channelType: item.channel.type,
-            studioName: "V",
-            studioId: "123",
-            studioType: "p",
+            name: item.channel.name,
+            id: item.channel.uid,
+            type: item.channel.type,
           },
+          studio: item.studio,
           progress: Math.ceil(item.progress * 100),
           hit: item.views,
           like: 0,
@@ -40,7 +37,13 @@ const Widget = ({ para }: IWidgetPaliChapterChannelList) => {
 
   return (
     <>
-      <ChapterInChannel data={tableData} book={para.book} para={para.para} />
+      <ChapterInChannel
+        data={tableData}
+        book={para.book}
+        para={para.para}
+        channelId={channelId}
+        openTarget={openTarget}
+      />
     </>
   );
 };

+ 11 - 20
dashboard/src/components/corpus/PaliChapterHead.tsx

@@ -4,32 +4,23 @@ import { message } from "antd";
 import { IApiResponsePaliChapter } from "../api/Corpus";
 import { get } from "../../request";
 import ChapterHead, { IChapterInfo } from "./ChapterHead";
-import { IParagraph } from "./BookViewer";
+import { IChapter } from "./BookViewer";
 import TocPath, { ITocPathNode } from "./TocPath";
 
-interface IWidgetPaliChapterHead {
-  para: IParagraph;
+interface IWidget {
+  para: IChapter;
   onChange?: Function;
 }
 
-const Widget = (prop: IWidgetPaliChapterHead) => {
-  const defaultPathData: ITocPathNode[] = [
-    {
-      book: 98,
-      paragraph: 55,
-      title: "string;",
-      paliTitle: "string;",
-      level: 2,
-    },
-  ];
-  const [pathData, setPathData] = useState(defaultPathData);
+const Widget = ({ para, onChange }: IWidget) => {
+  const [pathData, setPathData] = useState<ITocPathNode[]>([]);
   const [chapterData, setChapterData] = useState<IChapterInfo>({ title: "" });
   useEffect(() => {
     console.log("palichapterlist useEffect");
-    fetchData(prop.para);
-  }, [prop.para]);
+    fetchData(para);
+  }, [para]);
 
-  function fetchData(para: IParagraph) {
+  function fetchData(para: IChapter) {
     let url = `/v2/palitext?view=paragraph&book=${para.book}&para=${para.para}`;
     get<IApiResponsePaliChapter>(url).then(function (myJson) {
       console.log("ajex", myJson);
@@ -56,11 +47,11 @@ const Widget = (prop: IWidgetPaliChapterHead) => {
     <>
       <TocPath
         data={pathData}
-        onChange={(e: IParagraph) => {
+        onChange={(e: IChapter) => {
           message.success(e.book + ":" + e.para);
           fetchData(e);
-          if (typeof prop.onChange !== "undefined") {
-            prop.onChange(e);
+          if (typeof onChange !== "undefined") {
+            onChange(e);
           }
         }}
         link={"none"}

+ 33 - 22
dashboard/src/components/corpus/PaliChapterList.tsx

@@ -2,36 +2,47 @@ import { List } from "antd";
 
 import PaliChapterCard, { IPaliChapterData } from "./PaliChapterCard";
 
-interface IWidgetPaliChapterList {
-  data: IPaliChapterData[];
-  onChapterClick?: Function;
-}
-
 export interface IChapterClickEvent {
   para: IPaliChapterData;
   event: React.MouseEvent<HTMLDivElement, MouseEvent>;
 }
-const Widget = (prop: IWidgetPaliChapterList) => {
+
+interface IWidgetPaliChapterList {
+  data: IPaliChapterData[];
+  maxLevel?: number;
+  onChapterClick?: Function;
+}
+const Widget = ({
+  data,
+  maxLevel = 8,
+  onChapterClick,
+}: IWidgetPaliChapterList) => {
   return (
     <List
       itemLayout="vertical"
       size="large"
-      dataSource={prop.data}
-      renderItem={(item) => (
-        <List.Item>
-          <PaliChapterCard
-            onTitleClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
-              if (typeof prop.onChapterClick !== "undefined") {
-                prop.onChapterClick({
-                  para: item,
-                  event: e,
-                });
-              }
-            }}
-            data={item}
-          />
-        </List.Item>
-      )}
+      dataSource={data}
+      renderItem={(item) =>
+        item.level <= maxLevel ? (
+          <List.Item>
+            <PaliChapterCard
+              onTitleClick={(
+                e: React.MouseEvent<HTMLDivElement, MouseEvent>
+              ) => {
+                if (typeof onChapterClick !== "undefined") {
+                  onChapterClick({
+                    para: item,
+                    event: e,
+                  });
+                }
+              }}
+              data={item}
+            />
+          </List.Item>
+        ) : (
+          <></>
+        )
+      }
     />
   );
 };

+ 13 - 14
dashboard/src/components/corpus/PaliChapterListByPara.tsx

@@ -2,28 +2,27 @@ import { useState, useEffect } from "react";
 
 import { get } from "../../request";
 import { IApiResponsePaliChapterList } from "../api/Corpus";
-import { IParagraph } from "./BookViewer";
+import { IChapter } from "./BookViewer";
 import { IPaliChapterData } from "./PaliChapterCard";
 import PaliChapterList, { IChapterClickEvent } from "./PaliChapterList";
 
-interface IWidgetPaliChapterListByPara {
-  para: IParagraph;
+interface IWidget {
+  chapter: IChapter;
   onChapterClick?: Function;
 }
-const defaultData: IPaliChapterData[] = [];
-const Widget = (prop: IWidgetPaliChapterListByPara) => {
-  const [tableData, setTableData] = useState(defaultData);
+const Widget = ({ chapter, onChapterClick }: IWidget) => {
+  const [tableData, setTableData] = useState<IPaliChapterData[]>([]);
 
   useEffect(() => {
     console.log("palichapterlist useEffect");
-    let url = `/v2/palitext?view=chapter_children&book=${prop.para.book}&para=${prop.para.para}`;
-    get(url).then(function (myJson) {
-      console.log("ajex", myJson);
-      const data = myJson as unknown as IApiResponsePaliChapterList;
-      let newTree: IPaliChapterData[] = data.data.rows.map((item) => {
+    let url = `/v2/palitext?view=chapter_children&book=${chapter.book}&para=${chapter.para}`;
+    get<IApiResponsePaliChapterList>(url).then(function (json) {
+      console.log("chapter ajex", json);
+      const newTree: IPaliChapterData[] = json.data.rows.map((item) => {
         return {
           Title: item.toc,
           PaliTitle: item.toc,
+          level: item.level,
           Path: item.path,
           Book: item.book,
           Paragraph: item.paragraph,
@@ -31,14 +30,14 @@ const Widget = (prop: IWidgetPaliChapterListByPara) => {
       });
       setTableData(newTree);
     });
-  }, [prop.para]);
+  }, [chapter]);
 
   return (
     <>
       <PaliChapterList
         onChapterClick={(e: IChapterClickEvent) => {
-          if (prop.onChapterClick) {
-            prop.onChapterClick(e);
+          if (onChapterClick) {
+            onChapterClick(e);
           }
         }}
         data={tableData}

+ 2 - 0
dashboard/src/components/corpus/PaliChapterListByTag.tsx

@@ -24,6 +24,7 @@ const Widget = (prop: IWidgetPaliChapterListByTag) => {
         return {
           Title: item.title,
           PaliTitle: item.title,
+          level: item.level,
           Path: item.path,
           Book: item.book,
           Paragraph: item.paragraph,
@@ -37,6 +38,7 @@ const Widget = (prop: IWidgetPaliChapterListByTag) => {
     <>
       <PaliChapterList
         data={tableData}
+        maxLevel={1}
         onChapterClick={(e: IChapterClickEvent) => {
           if (typeof prop.onChapterClick !== "undefined") {
             prop.onChapterClick(e);

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

@@ -45,7 +45,7 @@ const Widget = ({
         );
         break;
       case "self":
-        oneItem = <Link to={linkChapter}>{item.title}</Link>;
+        oneItem = <Link to={linkChapter}>{title}</Link>;
         break;
     }
     return (

+ 26 - 0
dashboard/src/components/corpus/TocStyleSelect.tsx

@@ -0,0 +1,26 @@
+import { Select } from "antd";
+const { Option } = Select;
+
+interface IWidget {
+  style?: string;
+  onChange?: Function;
+}
+const Widget = ({ style = "default", onChange }: IWidget) => {
+  return (
+    <Select
+      defaultValue={style}
+      style={{ width: 90 }}
+      loading={false}
+      onChange={(value: string) => {
+        if (typeof onChange !== "undefined") {
+          onChange(value);
+        }
+      }}
+    >
+      <Option value="default">Default</Option>
+      <Option value="cscd">CSCD</Option>
+    </Select>
+  );
+};
+
+export default Widget;

+ 38 - 4
dashboard/src/components/course/CourseShow.tsx → dashboard/src/components/course/CourseHead.tsx

@@ -1,22 +1,42 @@
 //课程详情图片标题按钮主讲人组合
 import { Link } from "react-router-dom";
-import { Image, Button, Space, Col, Row, Breadcrumb } from "antd";
+import { Image, Space, Col, Row, Breadcrumb } from "antd";
 import { Typography } from "antd";
 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";
 
-const { Title } = Typography;
+const { Title, Text } = Typography;
 
 interface IWidget {
+  id?: string;
   title?: string;
   subtitle?: string;
   coverUrl?: string;
+  startAt?: string;
+  endAt?: string;
   teacher?: IUser;
+  join?: TCourseJoinMode;
+  exp?: TCourseExpRequest;
 }
-const Widget = ({ title, subtitle, coverUrl, teacher }: IWidget) => {
+const Widget = ({
+  id,
+  title,
+  subtitle,
+  coverUrl,
+  teacher,
+  startAt,
+  endAt,
+  join,
+  exp,
+}: IWidget) => {
+  const intl = useIntl();
+
   return (
     <>
       <Row>
@@ -42,7 +62,21 @@ const Widget = ({ title, subtitle, coverUrl, teacher }: IWidget) => {
               <Space direction="vertical">
                 <Title level={3}>{title}</Title>
                 <Title level={5}>{subtitle}</Title>
-                <Button type="primary">关注</Button>
+
+                <Text>
+                  {startAt}——{endAt}
+                </Text>
+                <Text>
+                  {intl.formatMessage({
+                    id: `course.join.mode.${join}.message`,
+                  })}
+                </Text>
+                <JoinCourse
+                  courseId={id ? id : ""}
+                  expRequest={exp}
+                  joinMode={join}
+                  startAt={startAt}
+                />
               </Space>
             </Space>
             <div>

+ 406 - 0
dashboard/src/components/course/CourseInfoEdit.tsx

@@ -0,0 +1,406 @@
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormText,
+  ProFormDateRangePicker,
+  ProFormSelect,
+  ProFormUploadButton,
+  RequestOptionsType,
+  ProFormDependency,
+} from "@ant-design/pro-components";
+
+import { message, Form } from "antd";
+
+import { API_HOST, get, put } from "../../request";
+import {
+  ICourseDataRequest,
+  ICourseDataResponse,
+  ICourseResponse,
+} from "../../components/api/Course";
+import PublicitySelect from "../../components/studio/PublicitySelect";
+
+import { IUserListResponse } from "../../components/api/Auth";
+import MDEditor from "@uiw/react-md-editor";
+import { DefaultOptionType } from "antd/lib/select";
+import { UploadFile } from "antd/es/upload/interface";
+import { IAttachmentResponse } from "../../components/api/Attachments";
+
+import { IAnthologyListResponse } from "../../components/api/Article";
+import { IApiResponseChannelList } from "../../components/api/Channel";
+
+interface IFormData {
+  title: string;
+  subtitle: string;
+  summary?: string;
+  content?: string;
+  cover?: UploadFile<IAttachmentResponse>[];
+  teacherId?: string;
+  anthologyId?: string;
+  channelId?: string;
+  dateRange?: Date[];
+  status: number;
+  join: string;
+  exp: string;
+}
+
+interface IWidget {
+  studioName?: string;
+  courseId?: string;
+  onTitleChange?: Function;
+}
+const Widget = ({ studioName, courseId, onTitleChange }: IWidget) => {
+  const intl = useIntl();
+  const [teacherOption, setTeacherOption] = useState<DefaultOptionType[]>([]);
+  const [currTeacher, setCurrTeacher] = useState<RequestOptionsType>();
+  const [textbookOption, setTextbookOption] = useState<DefaultOptionType[]>([]);
+  const [currTextbook, setCurrTextbook] = useState<RequestOptionsType>();
+  const [channelOption, setChannelOption] = useState<DefaultOptionType[]>([]);
+  const [currChannel, setCurrChannel] = useState<RequestOptionsType>();
+  const [courseData, setCourseData] = useState<ICourseDataResponse>();
+
+  return (
+    <div>
+      <ProForm<IFormData>
+        formKey="course_edit"
+        onFinish={async (values: IFormData) => {
+          console.log("all data", values);
+          let startAt: string, endAt: string;
+          let _cover: string = "";
+          if (typeof values.dateRange === "undefined") {
+            startAt = "";
+            endAt = "";
+          } else if (
+            typeof values.dateRange[0] === "string" &&
+            typeof values.dateRange[1] === "string"
+          ) {
+            startAt = values.dateRange[0];
+            endAt = values.dateRange[1];
+          } else {
+            startAt = courseData ? courseData.start_at : "";
+            endAt = courseData ? courseData.end_at : "";
+          }
+
+          if (
+            typeof values.cover === "undefined" ||
+            values.cover.length === 0
+          ) {
+            _cover = "";
+          } else if (typeof values.cover[0].response === "undefined") {
+            _cover = values.cover[0].uid;
+          } else {
+            _cover = values.cover[0].response.data.url;
+          }
+
+          const res = await put<ICourseDataRequest, ICourseResponse>(
+            `/v2/course/${courseId}`,
+            {
+              title: values.title, //标题
+              subtitle: values.subtitle, //副标题
+              summary: values.summary,
+              content: values.content, //简介
+              cover: _cover, //封面图片文件名
+              teacher_id: values.teacherId, //UserID
+              publicity: values.status, //类型-公开/内部
+              anthology_id: values.anthologyId, //文集ID
+              channel_id: values.channelId,
+              start_at: startAt, //课程开始时间
+              end_at: endAt, //课程结束时间
+              join: values.join,
+              request_exp: values.exp,
+            }
+          );
+          console.log(res);
+          if (res.ok) {
+            message.success(intl.formatMessage({ id: "flashes.success" }));
+          } else {
+            message.error(res.message);
+          }
+        }}
+        request={async () => {
+          const res = await get<ICourseResponse>(`/v2/course/${courseId}`);
+          console.log("course data", res.data);
+          setCourseData(res.data);
+          if (typeof onTitleChange !== "undefined") {
+            onTitleChange(res.data.title);
+          }
+          console.log(res.data);
+          if (res.data.teacher) {
+            console.log("teacher", res.data.teacher);
+            const teacher = {
+              value: res.data.teacher.id,
+              label: res.data.teacher.nickName,
+            };
+            setCurrTeacher(teacher);
+            setTeacherOption([teacher]);
+            const textbook = {
+              value: res.data.anthology_id,
+              label:
+                res.data.anthology_owner?.nickName +
+                "/" +
+                res.data.anthology_title,
+            };
+            setCurrTextbook(textbook);
+            setTextbookOption([textbook]);
+            const channel = {
+              value: res.data.channel_id,
+              label:
+                res.data.channel_owner?.nickName + "/" + res.data.channel_name,
+            };
+            setCurrChannel(channel);
+            setChannelOption([channel]);
+          }
+          return {
+            title: res.data.title,
+            subtitle: res.data.subtitle,
+            summary: res.data.summary,
+            content: res.data.content,
+            cover: res.data.cover
+              ? [
+                  {
+                    uid: res.data.cover,
+                    name: "cover",
+                    thumbUrl: API_HOST + "/" + res.data.cover,
+                  },
+                ]
+              : [],
+            teacherId: res.data.teacher?.id,
+            anthologyId: res.data.anthology_id,
+            channelId: res.data.channel_id,
+            dateRange:
+              res.data.start_at && res.data.end_at
+                ? [new Date(res.data.start_at), new Date(res.data.end_at)]
+                : undefined,
+            status: res.data.publicity,
+            join: res.data.join,
+            exp: res.data.request_exp,
+          };
+        }}
+      >
+        <ProForm.Group>
+          <ProFormUploadButton
+            name="cover"
+            label="封面"
+            max={1}
+            fieldProps={{
+              name: "file",
+              listType: "picture-card",
+              className: "avatar-uploader",
+            }}
+            action={`${API_HOST}/api/v2/attachments`}
+            extra="封面必须为正方形。最大512*512"
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormText
+            width="md"
+            name="title"
+            required
+            label={intl.formatMessage({
+              id: "forms.fields.title.label",
+            })}
+            rules={[
+              {
+                required: true,
+              },
+            ]}
+          />
+          <ProFormText
+            width="md"
+            name="subtitle"
+            label={intl.formatMessage({
+              id: "forms.fields.subtitle.label",
+            })}
+          />
+        </ProForm.Group>
+
+        <ProForm.Group>
+          <ProFormSelect
+            options={teacherOption}
+            width="md"
+            name="teacherId"
+            label={intl.formatMessage({ id: "forms.fields.teacher.label" })}
+            showSearch
+            debounceTime={300}
+            request={async ({ keyWords }) => {
+              console.log("keyWord", keyWords);
+              if (typeof keyWords === "undefined") {
+                return currTeacher ? [currTeacher] : [];
+              }
+              const json = await get<IUserListResponse>(
+                `/v2/user?view=key&key=${keyWords}`
+              );
+              const userList = json.data.rows.map((item) => {
+                return {
+                  value: item.id,
+                  label: `${item.userName}-${item.nickName}`,
+                };
+              });
+              console.log("json", userList);
+              return userList;
+            }}
+            placeholder={intl.formatMessage({
+              id: "forms.fields.teacher.label",
+            })}
+          />
+          <ProFormDateRangePicker
+            width="md"
+            name="dateRange"
+            label="课程区间"
+          />
+        </ProForm.Group>
+
+        <ProForm.Group>
+          <ProFormSelect
+            options={textbookOption}
+            width="md"
+            name="anthologyId"
+            label={intl.formatMessage({ id: "forms.fields.textbook.label" })}
+            showSearch
+            debounceTime={300}
+            request={async ({ keyWords }) => {
+              console.log("keyWord", keyWords);
+              if (typeof keyWords === "undefined") {
+                return currTextbook ? [currTextbook] : [];
+              }
+              const json = await get<IAnthologyListResponse>(
+                `/v2/anthology?view=public`
+              );
+              const textbookList = json.data.rows.map((item) => {
+                return {
+                  value: item.uid,
+                  label: `${item.studio.nickName}/${item.title}`,
+                };
+              });
+              console.log("json", textbookList);
+              return textbookList;
+            }}
+          />
+          <ProFormSelect
+            options={channelOption}
+            width="md"
+            name="channelId"
+            label={"标准答案"}
+            showSearch
+            debounceTime={300}
+            request={async ({ keyWords }) => {
+              console.log("keyWord", keyWords);
+              if (typeof keyWords === "undefined") {
+                return currChannel ? [currChannel] : [];
+              }
+              const json = await get<IApiResponseChannelList>(
+                `/v2/channel?view=studio&name=${studioName}`
+              );
+              const textbookList = json.data.rows.map((item) => {
+                return {
+                  value: item.uid,
+                  label: `${item.studio.nickName}/${item.name}`,
+                };
+              });
+              console.log("json", textbookList);
+              return textbookList;
+            }}
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <PublicitySelect width="md" />
+          <ProFormDependency name={["status"]}>
+            {({ status }) => {
+              const option = [
+                {
+                  value: "invite",
+                  label: intl.formatMessage({
+                    id: "course.join.mode.invite.label",
+                  }),
+                  disabled: false,
+                },
+                {
+                  value: "manual",
+                  label: intl.formatMessage({
+                    id: "course.join.mode.manual.label",
+                  }),
+                  disabled: false,
+                },
+                {
+                  value: "open",
+                  label: intl.formatMessage({
+                    id: "course.join.mode.open.label",
+                  }),
+                  disabled: false,
+                },
+              ];
+              if (status === 10) {
+                option[1].disabled = true;
+                option[2].disabled = true;
+              } else {
+                option[0].disabled = true;
+              }
+              return (
+                <ProFormSelect
+                  options={option}
+                  width="md"
+                  name="join"
+                  allowClear={false}
+                  label="录取方式"
+                />
+              );
+            }}
+          </ProFormDependency>
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormDependency name={["join"]}>
+            {({ join }) => {
+              const option = [
+                {
+                  value: "none",
+                  label: intl.formatMessage({
+                    id: "course.exp.request.none.label",
+                  }),
+                  disabled: false,
+                },
+                {
+                  value: "begin-end",
+                  label: intl.formatMessage({
+                    id: "course.exp.request.begin-end.label",
+                  }),
+                  disabled: false,
+                },
+                {
+                  value: "daily",
+                  label: intl.formatMessage({
+                    id: "course.exp.request.daily.label",
+                  }),
+                  disabled: false,
+                },
+              ];
+              if (join === "open") {
+                option[1].disabled = true;
+                option[2].disabled = true;
+              }
+              return (
+                <ProFormSelect
+                  tooltip="要求查看经验值,需要学生同意才会生效。"
+                  options={option}
+                  width="md"
+                  name="exp"
+                  label="查看学生经验值"
+                  allowClear={false}
+                />
+              );
+            }}
+          </ProFormDependency>
+        </ProForm.Group>
+        <ProForm.Group>
+          <Form.Item
+            name="content"
+            label={intl.formatMessage({ id: "forms.fields.content.label" })}
+          >
+            <MDEditor />
+          </Form.Item>
+        </ProForm.Group>
+      </ProForm>
+    </div>
+  );
+};
+
+export default Widget;

+ 15 - 6
dashboard/src/components/course/CourseMember.tsx

@@ -9,6 +9,7 @@ import { delete_, get } from "../../request";
 import {
   ICourseMemberDeleteResponse,
   ICourseMemberListResponse,
+  TCourseMemberStatus,
 } from "../api/Course";
 
 const { Content } = Layout;
@@ -17,12 +18,20 @@ interface IRoleTag {
   title: string;
   color: string;
 }
-interface DataItem {
-  id: number;
+
+export interface ICourseMember {
+  sn?: number;
+  id?: string;
   userId: string;
   name?: string;
   tag: IRoleTag[];
   image: string;
+  role?: string;
+  startExp?: number;
+  endExp?: number;
+  currentExp?: number;
+  expByDay?: number;
+  status?: TCourseMemberStatus;
 }
 interface IWidget {
   courseId?: string;
@@ -35,7 +44,7 @@ const Widget = ({ courseId }: IWidget) => {
   const ref = useRef<ActionType>();
   return (
     <Content>
-      <ProList<DataItem>
+      <ProList<ICourseMember>
         rowKey="id"
         actionRef={ref}
         headerTitle={
@@ -82,9 +91,9 @@ const Widget = ({ courseId }: IWidget) => {
                 setCanDelete(true);
                 break;
             }
-            const items: DataItem[] = res.data.rows.map((item, id) => {
-              let member: DataItem = {
-                id: item.id ? item.id : 0,
+            const items: ICourseMember[] = res.data.rows.map((item, id) => {
+              let member: ICourseMember = {
+                id: item.id ? item.id : "",
                 userId: item.user_id,
                 name: item.user?.nickName,
                 tag: [],

+ 406 - 0
dashboard/src/components/course/CourseMemberList.tsx

@@ -0,0 +1,406 @@
+import { useIntl } from "react-intl";
+
+import { Space, Button, Dropdown, Table, Modal } from "antd";
+import { ActionType, ProTable } from "@ant-design/pro-components";
+import {
+  DeleteOutlined,
+  BarChartOutlined,
+  ExclamationCircleFilled,
+} from "@ant-design/icons";
+
+import { delete_, get, put } from "../../request";
+import { ICourseMember } from "./CourseMember";
+import AddMember from "./AddMember";
+import { useRef, useState } from "react";
+import {
+  ICourseMemberData,
+  ICourseMemberDeleteResponse,
+  ICourseMemberListResponse,
+  ICourseMemberResponse,
+  TCourseMemberStatus,
+} from "../api/Course";
+import { ItemType } from "antd/lib/menu/hooks/useItems";
+const { confirm } = Modal;
+
+interface IWidget {
+  studioName?: string;
+  courseId?: string;
+}
+
+const Widget = ({ studioName, courseId }: IWidget) => {
+  const intl = useIntl(); //i18n
+  const [canDelete, setCanDelete] = useState(false);
+  const ref = useRef<ActionType>();
+
+  const ChangeStatus = (
+    id: string,
+    name: string,
+    status: TCourseMemberStatus
+  ) => {
+    confirm({
+      title: (
+        <div>
+          <div>
+            {intl.formatMessage({
+              id: `course.member.status.${status}.message`,
+            })}
+          </div>
+          <div>{name}</div>
+        </div>
+      ),
+      icon: <ExclamationCircleFilled />,
+      onOk() {
+        return put<ICourseMemberData, ICourseMemberResponse>(
+          "/v2/course-member/" + id,
+          {
+            course_id: "",
+            user_id: "",
+            status: status,
+          }
+        )
+          .then((json) => {
+            if (json.ok) {
+              console.log("delete ok");
+              ref.current?.reload();
+            }
+          })
+          .catch(() => console.log("Oops errors!"));
+      },
+    });
+  };
+  return (
+    <>
+      <ProTable<ICourseMember>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.name.label",
+            }),
+            dataIndex: "name",
+            key: "name",
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.role.label",
+            }),
+            dataIndex: "role",
+            key: "role",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: {
+                text: intl.formatMessage({
+                  id: "tables.publicity.all",
+                }),
+                status: "Default",
+              },
+              student: {
+                text: intl.formatMessage({
+                  id: "auth.role.student",
+                }),
+                status: "Default",
+              },
+              assistant: {
+                text: intl.formatMessage({
+                  id: "auth.role.assistant",
+                }),
+                status: "Success",
+              },
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.status.label",
+            }),
+            dataIndex: "status",
+            key: "status",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: {
+                text: intl.formatMessage({
+                  id: "tables.publicity.all",
+                }),
+                status: "Default",
+              },
+              progressing: {
+                text: intl.formatMessage({
+                  id: "course.member.status.progressing.label",
+                }),
+                status: "Processing",
+              },
+              accepted: {
+                text: intl.formatMessage({
+                  id: "course.member.status.accepted.label",
+                }),
+                status: "success",
+              },
+              rejected: {
+                text: intl.formatMessage({
+                  id: "course.member.status.rejected.label",
+                }),
+                status: "warning",
+              },
+              left: {
+                text: intl.formatMessage({
+                  id: "course.member.status.left.label",
+                }),
+                status: "warning",
+              },
+              blocked: {
+                text: intl.formatMessage({
+                  id: "course.member.status.blocked.label",
+                }),
+                status: "warning",
+              },
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "course.exp.start.label",
+            }),
+            dataIndex: "startExp",
+            key: "startExp",
+          },
+          {
+            title: intl.formatMessage({
+              id: "course.exp.current.label",
+            }),
+            dataIndex: "currentExp",
+            key: "currentExp",
+          },
+          {
+            title: intl.formatMessage({
+              id: "course.exp.end.label",
+            }),
+            dataIndex: "endExp",
+            key: "endExp",
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (text, row, index, action) => {
+              let items: ItemType[] = [];
+              switch (row.status) {
+                case "accepted":
+                  items = [
+                    {
+                      key: "exp",
+                      label: "经验值",
+                      icon: <BarChartOutlined />,
+                    },
+                    {
+                      key: "delete",
+                      label: "删除",
+                      icon: <DeleteOutlined />,
+                    },
+                  ];
+                  break;
+                case "progressing":
+                  items = [
+                    {
+                      key: "accept",
+                      label: "接受",
+                      icon: <BarChartOutlined />,
+                    },
+                    {
+                      key: "reject",
+                      label: "拒绝",
+                      icon: <DeleteOutlined />,
+                    },
+                  ];
+                  break;
+                default:
+                  break;
+              }
+
+              return [
+                canDelete ? (
+                  <Dropdown.Button
+                    key={index}
+                    type="link"
+                    menu={{
+                      items,
+                      onClick: (e) => {
+                        console.log("click", e);
+                        switch (e.key) {
+                          case "exp":
+                            break;
+                          case "delete":
+                            confirm({
+                              title: `删除此成员吗?`,
+                              icon: <ExclamationCircleFilled />,
+                              content: "此操作不能恢复",
+                              okType: "danger",
+                              onOk() {
+                                return delete_<ICourseMemberDeleteResponse>(
+                                  "/v2/course-member/" + row.id
+                                )
+                                  .then((json) => {
+                                    if (json.ok) {
+                                      console.log("delete ok");
+                                      ref.current?.reload();
+                                    }
+                                  })
+                                  .catch(() => console.log("Oops errors!"));
+                              },
+                            });
+                            break;
+                          case "accept":
+                            if (row.id && row.name) {
+                              ChangeStatus(row.id, row.name, "accepted");
+                            }
+                            break;
+                          case "reject":
+                            if (row.id && row.name) {
+                              ChangeStatus(row.id, row.name, "rejected");
+                            }
+                            break;
+                          default:
+                            break;
+                        }
+                      },
+                    }}
+                  >
+                    <></>
+                  </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) => {
+          console.log(params, sorter, filter);
+
+          let url = `/v2/course-member?view=course&id=${courseId}`;
+          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 : "");
+          }
+          const res = await get<ICourseMemberListResponse>(url);
+          if (res.ok) {
+            console.log(res.data);
+            switch (res.data.role) {
+              case "owner":
+              case "manager":
+              case "assistant":
+                setCanDelete(true);
+                break;
+            }
+            const items: ICourseMember[] = res.data.rows.map((item, id) => {
+              let member: ICourseMember = {
+                sn: id + 1,
+                id: item.id,
+                userId: item.user_id,
+                name: item.user?.nickName,
+                role: item.role,
+                status: item.status,
+                tag: [],
+                image: "",
+              };
+              member.tag.push({
+                title: intl.formatMessage({
+                  id: "forms.fields." + item.role + ".label",
+                }),
+                color: "default",
+              });
+
+              return member;
+            });
+            console.log(items);
+            return {
+              total: res.data.count,
+              succcess: true,
+              data: items,
+            };
+          } else {
+            console.error(res.message);
+            return {
+              total: 0,
+              succcess: false,
+              data: [],
+            };
+          }
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <AddMember
+            courseId={courseId}
+            onCreated={() => {
+              ref.current?.reload();
+            }}
+          />,
+        ]}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 48 - 0
dashboard/src/components/course/ExerciseAnswer.tsx

@@ -0,0 +1,48 @@
+import { useEffect, useState } from "react";
+import { Collapse, message } from "antd";
+
+import { get } from "../../request";
+import { IArticleResponse } from "../api/Article";
+import MdView from "../template/MdView";
+
+const { Panel } = Collapse;
+
+interface IWidget {
+  courseId?: string;
+  articleId?: string;
+  exerciseId?: string;
+  mode?: string;
+  active?: boolean;
+}
+const Widget = ({
+  courseId,
+  articleId,
+  exerciseId,
+  mode,
+  active = false,
+}: IWidget) => {
+  const [answer, setAnswer] = useState<string>();
+
+  useEffect(() => {
+    const url = `/v2/article/${articleId}?mode=${mode}&course=${courseId}&exercise=${exerciseId}&view=answer`;
+    get<IArticleResponse>(url).then((json) => {
+      console.log("article", json);
+      if (json.ok) {
+        setAnswer(json.data.html);
+      } else {
+        message.error(json.message);
+      }
+    });
+  }, [courseId, articleId, exerciseId, mode]);
+  return (
+    <div>
+      <Collapse defaultActiveKey={active ? ["answer"] : []}>
+        <Panel header="答案" key="answer">
+          <MdView html={answer} />
+        </Panel>
+      </Collapse>
+    </div>
+  );
+};
+
+export default Widget;

+ 153 - 0
dashboard/src/components/course/JoinCourse.tsx

@@ -0,0 +1,153 @@
+/**
+ * 报名按钮
+ * 已经报名显示报名状态
+ * 未报名显示报名按钮以及必要的提示
+ */
+import { Button, message, Modal, Typography } from "antd";
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+import { ExclamationCircleFilled } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import { get, post } from "../../request";
+import {
+  ICourseMemberData,
+  ICourseMemberListResponse,
+  ICourseMemberResponse,
+  TCourseExpRequest,
+  TCourseJoinMode,
+} from "../api/Course";
+import LeaveCourse from "./LeaveCourse";
+
+const { confirm } = Modal;
+const { Text } = Typography;
+
+interface IWidget {
+  courseId: string;
+  startAt?: string;
+  joinMode?: TCourseJoinMode;
+  expRequest?: TCourseExpRequest;
+}
+const Widget = ({ courseId, joinMode, startAt, expRequest }: IWidget) => {
+  const user = useAppSelector(_currentUser);
+  const intl = useIntl();
+  const [currMember, setCurrMember] = useState<ICourseMemberData>();
+
+  const today = new Date();
+  const courseStart = new Date(startAt ? startAt : "3000-01-01");
+  /**
+   * 获取该课程报名状态
+   */
+  const loadStatus = () => {
+    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);
+          }
+        }
+      }
+    });
+  };
+  useEffect(loadStatus, [courseId]);
+
+  let button = <></>;
+  let labelStatus = "";
+  if (currMember?.role === "student") {
+    labelStatus = intl.formatMessage({
+      id: `course.member.status.${currMember.status}.label`,
+    });
+    if (
+      currMember.status === "accepted" ||
+      currMember.status === "progressing"
+    ) {
+      button = (
+        <LeaveCourse
+          joinMode={joinMode}
+          currUser={currMember}
+          onStatusChanged={() => {
+            loadStatus();
+          }}
+        />
+      );
+    }
+  } else if (currMember?.role === "assistant") {
+    labelStatus = "助理老师";
+  } else {
+    if (courseStart > today) {
+      button = (
+        <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 : "",
+                  }
+                )
+                  .then((json) => {
+                    console.log("add member", json);
+                    if (json.ok) {
+                      console.log("new", json.data);
+                      setCurrMember({
+                        role: "student",
+                        course_id: courseId,
+                        user_id: json.data.user_id,
+                        status: json.data.status,
+                      });
+                      message.success(
+                        intl.formatMessage({ id: "flashes.success" })
+                      );
+                    } else {
+                      message.error(json.message);
+                    }
+                  })
+                  .catch((error) => {
+                    message.error(error);
+                  });
+              },
+            });
+          }}
+        >
+          报名
+        </Button>
+      );
+    } else {
+      labelStatus = "已经过期";
+    }
+  }
+  return (
+    <div>
+      <span>{labelStatus}</span>
+      {button}
+    </div>
+  );
+};
+
+export default Widget;

+ 114 - 0
dashboard/src/components/course/LeaveCourse.tsx

@@ -0,0 +1,114 @@
+import { Button, message, Modal, Typography } from "antd";
+import { useIntl } from "react-intl";
+import { ExclamationCircleFilled } from "@ant-design/icons";
+
+import { delete_, put } from "../../request";
+import {
+  ICourseMemberData,
+  ICourseMemberDeleteResponse,
+  ICourseMemberResponse,
+  TCourseJoinMode,
+  TCourseMemberStatus,
+} from "../api/Course";
+
+const { confirm } = Modal;
+const { Text } = Typography;
+
+interface IWidget {
+  joinMode?: TCourseJoinMode;
+  currUser?: ICourseMemberData;
+  onStatusChanged?: Function;
+}
+const Widget = ({ joinMode, currUser, onStatusChanged }: IWidget) => {
+  const intl = useIntl();
+  /**
+   * 离开课程业务逻辑
+   * open 直接删除记录
+   * manual,invite
+   *  progressing 直接删除记录
+   *  其他        设置为 left
+   */
+  let isDelete = false;
+  if (joinMode === "open") {
+    isDelete = true;
+  } else if (currUser?.status === "progressing") {
+    isDelete = true;
+  }
+  const statusChange = (status: TCourseMemberStatus) => {
+    if (typeof onStatusChanged !== "undefined") {
+      onStatusChanged(status);
+    }
+  };
+  return (
+    <>
+      <Button
+        type="primary"
+        onClick={() => {
+          confirm({
+            title: "退出已经报名的课程吗?",
+            icon: <ExclamationCircleFilled />,
+            content: (
+              <div>
+                <Text type="danger">
+                  {joinMode !== "open"
+                    ? intl.formatMessage({
+                        id: `course.leave.message`,
+                      })
+                    : ""}
+                </Text>
+              </div>
+            ),
+            onOk() {
+              return isDelete
+                ? delete_<ICourseMemberDeleteResponse>(
+                    "/v2/course-member/" + currUser?.id
+                  )
+                    .then((json) => {
+                      console.log("add member", json);
+                      if (json.ok) {
+                        console.log("delete", json.data);
+                        statusChange("normal");
+                        message.success(
+                          intl.formatMessage({ id: "flashes.success" })
+                        );
+                      } else {
+                        message.error(json.message);
+                      }
+                    })
+                    .catch((error) => {
+                      message.error(error);
+                    })
+                : put<ICourseMemberData, ICourseMemberResponse>(
+                    "/v2/course-member/" + currUser?.id,
+                    {
+                      user_id: "",
+                      course_id: "",
+                      status: "left",
+                    }
+                  )
+                    .then((json) => {
+                      console.log("leave", json);
+                      if (json.ok) {
+                        console.log("leave", json.data);
+                        statusChange("left");
+                        message.success(
+                          intl.formatMessage({ id: "flashes.success" })
+                        );
+                      } else {
+                        message.error(json.message);
+                      }
+                    })
+                    .catch((error) => {
+                      message.error(error);
+                    });
+            },
+          });
+        }}
+      >
+        退出
+      </Button>
+    </>
+  );
+};
+
+export default Widget;

+ 0 - 55
dashboard/src/components/course/LessonTreeShow.tsx

@@ -1,55 +0,0 @@
-//上传封面组件
-import React, { useState } from "react";
-import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
-import { message, Upload, Tree } from "antd";
-import type { UploadChangeParam } from "antd/es/upload";
-import type { RcFile, UploadFile, UploadProps } from "antd/es/upload/interface";
-
-import type { DataNode } from "antd/es/tree";
-/*
-const dig = (path = "0", level = 3) => {
-  const list = [];
-  for (let i = 0; i < 10; i += 1) {
-    const key = `a-${i}`;
-    const treeNode: DataNode = {
-      title: key,
-      key,
-    };
-
-    if (level > 0) {
-      treeNode.children = dig(key, level - 1);
-    }
-
-    list.push(treeNode);
-  }
-  return list;
-};
-
-const treeData = dig();
-*/
-const treeData: DataNode[] = [
-  {
-    title: "课程1",
-    key: "0-0",
-    children: [
-      { title: "课程1-0", key: "0-0-0", isLeaf: true },
-      { title: "课程1-1", key: "0-0-1", isLeaf: true },
-      { title: "课程1-2", key: "0-0-2", isLeaf: true },
-      { title: "课程1-3", key: "0-0-3", isLeaf: true },
-    ],
-  },
-  {
-    title: "课程2",
-    key: "0-1",
-    children: [
-      { title: "课程2-0", key: "0-1-0", isLeaf: true },
-      { title: "课程2-1", key: "0-1-1", isLeaf: true },
-      { title: "课程2-2", key: "0-1-2", isLeaf: true },
-    ],
-  },
-];
-const Widget = () => {
-  return <Tree treeData={treeData} height={233} defaultExpandAll />;
-};
-
-export default Widget;

+ 114 - 0
dashboard/src/components/course/SelectChannel.tsx

@@ -0,0 +1,114 @@
+import { ModalForm, ProForm, ProFormSelect } from "@ant-design/pro-components";
+import { Button, message } from "antd";
+import { GlobalOutlined } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+
+import { get, put } from "../../request";
+import { IApiResponseChannelList } from "../api/Channel";
+import { LockIcon } from "../../assets/icon";
+import { ICourseMemberData, ICourseMemberResponse } from "../api/Course";
+import { useNavigate, useParams } from "react-router-dom";
+interface IWidget {
+  courseId?: string;
+  exerciseId?: string;
+  channel?: string;
+  onSelected?: Function;
+  open?: boolean;
+  onOpenChange?: Function;
+}
+const Widget = ({
+  courseId,
+  exerciseId,
+  channel,
+  onSelected,
+  open,
+  onOpenChange,
+}: IWidget) => {
+  const user = useAppSelector(_currentUser);
+  const { type, id } = useParams(); //url 参数
+  const navigate = useNavigate();
+
+  return (
+    <ModalForm<{
+      channel: string;
+    }>
+      title="选择作业的存放位置"
+      trigger={<Button>做练习</Button>}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnClose: true,
+        onCancel: () => console.log("run"),
+      }}
+      submitTimeout={2000}
+      onFinish={async (values) => {
+        console.log(values.channel);
+        console.log("id", id);
+        const mCourseId = id?.split("_")[0];
+        if (typeof user !== "undefined" && typeof mCourseId !== "undefined") {
+          const json = await put<ICourseMemberData, ICourseMemberResponse>(
+            `/v2/course-member_set-channel`,
+            {
+              user_id: user.id,
+              course_id: mCourseId,
+              channel_id: values.channel,
+            }
+          );
+          if (json.ok) {
+            if (json.data.channel_id === courseId) {
+              message.success("提交成功");
+              navigate(
+                `/article/exercise/${id}_${exerciseId}_${user.realName}/wbw`
+              );
+            } else {
+              message.error(json.data.channel_id);
+            }
+          } else {
+            message.error(json.message);
+          }
+        } else {
+          console.log("select channel error:", user, courseId);
+        }
+
+        return true;
+      }}
+    >
+      <div>
+        您还没有选择版本。您将用一个版本保存自己的作业。这个版本将会被老师,助理老师看到。
+      </div>
+      <ProForm.Group>
+        <ProFormSelect
+          rules={[
+            {
+              required: true,
+            },
+          ]}
+          request={async () => {
+            const channelData = await get<IApiResponseChannelList>(
+              `/v2/channel?view=studio&name=${user?.realName}`
+            );
+            const channel = channelData.data.rows.map((item) => {
+              const icon =
+                item.status === 30 ? <GlobalOutlined /> : <LockIcon />;
+              return {
+                value: item.uid,
+                label: (
+                  <>
+                    {icon} {item.name}
+                  </>
+                ),
+              };
+            });
+            return channel;
+          }}
+          width="md"
+          name="channel"
+          label="版本风格"
+        />
+      </ProForm.Group>
+    </ModalForm>
+  );
+};
+
+export default Widget;

+ 0 - 95
dashboard/src/components/course/StudentsSelect.tsx

@@ -1,95 +0,0 @@
-//选择讲师组件
-
-import { useIntl } from "react-intl";
-import { useState } from "react";
-import { ProList } from "@ant-design/pro-components";
-import { UserAddOutlined } from "@ant-design/icons";
-import { Space, Tag, Button, Layout } from "antd";
-import AddStudent from "./AddStudent";
-
-const { Content } = Layout;
-
-const defaultData = [
-  {
-    id: "1",
-    name: "小僧善巧",
-    tag: [{ title: "助教", color: "success" }],
-    image:
-      "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
-  },
-  {
-    id: "2",
-    name: "学员1",
-    tag: [{ title: "学员", color: "blue" }],
-    image:
-      "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
-  },
-  {
-    id: "3",
-    name: "学员2",
-    tag: [{ title: "学员", color: "blue" }],
-    image:
-      "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
-  },
-];
-type DataItem = typeof defaultData[number];
-interface IWidge {
-  courseId?: string;
-}
-const Widget = ({ courseId }: IWidge) => {
-  const intl = useIntl(); //i18n
-  const [dataSource, setDataSource] = useState<DataItem[]>(defaultData);
-
-  return (
-    <>
-      <ProList<DataItem>
-        rowKey="id"
-        headerTitle={intl.formatMessage({
-          id: "forms.fields.studentsassistant.label",
-        })}
-        toolBarRender={() => {
-          return [<AddStudent courseId={courseId} />];
-        }}
-        dataSource={dataSource}
-        showActions="hover"
-        onDataSourceChange={setDataSource}
-        metas={{
-          title: {
-            dataIndex: "name",
-          },
-          avatar: {
-            dataIndex: "image",
-            editable: false,
-          },
-          subTitle: {
-            render: (text, row, index, action) => {
-              const showtag = row.tag.map((item, id) => {
-                return (
-                  <Tag color={item.color} key={id}>
-                    {item.title}
-                  </Tag>
-                );
-              });
-              return <Space size={0}>{showtag}</Space>;
-            },
-          },
-          actions: {
-            render: (text, row, index, action) => [
-              <Button
-                size="small"
-                type="link"
-                danger
-                onClick={() => {}}
-                key="link"
-              >
-                {intl.formatMessage({ id: "buttons.remove" })}
-              </Button>,
-            ],
-          },
-        }}
-      />
-    </>
-  );
-};
-
-export default Widget;

+ 0 - 82
dashboard/src/components/course/TeacherSelect.tsx

@@ -1,82 +0,0 @@
-//选择讲师组件
-
-import { useIntl } from "react-intl";
-import { useState } from "react";
-import { ProList } from "@ant-design/pro-components";
-import { UserAddOutlined } from "@ant-design/icons";
-import { Space, Tag, Button, Layout } from "antd";
-import AddTeacher from "./AddTeacher";
-
-const { Content } = Layout;
-
-const defaultData = [
-  {
-    id: "1",
-    name: "小僧善巧",
-    tag: [{ title: "管理员", color: "success" }],
-    image:
-      "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
-  },
-];
-type DataItem = typeof defaultData[number];
-interface IWidgetGroupFile {
-  groupId?: string;
-}
-const Widget = ({ groupId }: IWidgetGroupFile) => {
-  const intl = useIntl(); //i18n
-  const [dataSource, setDataSource] = useState<DataItem[]>(defaultData);
-
-  return (
-    <Content>
-      <Space>{groupId}</Space>
-      <ProList<DataItem>
-        rowKey="id"
-        headerTitle={intl.formatMessage({ id: "forms.fields.teacher.label" })}
-        toolBarRender={() => {
-          return [<AddTeacher groupId={groupId} />];
-        }}
-        dataSource={dataSource}
-        showActions="hover"
-        onDataSourceChange={setDataSource}
-        metas={{
-          title: {
-            dataIndex: "name",
-          },
-          avatar: {
-            dataIndex: "image",
-            editable: false,
-          },
-          subTitle: {
-            render: (text, row, index, action) => {
-              const showtag = row.tag.map((item, id) => {
-                return (
-                  <Tag color={item.color} key={id}>
-                    {item.title}
-                  </Tag>
-                );
-              });
-              return <Space size={0}>{showtag}</Space>;
-            },
-          },
-          actions: {
-            render: (text, row, index, action) => [
-              <Button
-                style={{ padding: 0, margin: 0 }}
-                type="link"
-                danger
-                onClick={() => {
-                  action?.startEditable(row.id);
-                }}
-                key="link"
-              >
-                {intl.formatMessage({ id: "buttons.remove" })}
-              </Button>,
-            ],
-          },
-        }}
-      />
-    </Content>
-  );
-};
-
-export default Widget;

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

@@ -18,7 +18,7 @@ const Widget = ({ anthologyId, courseId }: IWidget) => {
           <AnthologyDetail
             aid={anthologyId}
             onArticleSelect={(keys: string[]) => {
-              navigate(`/article/textbook/${courseId}/${keys[0]}`);
+              navigate(`/article/textbook/${courseId}_${keys[0]}/read`);
             }}
           />
         </Col>

+ 45 - 24
dashboard/src/components/dict/CaseList.tsx

@@ -1,31 +1,52 @@
-import { List, Card } from "antd";
-import { Row, Col } from "antd";
+import { List, Card, Tag, Typography } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import { ICaseListResponse } from "../api/Dict";
+const { Text } = Typography;
 
 export interface ICaseListData {
-	word: string;
-	count: number;
+  word: string;
+  count: number;
+  bold: number;
 }
-interface IWidgetCaseList {
-	data: ICaseListData[];
+interface IWidget {
+  word?: string;
 }
-const Widget = (prop: IWidgetCaseList) => {
-	return (
-		<Card title="Case List">
-			<List
-				footer={"共计30"}
-				size="small"
-				dataSource={prop.data}
-				renderItem={(item) => (
-					<List.Item>
-						<Row>
-							<Col>{item.word}</Col>
-							<Col>{item.count}</Col>
-						</Row>
-					</List.Item>
-				)}
-			/>
-		</Card>
-	);
+const Widget = ({ word }: IWidget) => {
+  const [caseData, setCaseData] = useState<ICaseListData[]>();
+  const [count, setCount] = useState<number>();
+  useEffect(() => {
+    get<ICaseListResponse>(`/v2/case/${word}`).then((json) => {
+      console.log("case", json);
+      if (json.ok) {
+        setCaseData(json.data.rows.sort((a, b) => b.count - a.count));
+        setCount(json.data.count);
+      }
+    });
+  }, [word]);
+  return (
+    <div style={{ padding: 4 }}>
+      <List
+        header={`单词数:${count}`}
+        size="small"
+        dataSource={caseData}
+        renderItem={(item) => (
+          <List.Item>
+            <div
+              style={{
+                display: "flex",
+                justifyContent: "space-between",
+                width: "100%",
+              }}
+            >
+              <Text strong={item.bold > 0 ? true : false}>{item.word}</Text>
+              <Tag>{item.count}</Tag>
+            </div>
+          </List.Item>
+        )}
+      />
+    </div>
+  );
 };
 
 export default Widget;

+ 81 - 0
dashboard/src/components/dict/Compound.tsx

@@ -0,0 +1,81 @@
+import { List, Select, Space, Typography } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import {
+  IApiResponseDictList,
+  IDictFirstMeaningResponse,
+  IFirstMeaning,
+} from "../api/Dict";
+
+const { Text } = Typography;
+
+interface IOptions {
+  value: string;
+  label: string;
+}
+interface IWidget {
+  word?: string;
+  add?: string;
+  split?: string;
+}
+const Widget = ({ word, add, split }: IWidget) => {
+  const [compound, setCompound] = useState<IOptions[]>([]);
+  const [factors, setFactors] = useState<IOptions[]>([]);
+  const [meaningData, setMeaningData] = useState<IFirstMeaning[]>();
+  const [currValue, setCurrValue] = useState<string>();
+  const onSelectChange = (value: string) => {
+    console.log("selected", value);
+    get<IDictFirstMeaningResponse>(
+      `/v2/dict-meaning?lang=zh-Hans&word=` + value.replaceAll("+", "-")
+    ).then((json) => {
+      if (json.ok) {
+        setMeaningData(json.data);
+      }
+    });
+  };
+  useEffect(() => {
+    if (typeof add === "undefined") {
+      setFactors(compound);
+    } else {
+      setFactors([{ value: add, label: add }, ...compound]);
+      setCurrValue(add);
+      onSelectChange(add);
+    }
+  }, [add, compound]);
+  useEffect(() => {
+    get<IApiResponseDictList>(`/v2/userdict?view=compound&word=${word}`).then(
+      (json) => {
+        if (json.ok) {
+          const data = json.data.rows.map((item) => {
+            return { value: item.factors, label: item.factors };
+          });
+          setCompound(data);
+        }
+      }
+    );
+  }, [word]);
+  return (
+    <div>
+      <Select
+        value={currValue}
+        style={{ width: "100%" }}
+        onChange={onSelectChange}
+        options={factors}
+      />
+      <List
+        size="small"
+        dataSource={meaningData}
+        renderItem={(item) => (
+          <List.Item>
+            <div>
+              <Text strong>{item.word}</Text>{" "}
+              <Text type="secondary">{item.meaning}</Text>
+            </div>
+          </List.Item>
+        )}
+      />
+    </div>
+  );
+};
+
+export default Widget;

+ 2 - 42
dashboard/src/components/dict/DictComponent.tsx

@@ -1,26 +1,14 @@
 import { useState, useEffect } from "react";
-import { Affix, Col, Row } from "antd";
-import { Input } from "antd";
 
 import { useAppSelector } from "../../hooks";
 import { message } from "../../reducers/command";
-
-import DictSearch from "./DictSearch";
-
-const { Search } = Input;
+import Dictionary from "./Dictionary";
 
 export interface IWidgetDict {
   word?: string;
 }
 const Widget = ({ word }: IWidgetDict) => {
-  const [container, setContainer] = useState<HTMLDivElement | null>(null);
   const [wordSearch, setWordSearch] = useState(word);
-
-  const onSearch = (value: string) => {
-    console.log("onSearch", value);
-    setWordSearch(value);
-  };
-
   //接收查字典消息
   const commandMsg = useAppSelector(message);
   useEffect(() => {
@@ -30,35 +18,7 @@ const Widget = ({ word }: IWidgetDict) => {
     }
   }, [commandMsg]);
 
-  return (
-    <div ref={setContainer}>
-      <Affix offsetTop={0} target={() => container}>
-        <div style={{ backgroundColor: "gray", height: "3.5em" }}>
-          <Row style={{ paddingTop: "0.5em" }}>
-            <Col xs={0} lg={8}></Col>
-            <Col xs={24} lg={8}>
-              <Search
-                placeholder="input search text"
-                onSearch={onSearch}
-                value={wordSearch}
-                style={{ width: "100%" }}
-              />
-            </Col>
-            <Col xs={0} lg={8}></Col>
-          </Row>
-        </div>
-      </Affix>
-      <div>
-        <Row>
-          <Col flex="auto"></Col>
-          <Col flex="1260px">
-            <DictSearch word={wordSearch} />
-          </Col>
-          <Col flex="auto"></Col>
-        </Row>
-      </div>
-    </div>
-  );
+  return <Dictionary word={wordSearch} compact={true} />;
 };
 
 export default Widget;

+ 7 - 5
dashboard/src/components/dict/DictContent.tsx

@@ -19,24 +19,26 @@ export interface IApiDictContentData {
   data: IWidgetDictContentData;
 }
 
-interface IWidgetDictContent {
+interface IWidget {
+  word?: string;
   data: IWidgetDictContentData;
+  compact?: boolean;
 }
 
-const Widget = (prop: IWidgetDictContent) => {
+const Widget = ({ word, data, compact }: IWidget) => {
   return (
     <>
       <Row>
         <Col flex="200px">
-          <DictList data={prop.data.dictlist} />
+          {compact ? <></> : <DictList data={data.dictlist} />}
         </Col>
         <Col flex="760px">
-          {prop.data.words.map((it, id) => {
+          {data.words.map((it, id) => {
             return <WordCard key={id} data={it} />;
           })}
         </Col>
         <Col flex="200px">
-          <CaseList data={prop.data.caselist} />
+          <CaseList word={word} />
         </Col>
       </Row>
     </>

+ 7 - 11
dashboard/src/components/dict/DictEdit.tsx

@@ -1,4 +1,3 @@
-import { useEffect } from "react";
 import { useIntl } from "react-intl";
 import { ProForm } from "@ant-design/pro-components";
 import { message } from "antd";
@@ -9,12 +8,11 @@ import { get, put } from "../../request";
 import DictEditInner from "./DictEditInner";
 import { IDictFormData } from "./DictCreate";
 
-type IWidgetDictEdit = {
-  wordId: number;
-};
-const Widget = (prop: IWidgetDictEdit) => {
+interface IWidget {
+  wordId?: string;
+}
+const Widget = ({ wordId }: IWidget) => {
   const intl = useIntl();
-  useEffect(() => {});
 
   return (
     <>
@@ -36,7 +34,7 @@ const Widget = (prop: IWidgetDictEdit) => {
             confidence: values.confidence,
           };
           const res = await put<IDictDataRequest, IApiResponseDict>(
-            `/v2/userdict/${prop.wordId}`,
+            `/v2/userdict/${wordId}`,
             request
           );
           console.log(res);
@@ -48,11 +46,9 @@ const Widget = (prop: IWidgetDictEdit) => {
         }}
         formKey="dict_edit"
         request={async () => {
-          const res: IApiResponseDict = await get(
-            `/v2/userdict/${prop.wordId}`
-          );
+          const res: IApiResponseDict = await get(`/v2/userdict/${wordId}`);
           return {
-            id: res.data.id,
+            id: 1,
             wordId: res.data.id,
             word: res.data.word,
             type: res.data.type,

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff