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

Merge pull request #1091 from visuddhinanda/agile

Agile
visuddhinanda 3 лет назад
Родитель
Сommit
9556323dbd
85 измененных файлов с 2480 добавлено и 456 удалено
  1. 6 1
      dashboard/src/Router.tsx
  2. 21 0
      dashboard/src/assets/icon/index.tsx
  3. 6 1
      dashboard/src/components/anthology/EditableTocTree.tsx
  4. 9 0
      dashboard/src/components/api/Article.ts
  5. 2 2
      dashboard/src/components/api/Auth.ts
  6. 8 4
      dashboard/src/components/api/Channel.ts
  7. 34 0
      dashboard/src/components/api/Corpus.ts
  8. 2 2
      dashboard/src/components/api/Course.ts
  9. 28 12
      dashboard/src/components/api/Dict.ts
  10. 25 0
      dashboard/src/components/api/Exp.ts
  11. 3 3
      dashboard/src/components/api/Group.ts
  12. 3 3
      dashboard/src/components/api/Share.ts
  13. 1 1
      dashboard/src/components/api/Suggestion.ts
  14. 1 5
      dashboard/src/components/article/AnthologyDetail.tsx
  15. 71 17
      dashboard/src/components/article/Article.tsx
  16. 13 0
      dashboard/src/components/article/ArticleSkeleton.tsx
  17. 9 3
      dashboard/src/components/article/ArticleView.tsx
  18. 7 4
      dashboard/src/components/article/EditableTree.tsx
  19. 5 1
      dashboard/src/components/article/RightPanel.tsx
  20. 7 5
      dashboard/src/components/article/TocTree.tsx
  21. 130 0
      dashboard/src/components/article/ToolButtonPr.tsx
  22. 96 0
      dashboard/src/components/auth/Avatar.tsx
  23. 1 1
      dashboard/src/components/auth/StudioName.tsx
  24. 17 7
      dashboard/src/components/auth/User.tsx
  25. 141 53
      dashboard/src/components/channel/ChannelPickerTable.tsx
  26. 10 19
      dashboard/src/components/channel/ProgressSvg.tsx
  27. 1 6
      dashboard/src/components/comment/CommentListCard.tsx
  28. 1 6
      dashboard/src/components/comment/CommentTopicChildren.tsx
  29. 1 6
      dashboard/src/components/comment/CommentTopicInfo.tsx
  30. 0 1
      dashboard/src/components/corpus/ChapterCard.tsx
  31. 100 0
      dashboard/src/components/corpus/ChapterChannelSelect.tsx
  32. 3 1
      dashboard/src/components/corpus/ChapterInChannel.tsx
  33. 7 4
      dashboard/src/components/corpus/PaliChapterChannelList.tsx
  34. 0 1
      dashboard/src/components/course/LessonSelect.tsx
  35. 53 23
      dashboard/src/components/dict/Compound.tsx
  36. 27 4
      dashboard/src/components/dict/DictContent.tsx
  37. 3 3
      dashboard/src/components/dict/DictEdit.tsx
  38. 11 10
      dashboard/src/components/dict/Dictionary.tsx
  39. 147 0
      dashboard/src/components/dict/MyCreate.tsx
  40. 3 0
      dashboard/src/components/dict/SearchVocabulary.tsx
  41. 52 0
      dashboard/src/components/exp/ExpPie.tsx
  42. 110 0
      dashboard/src/components/exp/ExpStatisticCard.tsx
  43. 168 0
      dashboard/src/components/exp/Heatmap.tsx
  44. 72 0
      dashboard/src/components/exp/StudyTimeDualAxes.tsx
  45. 15 7
      dashboard/src/components/general/TimeShow.tsx
  46. 8 2
      dashboard/src/components/nut/Home.tsx
  47. 2 4
      dashboard/src/components/nut/users/SignIn.tsx
  48. 3 3
      dashboard/src/components/share/Share.tsx
  49. 23 3
      dashboard/src/components/studio/LeftSider.tsx
  50. 3 1
      dashboard/src/components/template/MdView.tsx
  51. 7 2
      dashboard/src/components/template/SentEdit.tsx
  52. 16 4
      dashboard/src/components/template/SentEdit/EditInfo.tsx
  53. 85 0
      dashboard/src/components/template/SentEdit/PrAcceptButton.tsx
  54. 31 8
      dashboard/src/components/template/SentEdit/SentCell.tsx
  55. 49 6
      dashboard/src/components/template/SentEdit/SentCellEditable.tsx
  56. 8 4
      dashboard/src/components/template/SentEdit/SuggestionAdd.tsx
  57. 94 0
      dashboard/src/components/template/SentEdit/SuggestionBox.tsx
  58. 52 26
      dashboard/src/components/template/SentEdit/SuggestionList.tsx
  59. 53 0
      dashboard/src/components/template/SentEdit/SuggestionToolbar.tsx
  60. 7 2
      dashboard/src/components/template/SentRead.tsx
  61. 0 1
      dashboard/src/components/template/Term.tsx
  62. 36 17
      dashboard/src/components/template/Wbw/WbwDetail.tsx
  63. 2 18
      dashboard/src/components/template/Wbw/WbwDetailAdvance.tsx
  64. 44 0
      dashboard/src/components/template/Wbw/WbwDetailAttachment.tsx
  65. 54 60
      dashboard/src/components/template/Wbw/WbwDetailBasic.tsx
  66. 24 0
      dashboard/src/components/template/Wbw/WbwFactors.tsx
  67. 15 0
      dashboard/src/components/template/Wbw/WbwPali.tsx
  68. 1 1
      dashboard/src/components/template/Wbw/wbw.css
  69. 16 0
      dashboard/src/load.ts
  70. 1 0
      dashboard/src/locales/en-US/index.ts
  71. 1 0
      dashboard/src/locales/zh-Hans/auth/index.ts
  72. 8 2
      dashboard/src/locales/zh-Hans/buttons.ts
  73. 4 1
      dashboard/src/locales/zh-Hans/index.ts
  74. 11 0
      dashboard/src/locales/zh-Hans/label.ts
  75. 1 0
      dashboard/src/locales/zh-Hans/message.ts
  76. 154 0
      dashboard/src/pages/admin/_defaultProps.tsx
  77. 106 0
      dashboard/src/pages/admin/index.tsx
  78. 38 16
      dashboard/src/pages/library/article/show.tsx
  79. 1 6
      dashboard/src/pages/library/discussion/list.tsx
  80. 8 8
      dashboard/src/pages/studio/analysis/index.tsx
  81. 18 45
      dashboard/src/pages/studio/analysis/list.tsx
  82. 22 0
      dashboard/src/pages/studio/setting/index.tsx
  83. 30 0
      dashboard/src/reducers/accept-pr.ts
  84. 2 0
      dashboard/src/store.ts
  85. 12 0
      dashboard/src/theme/antpro.dark.css

+ 6 - 1
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 AdminHome from "./pages/admin";
 import LibraryHome from "./pages/library";
 import LibraryCommunity from "./pages/library/community";
 import LibraryCommunityList from "./pages/library/community/list";
@@ -83,6 +84,8 @@ import StudioAnthology from "./pages/studio/anthology";
 import StudioAnthologyList from "./pages/studio/anthology/list";
 import StudioAnthologyEdit from "./pages/studio/anthology/edit";
 
+import StudioSetting from "./pages/studio/setting";
+
 import StudioAnalysis from "./pages/studio/analysis";
 import StudioAnalysisList from "./pages/studio/analysis/list";
 import { ConfigProvider } from "antd";
@@ -94,6 +97,7 @@ const Widget = () => {
   return (
     <ConfigProvider prefixCls={theme}>
       <Routes>
+        <Route path="admin" element={<AdminHome />} />
         <Route path="anonymous" element={<Anonymous />}>
           <Route path="users">
             <Route path="sign-in" element={<NutUsersSignIn />} />
@@ -234,8 +238,9 @@ const Widget = () => {
               element={<StudioAnthologyEdit />}
             />
           </Route>
+          <Route path="setting" element={<StudioSetting />} />
 
-          <Route path="analysis" element={<StudioAnalysis />}>
+          <Route path="exp" element={<StudioAnalysis />}>
             <Route path="list" element={<StudioAnalysisList />} />
           </Route>
         </Route>

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

@@ -64,6 +64,23 @@ const UnLockSvg = () => (
     <path d="M11 1a2 2 0 0 0-2 2v4a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V3a3 3 0 0 1 6 0v4a.5.5 0 0 1-1 0V3a2 2 0 0 0-2-2zM3 8a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1H3z" />
   </svg>
 );
+
+const HandOutlined = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="1704"
+    width="1em"
+    height="1em"
+    fill="currentColor"
+  >
+    <path
+      d="M870.4 204.8c-18.6368 0-36.1472 5.0176-51.2 13.7728l0-64.9728c0-56.4736-45.9264-102.4-102.4-102.4-21.0944 0-40.6528 6.4-56.9856 17.3568-14.0288-39.8848-52.0192-68.5568-96.6144-68.5568s-82.6368 28.672-96.6144 68.5568c-16.2816-10.9568-35.8912-17.3568-56.9856-17.3568-56.4736 0-102.4 45.9264-102.4 102.4l0 377.4976-68.9152-119.4496c-13.3632-24.32-35.1744-41.6256-61.3888-48.7936-25.5488-6.9632-52.1216-3.2768-74.8544 10.3424-46.4384 27.8528-64.1536 90.8288-39.424 140.3904 1.536 3.1232 34.2016 70.0416 136.192 273.92 48.0256 96 100.7104 164.6592 156.6208 203.9808 43.8784 30.8736 74.1888 32.4608 79.8208 32.4608l256 0c43.5712 0 84.0704-14.1824 120.4224-42.0864 34.1504-26.2656 63.7952-64.256 88.064-112.8448 47.8208-95.6416 73.1136-227.9424 73.1136-382.6688l0-179.2c0-56.4736-45.9264-102.4-102.4-102.4zM921.6 486.4c0 146.7904-23.3984 271.1552-67.6864 359.7312-28.8768 57.7536-80.5888 126.6688-162.7136 126.6688l-255.488 0c-1.9968-0.1536-23.552-2.56-56.064-26.88-32.4096-24.2688-82.176-75.3664-135.0656-181.248-103.7824-207.5648-135.68-272.9472-135.9872-273.5616-0.0512-0.1024-0.0512-0.1536-0.1024-0.2048-12.8512-25.7536-3.7376-59.4944 19.9168-73.6768 10.6496-6.4 23.0912-8.0896 35.072-4.864 12.7488 3.4816 23.4496 12.0832 30.0544 24.1664 0.1024 0.1536 0.2048 0.3584 0.3072 0.512l79.9232 138.496c16.3328 29.8496 34.7136 42.3936 54.6304 37.3248 19.968-5.0688 30.0544-25.0368 30.0544-59.2384l0-400.0256c0-28.2112 22.9888-51.2 51.2-51.2s51.2 22.9888 51.2 51.2l0 332.8c0 14.1312 11.4688 25.6 25.6 25.6s25.6-11.4688 25.6-25.6l0-384c0-28.2112 22.9888-51.2 51.2-51.2s51.2 22.9888 51.2 51.2l0 384c0 14.1312 11.4688 25.6 25.6 25.6s25.6-11.4688 25.6-25.6l0-332.8c0-28.2112 22.9888-51.2 51.2-51.2s51.2 22.9888 51.2 51.2l0 384c0 14.1312 11.4688 25.6 25.6 25.6s25.6-11.4688 25.6-25.6l0-230.4c0-28.2112 22.9888-51.2 51.2-51.2s51.2 22.9888 51.2 51.2l0 179.2z"
+      p-id="1705"
+    ></path>
+  </svg>
+);
 export const DictIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={DictSvg} {...props} />
 );
@@ -82,3 +99,7 @@ export const LockIcon = (props: Partial<CustomIconComponentProps>) => (
 export const UnLockIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={UnLockSvg} {...props} />
 );
+
+export const HandOutlinedIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={HandOutlined} {...props} />
+);

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

@@ -48,10 +48,15 @@ const Widget = ({ anthologyId, onSelect }: IWidget) => {
             `/v2/article-map/${anthologyId}`,
             {
               data: data.map((item) => {
+                let title = "";
+                if (typeof item.title === "string") {
+                  title = item.title;
+                }
+                //TODO 整一个string title
                 return {
                   article_id: item.key,
                   level: item.level,
-                  title: item.title,
+                  title: title,
                   children: item.children,
                 };
               }),

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

@@ -65,6 +65,14 @@ export interface IArticleDataRequest {
   status: number;
   lang: string;
 }
+export interface IChapterToc {
+  book: number;
+  paragraph: number;
+  level: number;
+  pali_title: string /**巴利文标题 */;
+  title?: string /**译文文标题 */;
+  progress?: number[];
+}
 export interface IArticleDataResponse {
   uid: string;
   title: string;
@@ -72,6 +80,7 @@ export interface IArticleDataResponse {
   summary: string;
   content?: string;
   content_type?: string;
+  toc?: IChapterToc[];
   html?: string;
   path?: ITocPathNode[];
   status: number;

+ 2 - 2
dashboard/src/components/api/Auth.ts

@@ -1,4 +1,4 @@
-export type Role =
+export type TRole =
   | "owner"
   | "manager"
   | "editor"
@@ -39,6 +39,6 @@ export interface IStudioApiResponse {
   id: string;
   nickName: string;
   studioName: string;
-  avatar: string;
+  avatar?: string;
   owner: IUserApiResponse;
 }

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

@@ -1,5 +1,5 @@
 import { IStudio } from "../auth/StudioName";
-import { IStudioApiResponse, Role } from "./Auth";
+import { IStudioApiResponse, TRole } from "./Auth";
 export type TChannelType =
   | "translation"
   | "nissaya"
@@ -17,19 +17,23 @@ export interface ChannelInfoProps {
   studio: IStudio;
   count?: number;
 }
-
+/**
+ * 句子完成情况
+ * [句子字符数,是否完成]
+ *
+ */
 export type IFinal = [number, boolean];
 export interface IApiResponseChannelData {
   uid: string;
   name: string;
   summary: string;
   type: TChannelType;
-  studio: IStudioApiResponse;
+  studio: IStudio;
   lang: string;
   status: number;
   created_at: string;
   updated_at: string;
-  role?: Role;
+  role?: TRole;
   final?: IFinal[];
 }
 export interface IApiResponseChannel {

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

@@ -1,6 +1,7 @@
 import { IStudio } from "../auth/StudioName";
 import { IUser } from "../auth/User";
 import { IChannel } from "../channel/Channel";
+import { ISuggestionCount } from "../template/SentEdit";
 import { TChannelType } from "./Channel";
 import { TagNode } from "./Tag";
 
@@ -141,9 +142,13 @@ export interface ISentenceRequest {
   wordEnd: number;
   channel: string;
   content: string;
+  prEditor?: string;
+  prId?: string;
+  prEditAt?: string;
 }
 
 export interface ISentenceData {
+  id?: string;
   book: number;
   paragraph: number;
   word_start: number;
@@ -152,7 +157,11 @@ export interface ISentenceData {
   html: string;
   editor: IUser;
   channel: IChannel;
+  studio: IStudio;
   updated_at: string;
+  acceptor?: IUser;
+  pr_edit_at?: string;
+  suggestionCount?: ISuggestionCount;
 }
 
 export interface ISentenceResponse {
@@ -222,3 +231,28 @@ export interface IChapterLangListResponse {
   message: string;
   data: { rows: ILangList[]; count: number };
 }
+
+export interface ISentencePrRequest {
+  book: number;
+  para: number;
+  begin: number;
+  end: number;
+  channel: string;
+  text: string;
+}
+export interface ISentencePrResponseData {
+  book_id: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  channel_uid: string;
+}
+export interface ISentencePrResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    new: ISentencePrResponseData;
+    count: number;
+    webhook: { message: string; ok: boolean };
+  };
+}

+ 2 - 2
dashboard/src/components/api/Course.ts

@@ -1,6 +1,6 @@
 import { IStudio } from "../auth/StudioName";
 import { IUser } from "../auth/User";
-import { IUserRequest, Role } from "./Auth";
+import { IUserRequest, TRole } from "./Auth";
 
 export interface ICourseListApiResponse {
   article: string;
@@ -116,7 +116,7 @@ export interface ICourseMemberListResponse {
   message: string;
   data: {
     rows: ICourseMemberData[];
-    role: Role;
+    role: TRole;
     count: number;
   };
 }

+ 28 - 12
dashboard/src/components/api/Dict.ts

@@ -1,20 +1,36 @@
+import { IStudio } from "../auth/StudioName";
+import { IUser } from "../auth/User";
 import { ICaseListData } from "../dict/CaseList";
 
-export interface IDictDataRequest {
-  id: number;
+export interface IDictRequest {
+  id?: number;
   word: string;
-  type: string;
-  grammar: string;
-  mean: string;
-  parent: string;
-  note: string;
-  factors: string;
-  factormean: string;
-  dictId?: string;
-  dictName?: string;
-  language: string;
+  type?: string;
+  grammar?: string;
+  mean?: string;
+  parent?: string;
+  note?: string;
+  factors?: string;
+  factormean?: string;
   confidence: number;
+  dict_id?: string;
+  dict_name?: string;
+  language?: string;
+  creator_id?: number;
+  editor?: IUser;
+  studio?: IStudio;
+  updated_at?: string;
 }
+export interface IUserDictCreate {
+  data: string;
+  view: string;
+}
+export interface IDictResponse {
+  ok: boolean;
+  message: string;
+  data: number[];
+}
+
 export interface IApiResponseDictData {
   id: string;
   word: string;

+ 25 - 0
dashboard/src/components/api/Exp.ts

@@ -0,0 +1,25 @@
+export interface IUserOperationDailyRequest {
+  date_int: number;
+  duration: number;
+  hit?: number;
+}
+
+export interface IUserOperationDailyResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IUserOperationDailyRequest[]; count: number };
+}
+
+export interface IUserStatistic {
+  exp: { sum: number };
+  wbw: { count: number };
+  lookup: { count: number };
+  translation: { count: number; count_pub: number };
+  term: { count: number; count_with_note: number };
+  dict: { count: number };
+}
+export interface IUserStatisticResponse {
+  ok: boolean;
+  message: string;
+  data: IUserStatistic;
+}

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

@@ -1,5 +1,5 @@
 import { IStudio } from "../auth/StudioName";
-import { IStudioApiResponse, IUserRequest, Role } from "./Auth";
+import { IStudioApiResponse, IUserRequest, TRole } from "./Auth";
 
 export interface IGroupRequest {
   name: string;
@@ -11,7 +11,7 @@ export interface IGroupDataRequest {
   name: string;
   description: string;
   studio: IStudioApiResponse;
-  role: Role;
+  role: TRole;
   created_at: string;
 }
 export interface IGroupResponse {
@@ -49,7 +49,7 @@ export interface IGroupMemberListResponse {
   message: string;
   data: {
     rows: IGroupMemberData[];
-    role: Role;
+    role: TRole;
     count: number;
   };
 }

+ 3 - 3
dashboard/src/components/api/Share.ts

@@ -1,10 +1,10 @@
 import { IUser } from "../auth/User";
-import { Role } from "./Auth";
+import { TRole } from "./Auth";
 
 export interface IShareRequest {
   res_id: string;
   res_type: string;
-  role: Role;
+  role: TRole;
   user_id: string;
   user_type: string;
 }
@@ -30,7 +30,7 @@ export interface IShareListResponse {
   message: string;
   data: {
     rows: IShareData[];
-    role: Role;
+    role: TRole;
     count: number;
   };
 }

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

@@ -2,7 +2,7 @@ import { IUserApiResponse } from "./Auth";
 import { IChannelApiData } from "./Channel";
 
 export interface ISuggestionData {
-  id: number;
+  id: string;
   book: number;
   paragraph: number;
   word_start: number;

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

@@ -64,11 +64,7 @@ const Widget = ({ aid, channels, onArticleSelect }: IWidgetAnthologyDetail) => {
       </div>
       <Space>
         <StudioName data={tableData?.studio} />
-        <TimeShow
-          time={tableData?.updated_at}
-          title="updated"
-          showTitle={true}
-        />
+        <TimeShow time={tableData?.updated_at} title="updated" />
       </Space>
       <div>
         <Marked text={tableData?.summary} />

+ 71 - 17
dashboard/src/components/article/Article.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from "react";
-import { message } from "antd";
+import { Divider, message, Tag } from "antd";
 
 import { modeChange } from "../../reducers/article-mode";
 import { get } from "../../request";
@@ -13,6 +13,9 @@ import ExerciseList from "./ExerciseList";
 import ExerciseAnswer from "../course/ExerciseAnswer";
 import "./article.css";
 import CommentListCard from "../comment/CommentListCard";
+import TocTree from "./TocTree";
+import PaliText from "../template/Wbw/PaliText";
+import ArticleSkeleton from "./ArticleSkeleton";
 
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleType =
@@ -35,16 +38,21 @@ interface IWidgetArticle {
   articleId?: string;
   mode?: ArticleMode;
   active?: boolean;
+  onArticleChange?: Function;
+  onFinal?: Function;
 }
 const Widget = ({
   type,
   articleId,
   mode = "read",
   active = false,
+  onArticleChange,
+  onFinal,
 }: IWidgetArticle) => {
   const [articleData, setArticleData] = useState<IArticleDataResponse>();
   const [articleMode, setArticleMode] = useState<ArticleMode>(mode);
   const [extra, setExtra] = useState(<></>);
+  const [showSkeleton, setShowSkeleton] = useState(true);
 
   let channels: string[] = [];
   if (typeof articleId !== "undefined") {
@@ -55,7 +63,7 @@ const Widget = ({
   }
   useEffect(() => {
     /**
-     * 由课本进入插叙当前用户的权限和channel
+     * 由课本进入查询当前用户的权限和channel
      */
     if (
       type === "textbook" ||
@@ -87,6 +95,7 @@ const Widget = ({
       }
     }
   }, [articleId, type]);
+
   useEffect(() => {
     console.log("mode", mode, articleMode);
     if (!active) {
@@ -170,14 +179,53 @@ const Widget = ({
           );
           break;
         default:
-          url = `/v2/corpus/${type}/${articleId}/${mode}`;
+          const aid = articleId.split("_");
+
+          url = `/v2/corpus/${type}/${articleId}/${mode}?mode=${mode}`;
+          if (aid.length > 0) {
+            const channels = aid.slice(1).join();
+            url += `&channels=${channels}`;
+          }
           break;
       }
       console.log("url", url);
+      setShowSkeleton(true);
       get<IArticleResponse>(url).then((json) => {
         console.log("article", json);
         if (json.ok) {
           setArticleData(json.data);
+          setShowSkeleton(false);
+
+          setExtra(
+            <TocTree
+              treeData={json.data.toc?.map((item) => {
+                const strTitle = item.title ? item.title : item.pali_title;
+                const progress = item.progress?.map((item, id) => (
+                  <Tag key={id}>{Math.round(item * 100)}</Tag>
+                ));
+
+                return {
+                  key: `${item.book}-${item.paragraph}`,
+                  title: (
+                    <>
+                      <PaliText text={strTitle} />
+                      {progress}
+                    </>
+                  ),
+                  level: item.level,
+                };
+              })}
+              onSelect={(keys: string[]) => {
+                console.log(keys);
+                if (typeof onArticleChange !== "undefined" && keys.length > 0) {
+                  const aid = articleId.split("_");
+                  const channels =
+                    aid.length > 1 ? "_" + aid.slice(1).join("_") : undefined;
+                  onArticleChange(keys[0] + channels);
+                }
+              }}
+            />
+          );
         } else {
           message.error(json.message);
         }
@@ -187,21 +235,27 @@ const Widget = ({
 
   return (
     <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}
-      />
+      {showSkeleton ? (
+        <ArticleSkeleton />
+      ) : (
+        <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}
+      <Divider />
       <CommentListCard resId={articleData?.uid} resType="article" />
     </div>
   );

+ 13 - 0
dashboard/src/components/article/ArticleSkeleton.tsx

@@ -0,0 +1,13 @@
+import { Divider, Skeleton } from "antd";
+
+const Widget = () => {
+  return (
+    <div style={{ paddingTop: "1em" }}>
+      <Skeleton title={{ width: 200 }} paragraph={{ rows: 1 }} active />
+      <Divider />
+      <Skeleton title={{ width: 200 }} paragraph={{ rows: 10 }} active />
+    </div>
+  );
+};
+
+export default Widget;

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

@@ -63,9 +63,15 @@ const Widget = ({
   }
   return (
     <>
-      <Button shape="round" size="small" icon={<ReloadOutlined />}>
-        刷新
-      </Button>
+      <div style={{ textAlign: "right" }}>
+        <Button
+          type="link"
+          shape="round"
+          size="small"
+          icon={<ReloadOutlined />}
+        />
+      </div>
+
       <div>
         <TocPath data={path} channel={channels} />
 

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

@@ -9,16 +9,17 @@ import {
   SaveOutlined,
 } from "@ant-design/icons";
 import { Button, Divider, Space } from "antd";
+import { useIntl } from "react-intl";
 
 interface TreeNodeData {
   key: string;
-  title: string;
+  title: string | React.ReactNode;
   children: TreeNodeData[];
   level: number;
 }
 export type ListNodeData = {
   key: string;
-  title: string;
+  title: string | React.ReactNode;
   level: number;
   children?: number;
 };
@@ -125,6 +126,8 @@ const Widget = ({
   onSelect,
   onSave,
 }: IWidgetEditableTree) => {
+  const intl = useIntl();
+
   const [gData, setGData] = useState<TreeNodeData[]>([]);
   const [listTreeData, setListTreeData] = useState<ListNodeData[]>();
 
@@ -241,7 +244,7 @@ const Widget = ({
             setGData(tmp);
           }}
         >
-          删除
+          {intl.formatMessage({ id: "buttons.remove" })}
         </Button>
         <Button
           icon={<SaveOutlined />}
@@ -252,7 +255,7 @@ const Widget = ({
           }}
           type="primary"
         >
-          保存
+          {intl.formatMessage({ id: "buttons.save" })}
         </Button>
       </Space>
       <Divider></Divider>

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

@@ -13,6 +13,7 @@ interface IWidget {
   articleId: string;
   selectedChannelKeys?: string[];
   onChannelSelect?: Function;
+  channelReload?: boolean;
 }
 const Widget = ({
   curr = "close",
@@ -20,6 +21,7 @@ const Widget = ({
   articleId,
   onChannelSelect,
   selectedChannelKeys,
+  channelReload = false,
 }: IWidget) => {
   const [dict, setDict] = useState("none");
   const [channel, setChannel] = useState("none");
@@ -42,8 +44,9 @@ const Widget = ({
   }, [curr]);
   return (
     <Affix offsetTop={44}>
-      <div>
+      <div key="panel">
         <div
+          key="DictComponent"
           style={{
             width: 350,
             height: `calc(100vh - 44px)`,
@@ -54,6 +57,7 @@ const Widget = ({
           <DictComponent />
         </div>
         <div
+          key="ChannelPickerTable"
           style={{
             width: 350,
             height: `calc(100vh - 44px)`,

+ 7 - 5
dashboard/src/components/article/TocTree.tsx

@@ -5,12 +5,12 @@ import { useEffect, useState } from "react";
 import type { ListNodeData } from "./EditableTree";
 import PaliText from "../template/Wbw/PaliText";
 
-type TreeNodeData = {
+interface TreeNodeData {
   key: string;
-  title: string;
+  title: string | React.ReactNode;
   children?: TreeNodeData[];
   level: number;
-};
+}
 
 function tocGetTreeData(
   listData: ListNodeData[],
@@ -94,13 +94,15 @@ interface IWidgetTocTree {
 const Widget = ({ treeData, expandedKey, onSelect }: IWidgetTocTree) => {
   const [tree, setTree] = useState<TreeNodeData[]>();
   const [expanded, setExpanded] = useState(expandedKey);
-
+  console.log("new tree data", treeData);
   useEffect(() => {
     if (treeData && treeData.length > 0) {
       const data = tocGetTreeData(treeData);
       setTree(data);
       setExpanded(expandedKey);
       console.log("create tree", treeData.length, expandedKey);
+    } else {
+      setTree([]);
     }
   }, [treeData, expandedKey]);
   const onNodeSelect: TreeProps["onSelect"] = (selectedKeys, info) => {
@@ -121,7 +123,7 @@ const Widget = ({ treeData, expandedKey, onSelect }: IWidgetTocTree) => {
         if (typeof node.title === "string") {
           return <PaliText text={node.title} />;
         } else {
-          return <></>;
+          return <>{node.title}</>;
         }
       }}
     />

+ 130 - 0
dashboard/src/components/article/ToolButtonPr.tsx

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

+ 96 - 0
dashboard/src/components/auth/Avatar.tsx

@@ -0,0 +1,96 @@
+import { useIntl } from "react-intl";
+import { useEffect, useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { Tooltip } from "antd";
+import { Avatar } from "antd";
+import { Popover } from "antd";
+import { ProCard } from "@ant-design/pro-components";
+import {
+  UserOutlined,
+  HomeOutlined,
+  LogoutOutlined,
+  SettingOutlined,
+} from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import { TooltipPlacement } from "antd/lib/tooltip";
+
+interface IWidget {
+  placement?: TooltipPlacement;
+}
+const Widget = ({ placement = "bottomRight" }: IWidget) => {
+  // TODO
+  const intl = useIntl();
+  const navigate = useNavigate();
+  const [userName, setUserName] = useState<string>();
+  const [nickName, setNickName] = useState<string>();
+  const user = useAppSelector(_currentUser);
+  useEffect(() => {
+    setUserName(user?.realName);
+    setNickName(user?.nickName);
+  }, [user]);
+
+  const userCard = (
+    <ProCard
+      style={{ maxWidth: 500, minWidth: 300 }}
+      actions={[
+        <Tooltip
+          title={intl.formatMessage({
+            id: "buttons.setting",
+          })}
+        >
+          <SettingOutlined key="setting" />
+        </Tooltip>,
+        <Tooltip
+          title={intl.formatMessage({
+            id: "columns.library.blog.label",
+          })}
+        >
+          <Link to={`/blog/${userName}/overview`}>
+            <HomeOutlined key="home" />
+          </Link>
+        </Tooltip>,
+        <Tooltip
+          title={intl.formatMessage({
+            id: "buttons.sign-out",
+          })}
+        >
+          <LogoutOutlined
+            key="logout"
+            onClick={() => {
+              sessionStorage.removeItem("token");
+              localStorage.removeItem("token");
+              navigate("/anonymous/users/sign-in");
+            }}
+          />
+        </Tooltip>,
+      ]}
+    >
+      <div>
+        <h2>{nickName}</h2>
+        <div style={{ textAlign: "right" }}>
+          {intl.formatMessage({
+            id: "buttons.welcome",
+          })}
+        </div>
+      </div>
+    </ProCard>
+  );
+  const login = <Link to="/anonymous/users/sign-in">登录</Link>;
+  return (
+    <>
+      <Popover content={user ? userCard : login} placement={placement}>
+        <Avatar
+          style={{ backgroundColor: user ? "#87d068" : "gray" }}
+          icon={<UserOutlined />}
+          size="small"
+        >
+          {user ? nickName?.slice(0, 1) : undefined}
+        </Avatar>
+      </Popover>
+    </>
+  );
+};
+
+export default Widget;

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

@@ -6,7 +6,7 @@ export interface IStudio {
   id: string;
   nickName: string;
   studioName: string;
-  avatar: string;
+  avatar?: string;
 }
 interface IWidghtStudio {
   data?: IStudio;

+ 17 - 7
dashboard/src/components/auth/User.tsx

@@ -1,17 +1,27 @@
-import { Avatar } from "antd";
+import { Avatar, Space } from "antd";
 
 export interface IUser {
   id: string;
   nickName: string;
-  realName: string;
+  userName: string;
   avatar?: string;
+  showAvatar?: boolean;
+  showName?: boolean;
 }
-const Widget = ({ nickName, realName, avatar }: IUser) => {
+const Widget = ({
+  nickName,
+  userName,
+  avatar,
+  showAvatar = true,
+  showName = true,
+}: IUser) => {
   return (
-    <>
-      <Avatar size="small">{nickName?.slice(0, 1)}</Avatar>
-      {nickName}
-    </>
+    <Space>
+      {showAvatar ? (
+        <Avatar size="small">{nickName?.slice(0, 1)}</Avatar>
+      ) : undefined}
+      {showName ? nickName : undefined}
+    </Space>
   );
 };
 

+ 141 - 53
dashboard/src/components/channel/ChannelPickerTable.tsx

@@ -1,24 +1,33 @@
-import { ProList } from "@ant-design/pro-components";
+import { useEffect, useRef, useState } from "react";
 import { useIntl } from "react-intl";
-import { Dropdown, Space, Table } from "antd";
+import { ActionType, ProList } from "@ant-design/pro-components";
+import { Button } from "antd";
+import { Badge, Dropdown, Space, Table, Typography } from "antd";
 import {
   GlobalOutlined,
   EditOutlined,
   MoreOutlined,
   CopyOutlined,
 } from "@ant-design/icons";
-import { Button } from "antd";
 
 import { IApiResponseChannelList, IFinal, TChannelType } from "../api/Channel";
-import { get } from "../../request";
+import { get, post } 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";
 
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import { sentenceList } from "../../reducers/sentence";
+
+const { Link } = Typography;
+
+interface IProgressRequest {
+  sentence: string[];
+}
 export interface IItem {
   id: number;
   uid: string;
@@ -31,12 +40,14 @@ export interface IItem {
   publicity: number;
   createdAt: number;
   final?: IFinal[];
+  progress: number;
 }
 interface IWidget {
   type?: ArticleType | "editable";
   articleId?: string;
-  multiSelect?: boolean;
+  multiSelect?: boolean /*是否支持多选*/;
   selectedKeys?: string[];
+  reload?: boolean;
   onSelect?: Function;
 }
 const Widget = ({
@@ -45,49 +56,62 @@ const Widget = ({
   multiSelect = true,
   selectedKeys = [],
   onSelect,
+  reload = false,
 }: IWidget) => {
   const intl = useIntl();
   const [selectedRowKeys, setSelectedRowKeys] =
     useState<React.Key[]>(selectedKeys);
+  const [showCheckBox, setShowCheckBox] = useState<boolean>(false);
+  const user = useAppSelector(_currentUser);
+  const ref = useRef<ActionType>();
+  const sentences = useAppSelector(sentenceList);
+
+  useEffect(() => {
+    if (reload) {
+      ref.current?.reload();
+    }
+  }, [reload]);
+
   return (
     <>
       <ProList<IItem>
+        actionRef={ref}
         rowSelection={
-          multiSelect
+          showCheckBox
             ? {
                 // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
                 // 注释该行则默认不显示下拉选项
-                selectedRowKeys,
+                alwaysShowAlert: true,
+                selectedRowKeys: selectedRowKeys,
+                onChange: (selectedRowKeys: React.Key[]) => {
+                  setSelectedRowKeys(selectedRowKeys);
+                },
                 selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
               }
             : undefined
         }
         tableAlertRender={
-          multiSelect
-            ? ({ selectedRowKeys, selectedRows, onCleanSelected }) => (
-                <Space size={24}>
-                  <span>
+          showCheckBox
+            ? ({ selectedRowKeys, selectedRows, onCleanSelected }) => {
+                console.log(selectedRowKeys);
+                return (
+                  <Space>
                     {intl.formatMessage({ id: "buttons.selected" })}
-                    {selectedRowKeys.length}
-                    <Button
-                      type="link"
-                      style={{ marginInlineStart: 8 }}
-                      onClick={onCleanSelected}
-                    >
-                      {intl.formatMessage({ id: "buttons.unselect" })}
-                    </Button>
-                  </span>
-                </Space>
-              )
+                    <Badge color="geekblue" count={selectedRowKeys.length} />
+                    <Link onClick={onCleanSelected}>
+                      {intl.formatMessage({ id: "buttons.empty" })}
+                    </Link>
+                  </Space>
+                );
+              }
             : undefined
         }
         tableAlertOptionRender={
-          multiSelect
+          showCheckBox
             ? ({ selectedRowKeys, selectedRows, onCleanSelected }) => {
                 return (
-                  <Space size={16}>
-                    <Button
-                      type="link"
+                  <Space>
+                    <Link
                       onClick={() => {
                         console.log("select", selectedRowKeys);
                         if (typeof onSelect !== "undefined") {
@@ -99,13 +123,25 @@ const Widget = ({
                               };
                             })
                           );
+                          setShowCheckBox(false);
+                          ref.current?.reload();
                         }
                       }}
                     >
                       {intl.formatMessage({
-                        id: "buttons.select",
+                        id: "buttons.ok",
                       })}
-                    </Button>
+                    </Link>
+                    <Link
+                      type="danger"
+                      onClick={() => {
+                        setShowCheckBox(false);
+                      }}
+                    >
+                      {intl.formatMessage({
+                        id: "buttons.cancel",
+                      })}
+                    </Link>
                   </Space>
                 );
               }
@@ -115,47 +151,67 @@ const Widget = ({
           // TODO
           console.log(params, sorter, filter);
           let url: string = "";
-          switch (type) {
-            case "editable":
-              url = `/v2/channel?view=user-edit`;
-              break;
-            case "chapter":
-              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;
+          if (typeof articleId !== "undefined") {
+            const id = articleId.split("_");
+            const [book, para] = id[0].split("-");
+            url = `/v2/channel-progress?view=user-in-chapter&book=${book}&para=${para}&progress=sent`;
           }
-          const res: IApiResponseChannelList = await get(url);
+          const res = await post<IProgressRequest, IApiResponseChannelList>(
+            url,
+            {
+              sentence: sentences,
+            }
+          );
           console.log("data", res.data.rows);
           const items: IItem[] = res.data.rows.map((item, id) => {
             const date = new Date(item.created_at);
+            let all: number = 0;
+            let finished: number = 0;
+            item.final?.forEach((value) => {
+              all += value[0];
+              finished += value[1] ? value[0] : 0;
+            });
+            const progress = finished / all;
             return {
               id: id,
               uid: item.uid,
               title: item.name,
               summary: item.summary,
-              studio: {
-                id: item.studio.id,
-                nickName: item.studio.nickName,
-                studioName: item.studio.studioName,
-                avatar: item.studio.avatar,
-              },
+              studio: item.studio,
               shareType: "my",
               role: item.role,
               type: item.type,
               publicity: item.status,
               createdAt: date.getTime(),
               final: item.final,
+              progress: progress,
             };
           });
+          //当前被选择的
+          const currChannel = items.filter((value) =>
+            selectedRowKeys.includes(value.uid)
+          );
+          let show = selectedRowKeys;
+          //有进度的
+          const progressing = items.filter(
+            (value) => value.progress > 0 && !show.includes(value.uid)
+          );
+          show = [...show, ...progressing.map((item) => item.uid)];
+          //我自己的
+          const myChannel = items.filter(
+            (value) => value.role === "owner" && !show.includes(value.uid)
+          );
+          show = [...show, ...myChannel.map((item) => item.uid)];
+          //其他的
+          const others = items.filter(
+            (value) => !show.includes(value.uid) && value.role !== "member"
+          );
+          console.log("user:", user);
           setSelectedRowKeys(selectedRowKeys);
           return {
             total: res.data.count,
             succcess: true,
-            data: items,
+            data: [...currChannel, ...progressing, ...myChannel, ...others],
           };
         }}
         rowKey="uid"
@@ -164,7 +220,25 @@ const Widget = ({
         search={{
           filterType: "light",
         }}
-        showActions="hover"
+        toolBarRender={() => [
+          <Button
+            onClick={() => {
+              ref.current?.reload();
+            }}
+          >
+            reload
+          </Button>,
+          multiSelect ? (
+            <Button
+              onClick={() => {
+                setShowCheckBox(true);
+                console.log("user:", user);
+              }}
+            >
+              选择
+            </Button>
+          ) : undefined,
+        ]}
         metas={{
           title: {
             render(dom, entity, index, action, schema) {
@@ -179,8 +253,22 @@ const Widget = ({
               }
 
               return (
-                <div key={index}>
-                  <div key="info">
+                <div
+                  key={index}
+                  style={{
+                    width: "100%",
+                    borderRadius: 5,
+                    padding: "0 5px",
+                    background:
+                      selectedKeys.includes(entity.uid) && !showCheckBox
+                        ? "linear-gradient(to right,#006112,rgba(0,0,0,0))"
+                        : undefined,
+                  }}
+                >
+                  <div
+                    key="info"
+                    style={{ overflowX: "clip", display: "flex" }}
+                  >
                     <Space>
                       {pIcon}
                       {entity.role !== "member" ? <EditOutlined /> : undefined}
@@ -260,7 +348,7 @@ const Widget = ({
           },
           status: {
             // 自己扩展的字段,主要用于筛选,不在列表中显示
-            title: "筛选",
+            title: "版本筛选",
             valueType: "select",
             valueEnum: {
               all: { text: "全部", status: "Default" },

+ 10 - 19
dashboard/src/components/channel/ProgressSvg.tsx

@@ -28,6 +28,7 @@ const Widget = ({ data, width = 300 }: IWidget) => {
     finished += item[1] ? stroke_width : 0;
     return (
       <rect
+        key={id}
         x={curr_x - stroke_width}
         y={0}
         height={svg_height}
@@ -38,6 +39,7 @@ const Widget = ({ data, width = 300 }: IWidget) => {
   });
   const finishedBar = (
     <rect
+      key="2"
       x={0}
       y={svg_height / 2 - svg_height / 20}
       width={finished}
@@ -48,30 +50,36 @@ const Widget = ({ data, width = 300 }: IWidget) => {
   const progress = (
     <svg viewBox={`0 0 ${svg_width} ${svg_height} `} width={"100%"}>
       <defs>
-        <linearGradient id="grad1" x1="0%" y1="0%" x2="0%" y2="100%">
+        <linearGradient key="1" id="grad1" x1="0%" y1="0%" x2="0%" y2="100%">
           <stop
+            key="1"
             offset="0%"
             style={{ stopColor: "rgb(0,180,0)", stopOpacity: 1 }}
           />
           <stop
+            key="2"
             offset="50%"
             style={{ stopColor: "rgb(255,255,255)", stopOpacity: 0.5 }}
           />
           <stop
+            key="3"
             offset="100%"
             style={{ stopColor: "rgb(0,180,0)", stopOpacity: 1 }}
           />
         </linearGradient>
-        <linearGradient id="grad2" x1="0%" y1="0%" x2="0%" y2="100%">
+        <linearGradient key="2" id="grad2" x1="0%" y1="0%" x2="0%" y2="100%">
           <stop
+            key="1"
             offset="0%"
             style={{ stopColor: "rgb(180,180,180)", stopOpacity: 1 }}
           />
           <stop
+            key="2"
             offset="50%"
             style={{ stopColor: "rgb(255,255,255)", stopOpacity: 0.5 }}
           />
           <stop
+            key="3"
             offset="100%"
             style={{ stopColor: "rgb(180,180,180)", stopOpacity: 1 }}
           />
@@ -81,23 +89,6 @@ const Widget = ({ data, width = 300 }: IWidget) => {
       {finishedBar}
     </svg>
   );
-  /*
-			output +=
-				"<rect  x='0' y='0'  width='" + svg_width + "' height='" + svg_height / 5 + "' class='progress_bar_bg' />";
-			output +=
-				"<rect  x='0' y='0'  width='" +
-				allFinal +
-				"' height='" +
-				svg_height / 5 +
-				"' class='progress_bar_percent' style='stroke-width: 0; fill: rgb(100, 228, 100);'/>";
-			output += '<text x="0" y="' + svg_height + '" font-size="' + svg_height * 0.8 + '">';
-			output += channalinfo["count"] + "/" + channalinfo["all"] + "@" + curr_x;
-			output += "</text>";
-			output += "<svg>";
-			output += "</div>";
-*/
-  //进度结束
-
   return <div style={{ width: width }}>{progress}</div>;
 };
 

+ 1 - 6
dashboard/src/components/comment/CommentListCard.tsx

@@ -61,12 +61,7 @@ const Widget = ({
               id: item.id,
               resId: item.res_id,
               resType: item.res_type,
-              user: {
-                id: item.editor?.id ? item.editor.id : "",
-                nickName: item.editor?.nickName ? item.editor.nickName : "",
-                realName: item.editor?.userName ? item.editor.userName : "",
-                avatar: item.editor?.avatar ? item.editor.avatar : "",
-              },
+              user: item.editor,
               title: item.title,
               content: item.content,
               childrenCount: item.children_count,

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

@@ -28,12 +28,7 @@ const Widget = ({ topicId, onItemCountChange }: IWidget) => {
               id: item.id,
               resId: item.res_id,
               resType: item.res_type,
-              user: {
-                id: item.editor?.id ? item.editor.id : "null",
-                nickName: item.editor?.nickName ? item.editor.nickName : "null",
-                realName: item.editor?.userName ? item.editor.userName : "null",
-                avatar: item.editor?.avatar ? item.editor.avatar : "null",
-              },
+              user: item.editor,
               title: item.title,
               content: item.content,
               createdAt: item.created_at,

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

@@ -27,12 +27,7 @@ const Widget = ({ topicId }: IWidget) => {
             id: item.id,
             resId: item.res_id,
             resType: item.res_type,
-            user: {
-              id: item.editor?.id ? item.editor.id : "null",
-              nickName: item.editor?.nickName ? item.editor.nickName : "null",
-              realName: item.editor?.userName ? item.editor.userName : "null",
-              avatar: item.editor?.avatar ? item.editor.avatar : "null",
-            },
+            user: item.editor,
             title: item.title,
             content: item.content,
             createdAt: item.created_at,

+ 0 - 1
dashboard/src/components/corpus/ChapterCard.tsx

@@ -36,7 +36,6 @@ interface IWidgetChapterCard {
 
 const Widget = ({ data, onTagClick }: IWidgetChapterCard) => {
   const path = JSON.parse(data.path);
-  console.log("path", data.path);
   return (
     <>
       <Row>

+ 100 - 0
dashboard/src/components/corpus/ChapterChannelSelect.tsx

@@ -0,0 +1,100 @@
+import { Col, List, Modal, Progress, Row, Space, Typography } from "antd";
+
+import ChannelListItem from "../channel/ChannelListItem";
+import { IChapterChannelData } from "./ChapterInChannel";
+import { LikeOutlined, EyeOutlined } from "@ant-design/icons";
+import { useState } from "react";
+import TimeShow from "../general/TimeShow";
+
+const { Text } = Typography;
+
+/**
+ * 章节中的版本选择对话框
+ * @returns
+ */
+interface IWidget {
+  trigger?: JSX.Element | string;
+  channels?: IChapterChannelData[];
+  currChannel?: string;
+  onSelect?: Function;
+}
+const Widget = ({ trigger, channels, currChannel, onSelect }: IWidget) => {
+  const [open, setOpen] = useState(false);
+
+  const handleCancel = () => {
+    setOpen(false);
+  };
+
+  return (
+    <div>
+      <div
+        onClick={() => {
+          setOpen(true);
+        }}
+      >
+        {trigger}
+      </div>
+      <Modal
+        title="版本选择"
+        open={open}
+        onCancel={handleCancel}
+        onOk={handleCancel}
+      >
+        <List
+          style={{ maxWidth: 500 }}
+          itemLayout="vertical"
+          size="small"
+          dataSource={channels}
+          pagination={
+            currChannel
+              ? 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={12}>
+                  <div
+                    onClick={() => {
+                      if (typeof onSelect !== "undefined") {
+                        onSelect(item.channel.id);
+                      }
+                    }}
+                  >
+                    <ChannelListItem
+                      channel={item.channel}
+                      studio={item.studio}
+                    />
+                  </div>
+                </Col>
+                <Col span={12}>
+                  <Progress percent={item.progress} size="small" />
+                </Col>
+              </Row>
+
+              <Text type="secondary">
+                <Space style={{ paddingLeft: "2em" }}>
+                  <EyeOutlined />
+                  {item.hit} | <LikeOutlined />
+                  {item.like} |
+                  <TimeShow time={item.updatedAt} title={item.updatedAt} />
+                </Space>
+              </Text>
+            </List.Item>
+          )}
+        />
+      </Modal>
+    </div>
+  );
+};
+
+export default Widget;

+ 3 - 1
dashboard/src/components/corpus/ChapterInChannel.tsx

@@ -37,7 +37,7 @@ const Widget = ({
   const intl = useIntl(); //i18n
   const [open, setOpen] = useState(false);
   const ChannelList = (channels: IChapterChannelData[]): JSX.Element => {
-    return (
+    return channels.length ? (
       <List
         style={{ maxWidth: 500 }}
         itemLayout="vertical"
@@ -87,6 +87,8 @@ const Widget = ({
           </List.Item>
         )}
       />
+    ) : (
+      <></>
     );
   };
 

+ 7 - 4
dashboard/src/components/corpus/PaliChapterChannelList.tsx

@@ -32,11 +32,12 @@ const Widget = ({ para, channelId, openTarget = "_blank" }: IWidget) => {
         };
       });
       setTableData(newData);
+      console.log("chapter", newData);
     });
   }, [para]);
 
-  return (
-    <>
+  if (tableData.length > 0) {
+    return (
       <ChapterInChannel
         data={tableData}
         book={para.book}
@@ -44,8 +45,10 @@ const Widget = ({ para, channelId, openTarget = "_blank" }: IWidget) => {
         channelId={channelId}
         openTarget={openTarget}
       />
-    </>
-  );
+    );
+  } else {
+    return <></>;
+  }
 };
 
 export default Widget;

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

@@ -28,7 +28,6 @@ const Widget = ({ groupId }: IWidgetGroupFile) => {
 
   return (
     <Content>
-      <Space>{groupId}</Space>
       <ProList<DataItem>
         rowKey="id"
         headerTitle={intl.formatMessage({ id: "forms.fields.lesson.label" })}

+ 53 - 23
dashboard/src/components/dict/Compound.tsx

@@ -7,7 +7,7 @@ import {
   IFirstMeaning,
 } from "../api/Dict";
 
-const { Text } = Typography;
+const { Text, Link } = Typography;
 
 interface IOptions {
   value: string;
@@ -17,25 +17,37 @@ interface IWidget {
   word?: string;
   add?: string;
   split?: string;
+  onSearch?: Function;
 }
-const Widget = ({ word, add, split }: IWidget) => {
+const Widget = ({ word, add, split, onSearch }: 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) => {
+  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);
-      }
-    });
+    if (typeof value === "undefined") {
+      setMeaningData(undefined);
+    } else {
+      get<IDictFirstMeaningResponse>(
+        `/v2/dict-meaning?lang=zh-Hans&word=` + value.replaceAll("+", "-")
+      ).then((json) => {
+        if (json.ok) {
+          setMeaningData(json.data);
+        }
+      });
+    }
   };
   useEffect(() => {
+    console.log("compound changed", add);
+  }, [add]);
+  useEffect(() => {
+    console.log("compound changed", add, compound);
     if (typeof add === "undefined") {
       setFactors(compound);
+      const value = compound.length > 0 ? compound[0].value : undefined;
+      setCurrValue(value);
+      onSelectChange(value);
     } else {
       setFactors([{ value: add, label: add }, ...compound]);
       setCurrValue(add);
@@ -55,25 +67,43 @@ const Widget = ({ word, add, split }: IWidget) => {
     );
   }, [word]);
   return (
-    <div>
+    <div
+      style={{
+        width: "100%",
+        maxWidth: 560,
+        marginLeft: "auto",
+        marginRight: "auto",
+      }}
+    >
       <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>
-        )}
-      />
+      {meaningData ? (
+        <List
+          size="small"
+          dataSource={meaningData}
+          renderItem={(item) => (
+            <List.Item>
+              <div>
+                <Link
+                  strong
+                  onClick={() => {
+                    if (typeof onSearch !== "undefined") {
+                      onSearch(item.word, true);
+                    }
+                  }}
+                >
+                  {item.word}
+                </Link>{" "}
+                <Text type="secondary">{item.meaning}</Text>
+              </div>
+            </List.Item>
+          )}
+        />
+      ) : undefined}
     </div>
   );
 };

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

@@ -1,4 +1,4 @@
-import { Col, Row } from "antd";
+import { Col, Row, Tabs } from "antd";
 
 import type { IAnchorData } from "./DictList";
 import type { IWidgetWordCardData } from "./WordCard";
@@ -7,6 +7,7 @@ import type { ICaseListData } from "./CaseList";
 import WordCard from "./WordCard";
 import CaseList from "./CaseList";
 import DictList from "./DictList";
+import MyCreate from "./MyCreate";
 
 export interface IWidgetDictContentData {
   dictlist: IAnchorData[];
@@ -33,9 +34,31 @@ const Widget = ({ word, data, compact }: IWidget) => {
           {compact ? <></> : <DictList data={data.dictlist} />}
         </Col>
         <Col flex="760px">
-          {data.words.map((it, id) => {
-            return <WordCard key={id} data={it} />;
-          })}
+          <Tabs
+            size="small"
+            items={[
+              {
+                label: `查询结果`,
+                key: "result",
+                children: (
+                  <div>
+                    {data.words.map((it, id) => {
+                      return <WordCard key={id} data={it} />;
+                    })}
+                  </div>
+                ),
+              },
+              {
+                label: `添加`,
+                key: "my",
+                children: (
+                  <div>
+                    <MyCreate word={word} />
+                  </div>
+                ),
+              },
+            ]}
+          />
         </Col>
         <Col flex="200px">
           <CaseList word={word} />

+ 3 - 3
dashboard/src/components/dict/DictEdit.tsx

@@ -2,7 +2,7 @@ import { useIntl } from "react-intl";
 import { ProForm } from "@ant-design/pro-components";
 import { message } from "antd";
 
-import { IApiResponseDict, IDictDataRequest } from "../api/Dict";
+import { IApiResponseDict, IDictRequest } from "../api/Dict";
 import { get, put } from "../../request";
 
 import DictEditInner from "./DictEditInner";
@@ -20,7 +20,7 @@ const Widget = ({ wordId }: IWidget) => {
         onFinish={async (values: IDictFormData) => {
           // TODO
           console.log(values);
-          const request: IDictDataRequest = {
+          const request: IDictRequest = {
             id: values.id,
             word: values.word,
             type: values.type,
@@ -33,7 +33,7 @@ const Widget = ({ wordId }: IWidget) => {
             language: values.lang,
             confidence: values.confidence,
           };
-          const res = await put<IDictDataRequest, IApiResponseDict>(
+          const res = await put<IDictRequest, IApiResponseDict>(
             `/v2/userdict/${wordId}`,
             request
           );

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

@@ -22,7 +22,14 @@ const Widget = ({ word, compact = false, onSearch }: IWidget) => {
   useEffect(() => {
     setWordSearch(word?.toLowerCase());
   }, [word]);
-
+  const dictSearch = (value: string, isFactor?: boolean) => {
+    console.log("onSearch", value);
+    const word = value.toLowerCase();
+    setWordSearch(word);
+    if (typeof onSearch !== "undefined") {
+      onSearch(value, isFactor);
+    }
+  };
   return (
     <div ref={setContainer}>
       <Affix offsetTop={0} target={compact ? () => container : undefined}>
@@ -37,15 +44,9 @@ const Widget = ({ word, compact = false, onSearch }: IWidget) => {
             <Col flex="560px">
               <SearchVocabulary
                 value={word}
-                onSearch={(value: string, isFactor?: boolean) => {
-                  console.log("onSearch", value);
-                  const word = value.toLowerCase();
-                  setWordSearch(word);
-                  if (typeof onSearch !== "undefined") {
-                    onSearch(value, isFactor);
-                  }
-                }}
+                onSearch={dictSearch}
                 onSplit={(word: string | undefined) => {
+                  console.log("onSplit", word);
                   setSplit(word);
                 }}
               />
@@ -58,7 +59,7 @@ const Widget = ({ word, compact = false, onSearch }: IWidget) => {
         <Row>
           {compact ? <></> : <Col flex="auto"></Col>}
           <Col flex="1260px">
-            <Compound word={wordSearch} add={split} />
+            <Compound word={wordSearch} add={split} onSearch={dictSearch} />
             <DictSearch word={wordSearch} compact={compact} />
           </Col>
           {compact ? <></> : <Col flex="auto"></Col>}

+ 147 - 0
dashboard/src/components/dict/MyCreate.tsx

@@ -0,0 +1,147 @@
+import { Button, Col, Divider, Input, message, Row } from "antd";
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import { SaveOutlined } from "@ant-design/icons";
+
+import WbwDetailBasic from "../template/Wbw/WbwDetailBasic";
+import WbwDetailNote from "../template/Wbw/WbwDetailNote";
+import { IWbw, IWbwField, TFieldName } from "../template/Wbw/WbwWord";
+import { post } from "../../request";
+import { IDictResponse, IUserDictCreate } from "../api/Dict";
+
+interface IWidget {
+  word?: string;
+}
+const Widget = ({ word }: IWidget) => {
+  const intl = useIntl();
+  const [wordSpell, setWordSpell] = useState(word);
+  const [editWord, setEditWord] = useState<IWbw>({
+    word: { value: word ? word : "", status: 1 },
+    confidence: 100,
+  });
+  const [loading, setLoading] = useState(false);
+
+  function fieldChanged(field: TFieldName, value: string) {
+    let mData = JSON.parse(JSON.stringify(editWord));
+    switch (field) {
+      case "note":
+        mData.note = { value: value, status: 5 };
+        break;
+      case "word":
+        mData.word = { value: value, status: 5 };
+        break;
+      case "meaning":
+        mData.meaning = { value: value.split("$"), status: 5 };
+        break;
+      case "factors":
+        mData.factors = { value: value, status: 5 };
+        break;
+      case "factorMeaning":
+        mData.factorMeaning = { value: value, status: 5 };
+        break;
+      case "parent":
+        mData.parent = { value: value, status: 5 };
+        break;
+      case "case":
+        mData.case = { value: value.split("$"), status: 5 };
+        break;
+      case "confidence":
+        mData.confidence = parseFloat(value);
+        break;
+      default:
+        break;
+    }
+    setEditWord(mData);
+  }
+  return (
+    <div style={{ padding: "0 5px" }}>
+      <Row>
+        <Col
+          span={4}
+          style={{
+            display: "inline-block",
+            flexGrow: 0,
+            overflow: "hidden",
+            whiteSpace: "nowrap",
+            textAlign: "right",
+            verticalAlign: "middle",
+            padding: 5,
+          }}
+        >
+          拼写
+        </Col>
+        <Col span={20}>
+          <Input
+            value={wordSpell}
+            placeholder="Basic usage"
+            onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+              setWordSpell(event.target.value);
+              fieldChanged("word", event.target.value);
+              fieldChanged("real", event.target.value);
+            }}
+          />
+        </Col>
+      </Row>
+
+      <WbwDetailBasic
+        data={editWord}
+        onChange={(e: IWbwField) => {
+          console.log("WbwDetailBasic onchange", e);
+          fieldChanged(e.field, e.value);
+        }}
+      />
+      <Divider>{intl.formatMessage({ id: "buttons.note" })}</Divider>
+      <WbwDetailNote
+        data={editWord}
+        onChange={(e: IWbwField) => {
+          fieldChanged(e.field, e.value);
+        }}
+      />
+      <Divider></Divider>
+      <div
+        style={{ display: "flex", justifyContent: "space-between", padding: 5 }}
+      >
+        <Button>重置</Button>
+        <Button
+          loading={loading}
+          icon={<SaveOutlined />}
+          onClick={() => {
+            console.log("edit word", editWord);
+            setLoading(true);
+            post<IUserDictCreate, IDictResponse>("/v2/userdict", {
+              view: "dict",
+              data: JSON.stringify([
+                {
+                  word: editWord.word.value,
+                  type: editWord.type?.value,
+                  grammar: editWord.grammar?.value,
+                  mean: editWord.meaning?.value.join("$"),
+                  parent: editWord.parent?.value,
+                  note: editWord.note?.value,
+                  factors: editWord.factors?.value,
+                  factormean: editWord.factorMeaning?.value,
+                  confidence: editWord.confidence,
+                },
+              ]),
+            })
+              .finally(() => {
+                setLoading(false);
+              })
+              .then((json) => {
+                if (json.ok) {
+                  message.success("成功");
+                } else {
+                  message.error(json.message);
+                }
+              });
+          }}
+          type="primary"
+        >
+          {intl.formatMessage({ id: "buttons.save" })}
+        </Button>
+      </div>
+    </div>
+  );
+};
+
+export default Widget;

+ 3 - 0
dashboard/src/components/dict/SearchVocabulary.tsx

@@ -109,6 +109,9 @@ const Widget = ({ value, onSplit, onSearch }: IWidget) => {
             }
           } else {
             setFactors([]);
+            if (typeof onSplit !== "undefined") {
+              onSplit();
+            }
           }
         }}
         onSearch={(value: string) => {

+ 52 - 0
dashboard/src/components/exp/ExpPie.tsx

@@ -0,0 +1,52 @@
+import { Pie } from "@ant-design/plots";
+
+export interface IPieData {
+  type: string;
+  value: number;
+}
+interface IWidget {
+  data?: IPieData[];
+}
+const Widget = ({ data = [] }: IWidget) => {
+  console.log("pie data", data);
+  const config = {
+    appendPadding: 10,
+    data,
+    angleField: "value",
+    colorField: "type",
+    radius: 1,
+    innerRadius: 0.6,
+    label: {
+      type: "inner",
+      offset: "-50%",
+      content: "{value}",
+      style: {
+        textAlign: "center",
+        fontSize: 14,
+        display: "none",
+      },
+    },
+    interactions: [
+      {
+        type: "element-selected",
+      },
+      {
+        type: "element-active",
+      },
+    ],
+    statistic: {
+      content: {
+        style: {
+          whiteSpace: "pre-wrap",
+          overflow: "hidden",
+          textOverflow: "ellipsis",
+          display: "none",
+        },
+        content: "a",
+      },
+    },
+  };
+  return <Pie {...config} style={{ height: 120 }} />;
+};
+
+export default Widget;

+ 110 - 0
dashboard/src/components/exp/ExpStatisticCard.tsx

@@ -0,0 +1,110 @@
+import { StatisticCard } from "@ant-design/pro-components";
+import { message } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import { IUserStatisticResponse } from "../api/Exp";
+import ExpPie, { IPieData } from "./ExpPie";
+
+const { Divider } = StatisticCard;
+
+interface IWidget {
+  studioName?: string;
+}
+const Widget = ({ studioName }: IWidget) => {
+  const [expSum, setExpSum] = useState<number>();
+  const [wbwCount, setWbwCount] = useState<number>();
+  const [lookupCount, setLookupCount] = useState<number>();
+  const [translationCount, setTranslationCount] = useState<number>();
+  const [translationPubCount, setTranslationPubCount] = useState<number>();
+  const [translationPieData, setTranslationPieData] = useState<IPieData[]>();
+  const [termCount, setTermCount] = useState<number>();
+  const [termNoteCount, setTermNoteCount] = useState<number>();
+  const [termPieData, setTermPieData] = useState<IPieData[]>();
+  const [dictCount, setDictCount] = useState<number>();
+  useEffect(() => {
+    get<IUserStatisticResponse>(`/v2/user-statistic/${studioName}`).then(
+      (json) => {
+        if (json.ok) {
+          setExpSum(Math.ceil(json.data.exp.sum / 1000 / 60 / 60));
+          setWbwCount(json.data.wbw.count);
+          setLookupCount(json.data.lookup.count);
+          setTranslationCount(json.data.translation.count);
+          setTranslationPubCount(json.data.translation.count_pub);
+          setTranslationPieData([
+            { type: "公开", value: json.data.translation.count_pub },
+            {
+              type: "未公开",
+              value:
+                json.data.translation.count - json.data.translation.count_pub,
+            },
+          ]);
+          setTermCount(json.data.term.count);
+          setTermNoteCount(json.data.term.count_with_note);
+          setTermPieData([
+            { type: "百科", value: json.data.term.count_with_note },
+            {
+              type: "仅术语",
+              value: json.data.term.count - json.data.term.count_with_note,
+            },
+          ]);
+          setDictCount(json.data.dict.count);
+        } else {
+          message.error(json.message);
+        }
+      }
+    );
+  }, [studioName]);
+
+  return (
+    <StatisticCard.Group>
+      <StatisticCard
+        statistic={{
+          title: "总经验",
+          tip: "帮助文字",
+          value: expSum,
+          suffix: "小时",
+        }}
+      />
+      <Divider />
+      <StatisticCard
+        statistic={{
+          title: "逐词解析",
+          value: wbwCount,
+          suffix: "词",
+        }}
+      />
+      <StatisticCard
+        statistic={{
+          title: "查字典",
+          value: lookupCount,
+          suffix: "次",
+        }}
+      />
+      <StatisticCard
+        statistic={{
+          title: "译文",
+          value: translationCount,
+          suffix: "句",
+        }}
+        chart={<ExpPie data={translationPieData} />}
+      />
+      <StatisticCard
+        statistic={{
+          title: "术语",
+          value: termCount,
+          suffix: "词",
+        }}
+        chart={<ExpPie data={termPieData} />}
+      />
+      <StatisticCard
+        statistic={{
+          title: "单词本",
+          value: dictCount,
+          suffix: "词",
+        }}
+      />
+    </StatisticCard.Group>
+  );
+};
+
+export default Widget;

+ 168 - 0
dashboard/src/components/exp/Heatmap.tsx

@@ -0,0 +1,168 @@
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+import { message, Select, Typography } from "antd";
+
+import { get } from "../../request";
+import { IUserOperationDailyResponse } from "../api/Exp";
+
+interface IOptions {
+  value: string;
+  label: string;
+}
+interface IDailyData {
+  date: string;
+  commits: number;
+  year: number;
+  month: number;
+  day: number;
+  week: number;
+}
+
+interface IWidget {
+  studioName?: string;
+}
+const Widget = ({ studioName }: IWidget) => {
+  const [dailyData, setDailyData] = useState<IDailyData[]>([]);
+  const [year, setYear] = useState<IOptions[]>([]);
+
+  const thisYear = new Date().getFullYear();
+  const [currYear, setCurrYear] = useState<string>(thisYear.toString());
+  const intl = useIntl();
+
+  useEffect(() => {
+    get<IUserOperationDailyResponse>(
+      `/v2/user-operation-daily?view=user-year&studio_name=${studioName}&year=2021`
+    ).then((json) => {
+      if (json.ok) {
+        //找到起止年份
+        if (json.data.rows.length > 0) {
+          const yearStart = new Date(json.data.rows[0].date_int).getFullYear();
+          const yearEnd = new Date(
+            json.data.rows[json.data.rows.length - 1].date_int
+          ).getFullYear();
+          let yearOption: IOptions[] = [];
+          for (let index = yearStart; index <= yearEnd; index++) {
+            yearOption.push({
+              value: index.toString(),
+              label: index.toString(),
+            });
+          }
+          setYear(yearOption);
+        }
+
+        const data = json.data.rows.map((item) => {
+          const date = new Date(item.date_int);
+          const oneJan = new Date(date.getFullYear(), 0, 1);
+          const week = Math.ceil(
+            ((date.getTime() - oneJan.getTime()) / 86400000 +
+              oneJan.getDay() +
+              1) /
+              7
+          );
+
+          return {
+            date:
+              date.getFullYear() +
+              "-" +
+              (date.getMonth() + 1) +
+              "-" +
+              date.getDate(),
+            year: date.getFullYear(),
+            month: date.getMonth() + 1,
+            day: date.getDay(),
+            week: week,
+            commits: item.duration / 1000 / 60,
+          };
+        });
+        console.log("data", data);
+        setDailyData(data);
+      } else {
+        message.error(json.message);
+      }
+    });
+  }, [studioName]);
+
+  const yAxisLabel = new Array(7).fill(1).map((item, id) => {
+    return (
+      <div key={id} style={{ display: "inline-block", width: "5em" }}>
+        <Typography.Text>
+          {intl.formatMessage({ id: `labels.week.${id}` })}
+        </Typography.Text>
+      </div>
+    );
+  });
+  const dayColor = ["#9be9a8", "#40c463", "#30a14e", "#216e39"];
+  const weeks = new Array(54).fill(1);
+  const heatmap = weeks.map((item, week) => {
+    const days = new Array(7).fill(1);
+    return (
+      <div key={week}>
+        {days.map((itemDay, day) => {
+          const time = dailyData.find(
+            (value) =>
+              value.year === parseInt(currYear) &&
+              value.week === week &&
+              value.day === day
+          )?.commits;
+          const color = time
+            ? time > 120
+              ? dayColor[0]
+              : time > 60
+              ? dayColor[1]
+              : time > 30
+              ? dayColor[2]
+              : time > 5
+              ? dayColor[3]
+              : "rgba(0,0,0,0)"
+            : "rgba(0,0,0,0)";
+          return (
+            <div
+              key={day}
+              style={{
+                display: "inline-block",
+                width: 12,
+                height: 12,
+                backgroundColor: `rgba(128,128,128,0.2)`,
+                margin: 0,
+                borderRadius: 2,
+                outline: "1px solid gray",
+              }}
+            >
+              <div
+                style={{
+                  width: 12,
+                  height: 12,
+                  backgroundColor: color,
+                }}
+              ></div>
+            </div>
+          );
+        })}
+      </div>
+    );
+  });
+
+  return (
+    <div style={{ width: 1000 }}>
+      <div style={{ textAlign: "right" }} key="toolbar">
+        <Select
+          defaultValue={thisYear.toString()}
+          style={{ width: 120 }}
+          onChange={(value: string) => {
+            console.log(`selected ${value}`);
+            setCurrYear(value);
+          }}
+          options={year}
+        />
+      </div>
+      <div style={{ display: "flex" }} key="map">
+        <div style={{ width: "5em" }} key="label">
+          {yAxisLabel}
+        </div>
+        {heatmap}
+      </div>
+    </div>
+  );
+};
+
+export default Widget;

+ 72 - 0
dashboard/src/components/exp/StudyTimeDualAxes.tsx

@@ -0,0 +1,72 @@
+import { DualAxes } from "@ant-design/plots";
+import { message } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import { IUserOperationDailyResponse } from "../api/Exp";
+
+interface IDailyData {
+  time: string;
+  sum: number;
+  hit: number;
+}
+
+interface IWidget {
+  studioName?: string;
+}
+const Widget = ({ studioName }: IWidget) => {
+  const [dailyData, setDailyData] = useState<IDailyData[]>([]);
+
+  useEffect(() => {
+    get<IUserOperationDailyResponse>(
+      `/v2/user-operation-daily?view=user-all&studio_name=${studioName}`
+    ).then((json) => {
+      if (json.ok) {
+        let timeSum = 0;
+        const data = json.data.rows.map((item) => {
+          const date = new Date(item.date_int);
+          timeSum += item.duration / 1000 / 3600;
+          return {
+            time:
+              date.getFullYear() +
+              "-" +
+              (date.getMonth() + 1) +
+              "-" +
+              date.getDate(),
+            sum: timeSum,
+            hit: item.hit ? item.hit : 0,
+          };
+        });
+        console.log("data", data);
+        setDailyData(data);
+      } else {
+        message.error(json.message);
+      }
+    });
+  }, [studioName]);
+
+  const config = {
+    data: [dailyData, dailyData],
+    xField: "time",
+    yField: ["hit", "sum"],
+    limitInPlot: false,
+    padding: [10, 20, 80, 30],
+    // 需要设置底部 padding 值,同 css
+    slider: {},
+    meta: {
+      time: {
+        sync: false, // 开启之后 slider 无法重绘
+      },
+    },
+    geometryOptions: [
+      {
+        geometry: "column",
+      },
+      {
+        geometry: "line",
+      },
+    ],
+  };
+  return <DualAxes {...config} />;
+};
+
+export default Widget;

+ 15 - 7
dashboard/src/components/general/TimeShow.tsx

@@ -1,30 +1,38 @@
 import { Space, Tooltip } from "antd";
 import { useIntl } from "react-intl";
 import { FieldTimeOutlined } from "@ant-design/icons";
+import { useEffect, useState } from "react";
 
 interface IWidgetTimeShow {
   showIcon?: boolean;
-  showTitle?: boolean;
   showTooltip?: boolean;
   time?: string;
-  title: string;
+  title?: string;
 }
 
 const Widget = ({
   showIcon = true,
-  showTitle = false,
   showTooltip = true,
   time,
   title,
 }: IWidgetTimeShow) => {
   const intl = useIntl(); //i18n
-  if (typeof time === "undefined") {
+  const [passTime, setPassTime] = useState<string>();
+  const updateTime = () => {
+    if (typeof time !== "undefined" && time !== "") {
+      setPassTime(getPassDataTime(time));
+    }
+  };
+
+  useEffect(() => {
+    updateTime();
+  }, [time]);
+
+  if (typeof time === "undefined" || time === "") {
     return <></>;
   }
   const icon = showIcon ? <FieldTimeOutlined /> : <></>;
-  const strTitle = showTitle ? title : "";
 
-  const passTime: string = getPassDataTime(time);
   const tooltip: string = getFullDataTime(time);
   const color = "lime";
   function getPassDataTime(t: string): string {
@@ -83,7 +91,7 @@ const Widget = ({
     <Tooltip title={tooltip} color={color} key={color}>
       <Space>
         {icon}
-        {strTitle}
+        {title}
         {passTime}
       </Space>
     </Tooltip>

+ 8 - 2
dashboard/src/components/nut/Home.tsx

@@ -7,11 +7,17 @@ import FontBox from "./FontBox";
 import DemoForm from "./Form";
 import TreeTest from "./TreeTest";
 import Share from "../share/Share";
+import ChannelPicker from "../channel/ChannelPicker";
+import { Layout } from "antd";
 
 const Widget = () => {
   return (
-    <div>
+    <Layout>
       <h1>Home</h1>
+      <ChannelPicker
+        type="chapter"
+        articleId="168-867_7fea264d-7a26-40f8-bef7-bc95102760fb"
+      />
       <div>
         <Share resId="dd" resType="dd" />
       </div>
@@ -31,7 +37,7 @@ const Widget = () => {
       <div>
         <ReactMarkdown>*This* is text with `quote`</ReactMarkdown>
       </div>
-    </div>
+    </Layout>
   );
 };
 

+ 2 - 4
dashboard/src/components/nut/users/SignIn.tsx

@@ -33,8 +33,6 @@ const Widget = () => {
   return (
     <ProForm<IFormData>
       onFinish={async (values: IFormData) => {
-        // TODO
-        console.log(values);
         const user = {
           username: values.email,
           password: values.password,
@@ -44,7 +42,6 @@ const Widget = () => {
           user
         );
         if (signin.ok) {
-          console.log("token", signin.data);
           localStorage.setItem("token", signin.data);
           get<IUserResponse>("/v2/auth/current").then((json) => {
             if (json.ok) {
@@ -54,6 +51,7 @@ const Widget = () => {
               console.error(json.message);
             }
           });
+
           message.success(intl.formatMessage({ id: "flashes.success" }));
         } else {
           message.error(signin.message);
@@ -68,7 +66,7 @@ const Widget = () => {
           label={intl.formatMessage({
             id: "forms.fields.email.or.username.label",
           })}
-          rules={[{ required: true, max: 255, min: 6 }]}
+          rules={[{ required: true, max: 255, min: 4 }]}
         />
       </ProForm.Group>
       <ProForm.Group>

+ 3 - 3
dashboard/src/components/share/Share.tsx

@@ -3,12 +3,12 @@ import { Divider, List, message, Select } from "antd";
 import { useState } from "react";
 import { useIntl } from "react-intl";
 import { get, post } from "../../request";
-import { IUserApiData, IUserListResponse, Role } from "../api/Auth";
+import { IUserApiData, IUserListResponse, TRole } from "../api/Auth";
 import { IShareData, IShareRequest, IShareResponse } from "../api/Share";
 
 interface IShareUserList {
   user: IUserApiData;
-  role: Role;
+  role: TRole;
 }
 interface IWidget {
   resId: string;
@@ -21,7 +21,7 @@ const Widget = ({ resId, resType }: IWidget) => {
   interface IFormData {
     userId: string;
     userType: string;
-    role: Role;
+    role: TRole;
   }
   return (
     <div>

+ 23 - 3
dashboard/src/components/studio/LeftSider.tsx

@@ -31,8 +31,9 @@ const Widget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
   const linkTerm = "/studio/" + studioname + "/term/list";
   const linkArticle = "/studio/" + studioname + "/article/list";
   const linkAnthology = "/studio/" + studioname + "/anthology/list";
-  const linkAnalysis = "/studio/" + studioname + "/analysis/list";
+  const linkAnalysis = "/studio/" + studioname + "/exp/list";
   const linkCourse = "/studio/" + studioname + "/course/list";
+  const linkSetting = "/studio/" + studioname + "/setting";
 
   const items: MenuProps["items"] = [
     {
@@ -76,12 +77,11 @@ const Widget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
           label: (
             <Link to={linkAnalysis}>
               {intl.formatMessage({
-                id: "columns.studio.analysis.title",
+                id: "columns.exp.title",
               })}
             </Link>
           ),
           key: "analysis",
-          disabled: true,
         },
       ],
     },
@@ -140,6 +140,26 @@ const Widget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
           ),
           key: "anthology",
         },
+        {
+          label: (
+            <Link to={linkSetting}>
+              {intl.formatMessage({
+                id: "columns.studio.setting.title",
+              })}
+            </Link>
+          ),
+          key: "setting",
+          children: [
+            {
+              label: "账户",
+              key: "account",
+            },
+            {
+              label: "显示",
+              key: "display",
+            },
+          ],
+        },
       ],
     },
     {

+ 3 - 1
dashboard/src/components/template/MdView.tsx

@@ -1,4 +1,6 @@
+import { Typography } from "antd";
 import { TCodeConvertor, XmlToReact } from "./utilities";
+const { Text } = Typography;
 
 interface IWidget {
   html?: string;
@@ -11,7 +13,7 @@ const Widget = ({
   convertor,
 }: IWidget) => {
   const jsx = XmlToReact(html, wordWidget, convertor);
-  return <>{jsx}</>;
+  return <Text>{jsx}</Text>;
 };
 
 export default Widget;

+ 7 - 2
dashboard/src/components/template/SentEdit.tsx

@@ -1,4 +1,5 @@
 import { Card } from "antd";
+import { IStudio } from "../auth/StudioName";
 
 import type { IUser } from "../auth/User";
 import { IChannel } from "../channel/Channel";
@@ -6,11 +7,12 @@ import SentContent from "./SentEdit/SentContent";
 import SentMenu from "./SentEdit/SentMenu";
 import SentTab from "./SentEdit/SentTab";
 
-interface ISuggestionCount {
+export interface ISuggestionCount {
   suggestion?: number;
-  qa?: number;
+  discussion?: number;
 }
 export interface ISentence {
+  id?: string;
   content: string;
   html: string;
   book: number;
@@ -18,7 +20,10 @@ export interface ISentence {
   wordStart: number;
   wordEnd: number;
   editor: IUser;
+  acceptor?: IUser;
+  prEditAt?: string;
   channel: IChannel;
+  studio?: IStudio;
   updateAt: string;
   suggestionCount?: ISuggestionCount;
 }

+ 16 - 4
dashboard/src/components/template/SentEdit/EditInfo.tsx

@@ -1,6 +1,7 @@
 import { Typography } from "antd";
 import { Space } from "antd";
 
+import StudioName from "../../auth/StudioName";
 import User from "../../auth/User";
 import TimeShow from "../../general/TimeShow";
 import { ISentence } from "../SentEdit";
@@ -9,15 +10,26 @@ const { Text } = Typography;
 
 interface IWidget {
   data: ISentence;
+  isPr?: boolean;
 }
-const Widget = ({ data }: IWidget) => {
+const Widget = ({ data, isPr = false }: IWidget) => {
   return (
     <div style={{ fontSize: "80%" }}>
       <Text type="secondary">
         <Space>
-          <User {...data.editor} />
-          <span>updated</span>
-          <TimeShow time={data.updateAt} title="UpdatedAt" />
+          {isPr ? undefined : <StudioName data={data.studio} />}
+          <User {...data.editor} showAvatar={isPr ? true : false} />
+          <span>edit</span>
+          {data.prEditAt ? (
+            <TimeShow time={data.prEditAt} />
+          ) : (
+            <TimeShow time={data.updateAt} />
+          )}
+          {data.acceptor ? (
+            <User {...data.acceptor} showAvatar={false} />
+          ) : undefined}
+          {data.acceptor ? "accept at" : undefined}
+          {data.prEditAt ? <TimeShow time={data.updateAt} /> : undefined}
         </Space>
       </Text>
     </div>

+ 85 - 0
dashboard/src/components/template/SentEdit/PrAcceptButton.tsx

@@ -0,0 +1,85 @@
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import { Button, message } from "antd";
+import { CheckOutlined } from "@ant-design/icons";
+
+import { put } from "../../../request";
+import { ISentenceRequest, ISentenceResponse } from "../../api/Corpus";
+import { ISentence } from "../SentEdit";
+import store from "../../../store";
+import { accept } from "../../../reducers/accept-pr";
+
+interface IWidget {
+  data: ISentence;
+  onAccept?: Function;
+}
+const Widget = ({ data, onAccept }: IWidget) => {
+  const intl = useIntl();
+
+  const [saving, setSaving] = useState<boolean>(false);
+
+  const save = () => {
+    setSaving(true);
+    put<ISentenceRequest, ISentenceResponse>(
+      `/v2/sentence/${data.book}_${data.para}_${data.wordStart}_${data.wordEnd}_${data.channel.id}`,
+      {
+        book: data.book,
+        para: data.para,
+        wordStart: data.wordStart,
+        wordEnd: data.wordEnd,
+        channel: data.channel.id,
+        content: data.content,
+        prEditor: data.editor.id,
+        prId: data.id,
+        prEditAt: data.updateAt,
+      }
+    )
+      .then((json) => {
+        console.log(json);
+        setSaving(false);
+
+        if (json.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+
+          const newData: ISentence = {
+            id: json.data.id,
+            content: json.data.content,
+            html: json.data.html,
+            book: json.data.book,
+            para: json.data.paragraph,
+            wordStart: json.data.word_start,
+            wordEnd: json.data.word_end,
+            editor: json.data.editor,
+            channel: json.data.channel,
+            updateAt: json.data.updated_at,
+            acceptor: json.data.acceptor,
+            prEditAt: json.data.pr_edit_at,
+            suggestionCount: json.data.suggestionCount,
+          };
+          store.dispatch(accept(newData));
+          if (typeof onAccept !== "undefined") {
+            onAccept(newData);
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e) => {
+        setSaving(false);
+        console.error("catch", e);
+        message.error(e.message);
+      });
+  };
+
+  return (
+    <Button
+      size="small"
+      type="text"
+      icon={<CheckOutlined />}
+      loading={saving}
+      onClick={() => save()}
+    />
+  );
+};
+
+export default Widget;

+ 31 - 8
dashboard/src/components/template/SentEdit/SentCell.tsx

@@ -1,20 +1,39 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
 
 import { ISentence } from "../SentEdit";
 import SentEditMenu from "./SentEditMenu";
 import SentCellEditable from "./SentCellEditable";
 import MdView from "../MdView";
-import SuggestionTabs from "./SuggestionTabs";
 import EditInfo from "./EditInfo";
+import SuggestionToolbar from "./SuggestionToolbar";
+import { Divider } from "antd";
+import { useAppSelector } from "../../../hooks";
+import { sentence } from "../../../reducers/accept-pr";
 
 interface ISentCell {
   data: ISentence;
   wordWidget?: boolean;
+  isPr?: boolean;
 }
-const Widget = ({ data, wordWidget = false }: ISentCell) => {
+const Widget = ({ data, wordWidget = false, isPr = false }: ISentCell) => {
   const [isEditMode, setIsEditMode] = useState(false);
   const [sentData, setSentData] = useState<ISentence>(data);
-
+  const acceptPr = useAppSelector(sentence);
+  useEffect(() => {
+    setSentData(data);
+  }, [data]);
+  useEffect(() => {
+    if (typeof acceptPr !== "undefined" && !isPr) {
+      if (
+        acceptPr.book === data.book &&
+        acceptPr.para === data.para &&
+        acceptPr.wordStart === data.wordStart &&
+        acceptPr.wordEnd === data.wordEnd &&
+        acceptPr.channel.id === data.channel.id
+      )
+        setSentData(acceptPr);
+    }
+  }, [acceptPr, data, isPr]);
   return (
     <div style={{ marginBottom: "8px" }}>
       <SentEditMenu
@@ -24,8 +43,10 @@ const Widget = ({ data, wordWidget = false }: ISentCell) => {
           }
         }}
       >
-        <EditInfo data={data} />
-        <div style={{ display: isEditMode ? "none" : "block" }}>
+        <EditInfo data={sentData} />
+        <div
+          style={{ display: isEditMode ? "none" : "block", marginLeft: "2em" }}
+        >
           <MdView
             html={sentData.html !== "" ? sentData.html : "请输入"}
             wordWidget={wordWidget}
@@ -34,6 +55,7 @@ const Widget = ({ data, wordWidget = false }: ISentCell) => {
         <div style={{ display: isEditMode ? "block" : "none" }}>
           <SentCellEditable
             data={sentData}
+            isPr={isPr}
             onClose={() => {
               setIsEditMode(false);
             }}
@@ -43,10 +65,11 @@ const Widget = ({ data, wordWidget = false }: ISentCell) => {
           />
         </div>
 
-        <div>
-          <SuggestionTabs data={data} />
+        <div style={{ marginLeft: "2em" }}>
+          <SuggestionToolbar data={data} isPr={isPr} />
         </div>
       </SentEditMenu>
+      <Divider style={{ margin: "10px 0" }} />
     </div>
   );
 };

+ 49 - 6
dashboard/src/components/template/SentEdit/SentCellEditable.tsx

@@ -4,8 +4,13 @@ import { Button, message, Typography } from "antd";
 import { SaveOutlined } from "@ant-design/icons";
 import TextArea from "antd/lib/input/TextArea";
 
-import { put } from "../../../request";
-import { ISentenceRequest, ISentenceResponse } from "../../api/Corpus";
+import { post, put } from "../../../request";
+import {
+  ISentencePrRequest,
+  ISentencePrResponse,
+  ISentenceRequest,
+  ISentenceResponse,
+} from "../../api/Corpus";
 import { ISentence } from "../SentEdit";
 
 const { Text } = Typography;
@@ -14,12 +19,50 @@ interface ISentCellEditable {
   data: ISentence;
   onDataChange?: Function;
   onClose?: Function;
+  onCreate?: Function;
+  isPr?: boolean;
 }
-const Widget = ({ data, onDataChange, onClose }: ISentCellEditable) => {
+const Widget = ({
+  data,
+  onDataChange,
+  onClose,
+  onCreate,
+  isPr = false,
+}: ISentCellEditable) => {
   const intl = useIntl();
   const [value, setValue] = useState(data.content);
   const [saving, setSaving] = useState<boolean>(false);
 
+  const savePr = () => {
+    setSaving(true);
+    post<ISentencePrRequest, ISentencePrResponse>(`/v2/sentpr`, {
+      book: data.book,
+      para: data.para,
+      begin: data.wordStart,
+      end: data.wordEnd,
+      channel: data.channel.id,
+      text: value,
+    })
+      .then((json) => {
+        console.log(json);
+        setSaving(false);
+
+        if (json.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e) => {
+        setSaving(false);
+        console.error("catch", e);
+        message.error(e.message);
+      });
+  };
+
   const save = () => {
     setSaving(true);
     put<ISentenceRequest, ISentenceResponse>(
@@ -85,7 +128,7 @@ const Widget = ({ data, onDataChange, onClose }: ISentCellEditable) => {
                 }
               }}
             >
-              cancel
+              {intl.formatMessage({ id: "buttons.cancel" })}
             </Button>
           </span>
           <span>
@@ -102,9 +145,9 @@ const Widget = ({ data, onDataChange, onClose }: ISentCellEditable) => {
             type="primary"
             icon={<SaveOutlined />}
             loading={saving}
-            onClick={() => save()}
+            onClick={() => (isPr ? savePr() : save())}
           >
-            Save
+            {intl.formatMessage({ id: "buttons.save" })}
           </Button>
         </div>
       </div>

+ 8 - 4
dashboard/src/components/template/SentEdit/SuggestionAdd.tsx

@@ -5,10 +5,11 @@ import { PlusOutlined } from "@ant-design/icons";
 import { ISentence } from "../SentEdit";
 import SentCellEditable from "./SentCellEditable";
 
-interface ISentCell {
+interface IWidget {
   data: ISentence;
+  onCreate?: Function;
 }
-const Widget = ({ data }: ISentCell) => {
+const Widget = ({ data, onCreate }: IWidget) => {
   const [isEditMode, setIsEditMode] = useState(false);
   const [sentData, setSentData] = useState<ISentence>(data);
 
@@ -29,11 +30,14 @@ const Widget = ({ data }: ISentCell) => {
       <div style={{ display: isEditMode ? "block" : "none" }}>
         <SentCellEditable
           data={sentData}
+          isPr={true}
           onClose={() => {
             setIsEditMode(false);
           }}
-          onDataChange={(data: ISentence) => {
-            setSentData(data);
+          onCreate={() => {
+            if (typeof onCreate !== "undefined") {
+              onCreate();
+            }
           }}
         />
       </div>

+ 94 - 0
dashboard/src/components/template/SentEdit/SuggestionBox.tsx

@@ -0,0 +1,94 @@
+import { useEffect, useState } from "react";
+import { Button, Card, Drawer, Space } from "antd";
+
+import SuggestionList from "./SuggestionList";
+import SuggestionAdd from "./SuggestionAdd";
+import { ISentence } from "../SentEdit";
+import Marked from "../../general/Marked";
+
+export interface IAnswerCount {
+  id: string;
+  count: number;
+}
+interface IWidget {
+  data: ISentence;
+  trigger?: JSX.Element;
+}
+const Widget = ({ trigger, data }: IWidget) => {
+  const [open, setOpen] = useState(false);
+  const [reload, setReload] = useState(false);
+  const [openNotification, setOpenNotification] = useState(false);
+
+  useEffect(() => {
+    if (localStorage.getItem("read_pr_Notification") === "ok") {
+      setOpenNotification(false);
+    } else {
+      setOpenNotification(true);
+    }
+  }, []);
+  const showDrawer = () => {
+    setOpen(true);
+  };
+
+  const onClose = () => {
+    setOpen(false);
+  };
+  const sid = `${data.book}_${data.para}_${data.wordStart}_${data.wordEnd}_${data.channel.id}`;
+  const hasPr = data.suggestionCount?.suggestion ? "true" : "false";
+  return (
+    <>
+      <span onClick={showDrawer}>{trigger}</span>
+      <div
+        dangerouslySetInnerHTML={{
+          __html: `<div class="pr_icon" id="${sid}" has-pr="${hasPr}" data-pr="${data.suggestionCount?.suggestion}"></div>`,
+        }}
+      />
+      <Drawer
+        title="修改建议"
+        width={520}
+        onClose={onClose}
+        open={open}
+        maskClosable={false}
+      >
+        <Space direction="vertical" style={{ width: "100%" }}>
+          <Card
+            title="温馨提示"
+            size="small"
+            style={{
+              width: "100%",
+              display: openNotification ? "block" : "none",
+            }}
+          >
+            <Marked
+              text="此处专为提交修改建议译文。您输入的应该是**译文**
+              而不是评论和问题。其他内容,请在讨论页面提交。"
+            />
+            <p style={{ textAlign: "center" }}>
+              <Button
+                onClick={() => {
+                  localStorage.setItem("read_pr_Notification", "ok");
+                  setOpenNotification(false);
+                }}
+              >
+                知道了
+              </Button>
+            </p>
+          </Card>
+          <SuggestionAdd
+            data={data}
+            onCreate={() => {
+              setReload(true);
+            }}
+          />
+          <SuggestionList
+            {...data}
+            reload={reload}
+            onReload={() => setReload(false)}
+          />
+        </Space>
+      </Drawer>
+    </>
+  );
+};
+
+export default Widget;

+ 52 - 26
dashboard/src/components/template/SentEdit/SuggestionList.tsx

@@ -1,3 +1,4 @@
+import { message } from "antd";
 import { useEffect, useState } from "react";
 
 import { get } from "../../../request";
@@ -11,41 +12,66 @@ interface IWidget {
   wordStart: number;
   wordEnd: number;
   channel: IChannel;
+  reload?: boolean;
+  onReload?: Function;
 }
-const Widget = ({ book, para, wordStart, wordEnd, channel }: IWidget) => {
+const Widget = ({
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  channel,
+  reload = false,
+  onReload,
+}: IWidget) => {
   const [sentData, setSentData] = useState<ISentence[]>([]);
 
-  useEffect(() => {
+  const load = () => {
     get<ISuggestionListResponse>(
       `/v2/sentpr?view=sent-info&book=${book}&para=${para}&start=${wordStart}&end=${wordEnd}&channel=${channel.id}`
-    ).then((json) => {
-      const newData: ISentence[] = json.data.rows.map((item) => {
-        return {
-          content: item.content,
-          html: item.html,
-          book: item.book,
-          para: item.paragraph,
-          wordStart: item.word_start,
-          wordEnd: item.word_end,
-          editor: {
-            id: item.editor.id,
-            nickName: item.editor.nickName,
-            realName: item.editor.userName,
-            avatar: item.editor.avatar,
-          },
-          channel: { name: item.channel.name, id: item.channel.id },
-          updateAt: item.updated_at,
-        };
+    )
+      .then((json) => {
+        if (json.ok) {
+          console.log("pr load", json.data.rows);
+          const newData: ISentence[] = json.data.rows.map((item) => {
+            return {
+              id: item.id,
+              content: item.content,
+              html: item.html,
+              book: item.book,
+              para: item.paragraph,
+              wordStart: item.word_start,
+              wordEnd: item.word_end,
+              editor: item.editor,
+              channel: { name: item.channel.name, id: item.channel.id },
+              updateAt: item.updated_at,
+            };
+          });
+          setSentData(newData);
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        if (reload && typeof onReload !== "undefined") {
+          onReload();
+        }
       });
-      setSentData(newData);
-    });
-  }, [book, para, wordStart, wordEnd, channel]);
+  };
+  useEffect(() => {
+    load();
+  }, []);
+  useEffect(() => {
+    if (reload) {
+      load();
+    }
+  }, [reload]);
   return (
-    <div>
+    <>
       {sentData.map((item, id) => {
-        return <SentCell data={item} key={id} />;
+        return <SentCell data={item} key={id} isPr={true} />;
       })}
-    </div>
+    </>
   );
 };
 

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

@@ -0,0 +1,53 @@
+import { Divider, Space, Typography } from "antd";
+import { CommentOutlined, LikeOutlined } from "@ant-design/icons";
+import { ISentence } from "../SentEdit";
+import { useState } from "react";
+import CommentBox from "../../comment/CommentBox";
+import SuggestionBox from "./SuggestionBox";
+import PrAcceptButton from "./PrAcceptButton";
+import { HandOutlinedIcon } from "../../../assets/icon";
+
+const { Text } = Typography;
+
+interface IWidget {
+  data: ISentence;
+  isPr?: boolean;
+  onAccept?: Function;
+}
+const Widget = ({ data, isPr = false, onAccept }: IWidget) => {
+  const [CommentCount, setCommentCount] = useState<number | undefined>(
+    data.suggestionCount?.discussion
+  );
+  const prButton = (
+    <Space>
+      <LikeOutlined />
+      <Divider type="vertical" />
+      <PrAcceptButton
+        data={data}
+        onAccept={(value: ISentence) => {
+          if (typeof onAccept !== "undefined") {
+            onAccept(value);
+          }
+        }}
+      />
+    </Space>
+  );
+  const normalButton = (
+    <Space>
+      <SuggestionBox data={data} trigger={<HandOutlinedIcon />} />
+      {data.suggestionCount?.suggestion} <Divider type="vertical" />
+      <CommentBox
+        resId={data.id}
+        resType="sentence"
+        trigger={<CommentOutlined />}
+        onCommentCountChange={(count: number) => {
+          setCommentCount(count);
+        }}
+      />
+      {CommentCount}
+    </Space>
+  );
+  return <Text type="secondary">{isPr ? prButton : normalButton}</Text>;
+};
+
+export default Widget;

+ 7 - 2
dashboard/src/components/template/SentRead.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useRef, useState } from "react";
-import { Tooltip, Button } from "antd";
+import { Tooltip, Button, Typography } from "antd";
 
 import { useAppSelector } from "../../hooks";
 import {
@@ -13,6 +13,7 @@ import { ISentence } from "./SentEdit";
 import MdView from "./MdView";
 import store from "../../store";
 import { push } from "../../reducers/sentence";
+const { Text } = Typography;
 
 interface IWidgetSentReadFrame {
   origin?: ISentence[];
@@ -99,7 +100,11 @@ const SentReadFrame = ({
         <div style={{ flex: "5" }}>
           {translation?.map((item, id) => {
             if (item.html.indexOf("<hr>") >= 0) console.log(item.html);
-            return <MdView key={id} html={item.html} />;
+            return (
+              <Text key={id}>
+                <MdView html={item.html} />
+              </Text>
+            );
           })}
         </div>
       </div>

+ 0 - 1
dashboard/src/components/template/Term.tsx

@@ -82,7 +82,6 @@ interface IWidgetTerm {
 }
 const Widget = ({ props }: IWidgetTerm) => {
   const prop = JSON.parse(atob(props)) as IWidgetTermCtl;
-  console.log(prop);
   return (
     <>
       <TermCtl {...prop} />

+ 36 - 17
dashboard/src/components/template/Wbw/WbwDetail.tsx

@@ -12,6 +12,7 @@ import WbwDetailAdvance from "./WbwDetailAdvance";
 import { LockIcon, UnLockIcon } from "../../../assets/icon";
 import { UploadFile } from "antd/es/upload/interface";
 import { IAttachmentResponse } from "../../api/Attachments";
+import WbwDetailAttachment from "./WbwDetailAttachment";
 
 interface IWidget {
   data: IWbw;
@@ -86,7 +87,7 @@ const Widget = ({ data, onClose, onSave }: IWidget) => {
         type="card"
         items={[
           {
-            label: `basic`,
+            label: intl.formatMessage({ id: "buttons.basic" }),
             key: "basic",
             children: (
               <div>
@@ -101,39 +102,57 @@ const Widget = ({ data, onClose, onSave }: IWidget) => {
             ),
           },
           {
-            label: `bookmark`,
+            label: intl.formatMessage({ id: "buttons.bookmark" }),
             key: "bookmark",
             children: (
-              <WbwDetailBookMark
-                data={data}
-                onChange={(e: IWbwField) => {
-                  fieldChanged(e.field, e.value);
-                }}
-              />
+              <div style={{ minHeight: 270 }}>
+                <WbwDetailBookMark
+                  data={data}
+                  onChange={(e: IWbwField) => {
+                    fieldChanged(e.field, e.value);
+                  }}
+                />
+              </div>
             ),
           },
           {
-            label: `Note`,
+            label: intl.formatMessage({ id: "buttons.note" }),
             key: "note",
             children: (
-              <WbwDetailNote
-                data={data}
-                onChange={(e: IWbwField) => {
-                  fieldChanged(e.field, e.value);
-                }}
-              />
+              <div style={{ minHeight: 270 }}>
+                <WbwDetailNote
+                  data={data}
+                  onChange={(e: IWbwField) => {
+                    fieldChanged(e.field, e.value);
+                  }}
+                />
+              </div>
             ),
           },
           {
-            label: `advance`,
+            label: intl.formatMessage({ id: "buttons.advance" }),
             key: "advance",
             children: (
-              <div>
+              <div style={{ minHeight: 270 }}>
                 <WbwDetailAdvance
                   data={currWbwData}
                   onChange={(e: IWbwField) => {
                     fieldChanged(e.field, e.value);
                   }}
+                />
+              </div>
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "buttons.attachments" }),
+            key: "attachments",
+            children: (
+              <div style={{ minHeight: 270 }}>
+                <WbwDetailAttachment
+                  data={currWbwData}
+                  onChange={(e: IWbwField) => {
+                    fieldChanged(e.field, e.value);
+                  }}
                   onUpload={(fileList: UploadFile<IAttachmentResponse>[]) => {
                     let mData = currWbwData;
                     mData.attachments = fileList.map((item) => {

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

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

+ 44 - 0
dashboard/src/components/template/Wbw/WbwDetailAttachment.tsx

@@ -0,0 +1,44 @@
+import { Input, Divider } from "antd";
+import { UploadFile } from "antd/es/upload/interface";
+import { IAttachmentResponse } from "../../api/Attachments";
+import WbwDetailUpload from "./WbwDetailUpload";
+
+import { IWbw } from "./WbwWord";
+
+interface IWidget {
+  data: IWbw;
+  onChange?: Function;
+  onUpload?: Function;
+}
+const Widget = ({ data, onChange, onUpload }: IWidget) => {
+  const onWordChange = (
+    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+  ) => {
+    console.log("onWordChange:", e.target.value);
+    if (typeof onChange !== "undefined") {
+      onChange({ field: "word", value: e.target.value });
+    }
+  };
+  const onRealChange = (
+    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+  ) => {
+    console.log("onRealChange:", e.target.value);
+    if (typeof onChange !== "undefined") {
+      onChange({ field: "real", value: e.target.value });
+    }
+  };
+  return (
+    <div>
+      <WbwDetailUpload
+        data={data}
+        onUpload={(fileList: UploadFile<IAttachmentResponse>[]) => {
+          if (typeof onUpload !== "undefined") {
+            onUpload(fileList);
+          }
+        }}
+      />
+    </div>
+  );
+};
+
+export default Widget;

+ 54 - 60
dashboard/src/components/template/Wbw/WbwDetailBasic.tsx

@@ -1,15 +1,24 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
-import { Divider, Form, Select, Input, Cascader } from "antd";
+import { Divider, Form, Select, Input, Cascader, AutoComplete } from "antd";
 import { Collapse } from "antd";
 
 import SelectCase from "../../dict/SelectCase";
 import { IWbw } from "./WbwWord";
 import WbwMeaningSelect from "./WbwMeaningSelect";
+import { useAppSelector } from "../../../hooks";
+import { inlineDict as _inlineDict } from "../../../reducers/inline-dict";
+import { getFactorsInDict } from "./WbwFactors";
 
 const { Option } = Select;
 const { Panel } = Collapse;
 
+interface ValueType {
+  key?: string;
+  label: React.ReactNode;
+  value: string | number;
+}
+
 export interface IWordBasic {
   meaning?: string[];
   case?: string;
@@ -31,12 +40,9 @@ interface IWidget {
 const Widget = ({ data, onChange }: IWidget) => {
   const [form] = Form.useForm();
   const intl = useIntl();
-  const [items, setItems] = useState(["jack", "lucy"]);
-
-  const formItemLayout = {
-    labelCol: { span: 4 },
-    wrapperCol: { span: 20 },
-  };
+  const [items, setItems] = useState<string[]>([]);
+  const inlineDict = useAppSelector(_inlineDict);
+  const [factorOptions, setFactorOptions] = useState<ValueType[]>([]);
   const onMeaningChange = (value: string | string[]) => {
     console.log(`Selected: ${value}`);
     if (typeof onChange !== "undefined") {
@@ -48,37 +54,26 @@ const Widget = ({ data, onChange }: IWidget) => {
     }
   };
 
-  const options: CascaderOption[] = [
-    {
-      value: "n",
-      label: intl.formatMessage({ id: "dict.fields.type.n.label" }),
-    },
-    {
-      value: "ti",
-      label: intl.formatMessage({ id: "dict.fields.type.ti.label" }),
-    },
-    {
-      value: "v",
-      label: intl.formatMessage({ id: "dict.fields.type.v.label" }),
-    },
-    {
-      value: "ind",
-      label: intl.formatMessage({ id: "dict.fields.type.ind.label" }),
-    },
-    {
-      value: "un",
-      label: intl.formatMessage({ id: "dict.fields.type.un.label" }),
-    },
-    {
-      value: "adj",
-      label: intl.formatMessage({ id: "dict.fields.type.adj.label" }),
-    },
-  ];
+  useEffect(() => {
+    const factors = getFactorsInDict(
+      data.word.value,
+      inlineDict.wordIndex,
+      inlineDict.wordList
+    );
+    const options = factors.map((item) => {
+      return {
+        label: item,
+        value: item,
+      };
+    });
+    setFactorOptions(options);
+  }, [inlineDict, data]);
 
   return (
     <>
       <Form
-        {...formItemLayout}
+        labelCol={{ span: 4 }}
+        wrapperCol={{ span: 20 }}
         className="wbw_detail_basic"
         name="basic"
         form={form}
@@ -88,10 +83,10 @@ const Widget = ({ data, onChange }: IWidget) => {
           factorMeaning: data.factorMeaning?.value,
           parent: data.parent?.value,
           case: data.case?.value,
-          case1: data.case?.value,
         }}
       >
         <Form.Item
+          style={{ marginBottom: 6 }}
           name="meaning"
           label={intl.formatMessage({ id: "forms.fields.meaning.label" })}
           tooltip={intl.formatMessage({ id: "forms.fields.meaning.tooltip" })}
@@ -132,28 +127,42 @@ const Widget = ({ data, onChange }: IWidget) => {
           />
         </Form.Item>
         <Form.Item
+          style={{ marginBottom: 6 }}
           name="factors"
           label={intl.formatMessage({ id: "forms.fields.factors.label" })}
           tooltip={intl.formatMessage({ id: "forms.fields.factors.tooltip" })}
+        >
+          <AutoComplete options={factorOptions}>
+            <Input
+              allowClear
+              placeholder={intl.formatMessage({
+                id: "forms.fields.factors.label",
+              })}
+            />
+          </AutoComplete>
+        </Form.Item>
+        <Form.Item
+          style={{ marginBottom: 6 }}
+          name="factorMeaning"
+          label={intl.formatMessage({
+            id: "forms.fields.factor.meaning.label",
+          })}
+          tooltip={intl.formatMessage({
+            id: "forms.fields.factor.meaning.tooltip",
+          })}
         >
           <Input
             allowClear
             placeholder={intl.formatMessage({
-              id: "forms.fields.factors.label",
+              id: "forms.fields.factor.meaning.label",
             })}
           />
         </Form.Item>
         <Form.Item
+          style={{ marginBottom: 6 }}
           label={intl.formatMessage({ id: "forms.fields.case.label" })}
           tooltip={intl.formatMessage({ id: "forms.fields.case.tooltip" })}
           name="case"
-        >
-          <Cascader options={options} placeholder="Please select case" />
-        </Form.Item>
-        <Form.Item
-          label={intl.formatMessage({ id: "forms.fields.case.label" })}
-          tooltip={intl.formatMessage({ id: "forms.fields.case.tooltip" })}
-          name="case1"
         >
           <SelectCase
             onCaseChange={(value: (string | number)[]) => {
@@ -164,22 +173,7 @@ const Widget = ({ data, onChange }: IWidget) => {
           />
         </Form.Item>
         <Form.Item
-          name="factorMeaning"
-          label={intl.formatMessage({
-            id: "forms.fields.factor.meaning.label",
-          })}
-          tooltip={intl.formatMessage({
-            id: "forms.fields.factor.meaning.tooltip",
-          })}
-        >
-          <Input
-            allowClear
-            placeholder={intl.formatMessage({
-              id: "forms.fields.factor.meaning.label",
-            })}
-          />
-        </Form.Item>
-        <Form.Item
+          style={{ marginBottom: 6 }}
           name="parent"
           label={intl.formatMessage({
             id: "forms.fields.parent.label",

+ 24 - 0
dashboard/src/components/template/Wbw/WbwFactors.tsx

@@ -8,9 +8,33 @@ import { IWbw, TWbwDisplayMode } from "./WbwWord";
 import { PaliReal } from "../../../utils";
 import { useAppSelector } from "../../../hooks";
 import { inlineDict as _inlineDict } from "../../../reducers/inline-dict";
+import { IApiResponseDictData } from "../../api/Dict";
 
 const { Text } = Typography;
 
+export const getFactorsInDict = (
+  wordIn: string,
+  wordIndex: string[],
+  wordList: IApiResponseDictData[]
+): string[] => {
+  if (wordIndex.includes(wordIn)) {
+    const result = wordList.filter((word) => word.word === wordIn);
+    //查重
+    //TODO 加入信心指数并排序
+    let myMap = new Map<string, number>();
+    let factors: string[] = [];
+    for (const iterator of result) {
+      myMap.set(iterator.factors, 1);
+    }
+    myMap.forEach((value, key, map) => {
+      factors.push(key);
+    });
+    return factors;
+  } else {
+    return [];
+  }
+};
+
 interface IWidget {
   data: IWbw;
   display?: TWbwDisplayMode;

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

@@ -14,6 +14,9 @@ import { PaliReal } from "../../../utils";
 import WbwVideoButton from "./WbwVideoButton";
 import CommentBox from "../../comment/CommentBox";
 import PaliText from "./PaliText";
+import store from "../../../store";
+import { command } from "../../../reducers/command";
+import { IWidgetDict } from "../../dict/DictComponent";
 
 const { Paragraph } = Typography;
 interface IWidget {
@@ -105,6 +108,18 @@ const Widget = ({ data, display, onSave }: IWidget) => {
         padding: padding,
         borderRadius: 5,
       }}
+      onClick={() => {
+        //发送点词查询消息
+
+        store.dispatch(
+          command({
+            prop: {
+              word: data.word.value,
+            },
+            type: "dict",
+          })
+        );
+      }}
     >
       {<PaliText text={data.word.value} />}
     </span>

+ 1 - 1
dashboard/src/components/template/Wbw/wbw.css

@@ -43,7 +43,7 @@
   color: brown;
 }
 .wbw_note {
-  color: blue;
+  color: #177ddc;
 }
 .block .wbw_note {
   font-weight: 500;

+ 16 - 0
dashboard/src/load.ts

@@ -79,6 +79,22 @@ const init = () => {
   } else {
     store.dispatch(refreshTheme("ant"));
   }
+
+  //设置时区到cookie
+  function setCookie(c_name: string, value: string, expiredays: number) {
+    var exdate = new Date();
+    exdate.setDate(exdate.getDate() + expiredays);
+    document.cookie =
+      c_name +
+      "=" +
+      escape(value) +
+      (expiredays == null
+        ? ""
+        : "; expires=" + exdate.toUTCString() + ";path=/");
+  }
+  const date = new Date();
+  const timezone = date.getTimezoneOffset();
+  setCookie("timezone", timezone.toString(), 10);
 };
 
 export default init;

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

@@ -28,6 +28,7 @@ const items = {
   "columns.studio.collaboration.title": "Collaboration",
   "columns.studio.basic.title": "Basic",
   "columns.studio.advance.title": "Advance",
+  "columns.exp.title": "exp",
 };
 
 export default items;

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

@@ -7,6 +7,7 @@ const items = {
   "auth.role.student": "学生",
   "auth.role.assistant": "助理老师",
   "auth.role.unknown": "未知",
+  "auth.role.delete": "删除",
   "auth.type.user": "用户",
   "auth.type.group": "群组",
 };

+ 8 - 2
dashboard/src/locales/zh-Hans/buttons.ts

@@ -8,9 +8,9 @@ const items = {
   "buttons.delete": "删除",
   "buttons.remove": "移除",
   "buttons.delete.all": "批量删除",
-  "buttons.selected": "已选择",
+  "buttons.selected": "已选择",
   "buttons.select": "选择",
-  "buttons.unselect": "取消选择",
+  "buttons.unselect": "全不选",
   "buttons.option": "操作",
   "buttons.save": "保存",
   "buttons.save.publish": "保存并公开",
@@ -33,6 +33,12 @@ const items = {
   "buttons.open.in.library": "在藏经阁中打开",
   "buttons.preview": "预览",
   "buttons.view": "查看",
+  "buttons.empty": "清空",
+  "buttons.basic": "基本",
+  "buttons.bookmark": "书签",
+  "buttons.note": "注解",
+  "buttons.advance": "高级",
+  "buttons.attachments": "附件",
 };
 
 export default items;

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

@@ -13,8 +13,8 @@ import setting from "./setting";
 import auth from "./auth";
 import course from "./course";
 import message from "./message";
+import label from "./label";
 const items = {
-  "flashes.success": "操作成功",
   "columns.library.title": "藏经阁",
   "columns.library.community.title": "社区",
   "columns.library.palicanon.title": "圣典",
@@ -43,6 +43,8 @@ const items = {
   "columns.studio.collaboration.title": "协作",
   "columns.studio.basic.title": "常用",
   "columns.studio.advance.title": "高级",
+  "columns.studio.setting.title": "设置",
+  "columns.exp.title": "经验",
   ...buttons,
   ...forms,
   ...tables,
@@ -58,6 +60,7 @@ const items = {
   ...auth,
   ...course,
   ...message,
+  ...label,
 };
 
 export default items;

+ 11 - 0
dashboard/src/locales/zh-Hans/label.ts

@@ -0,0 +1,11 @@
+const items = {
+  "labels.week.0": "星期日",
+  "labels.week.1": "星期一",
+  "labels.week.2": "星期二",
+  "labels.week.3": "星期三",
+  "labels.week.4": "星期四",
+  "labels.week.5": "星期五",
+  "labels.week.6": "星期六",
+};
+
+export default items;

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

@@ -1,4 +1,5 @@
 const items = {
+  "flashes.success": "操作成功",
   "message.irrevocable": "此操作不可撤销",
   "message.delete.sure": "确定删除吗?",
 };

+ 154 - 0
dashboard/src/pages/admin/_defaultProps.tsx

@@ -0,0 +1,154 @@
+import {
+  ChromeFilled,
+  CrownFilled,
+  SmileFilled,
+  TabletFilled,
+} from "@ant-design/icons";
+
+const defaultProps = {
+  route: {
+    path: "/",
+    routes: [
+      {
+        path: "/welcome",
+        name: "欢迎",
+        icon: <SmileFilled />,
+        component: "./Welcome",
+      },
+      {
+        path: "/admin",
+        name: "管理页",
+        icon: <CrownFilled />,
+        access: "canAdmin",
+        component: "./Admin",
+        routes: [
+          {
+            path: "/admin/sub-page1",
+            name: "一级页面",
+            icon: "https://gw.alipayobjects.com/zos/antfincdn/upvrAjAPQX/Logo_Tech%252520UI.svg",
+            component: "./Welcome",
+          },
+          {
+            path: "/admin/sub-page2",
+            name: "二级页面",
+            icon: <CrownFilled />,
+            component: "./Welcome",
+          },
+          {
+            path: "/admin/sub-page3",
+            name: "三级页面",
+            icon: <CrownFilled />,
+            component: "./Welcome",
+          },
+        ],
+      },
+      {
+        name: "列表页",
+        icon: <TabletFilled />,
+        path: "/list",
+        component: "./ListTableList",
+        routes: [
+          {
+            path: "/list/sub-page",
+            name: "列表页面",
+            icon: <CrownFilled />,
+            routes: [
+              {
+                path: "sub-sub-page1",
+                name: "一一级列表页面",
+                icon: <CrownFilled />,
+                component: "./Welcome",
+              },
+              {
+                path: "sub-sub-page2",
+                name: "一二级列表页面",
+                icon: <CrownFilled />,
+                component: "./Welcome",
+              },
+              {
+                path: "sub-sub-page3",
+                name: "一三级列表页面",
+                icon: <CrownFilled />,
+                component: "./Welcome",
+              },
+            ],
+          },
+          {
+            path: "/list/sub-page2",
+            name: "二级列表页面",
+            icon: <CrownFilled />,
+            component: "./Welcome",
+          },
+          {
+            path: "/list/sub-page3",
+            name: "三级列表页面",
+            icon: <CrownFilled />,
+            component: "./Welcome",
+          },
+        ],
+      },
+      {
+        path: "https://ant.design",
+        name: "Ant Design 官网外链",
+        icon: <ChromeFilled />,
+      },
+    ],
+  },
+  location: {
+    pathname: "/",
+  },
+  appList: [
+    {
+      icon: "https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg",
+      title: "Ant Design",
+      desc: "杭州市较知名的 UI 设计语言",
+      url: "https://ant.design",
+    },
+    {
+      icon: "https://gw.alipayobjects.com/zos/antfincdn/FLrTNDvlna/antv.png",
+      title: "AntV",
+      desc: "蚂蚁集团全新一代数据可视化解决方案",
+      url: "https://antv.vision/",
+      target: "_blank",
+    },
+    {
+      icon: "https://gw.alipayobjects.com/zos/antfincdn/upvrAjAPQX/Logo_Tech%252520UI.svg",
+      title: "Pro Components",
+      desc: "专业级 UI 组件库",
+      url: "https://procomponents.ant.design/",
+    },
+    {
+      icon: "https://img.alicdn.com/tfs/TB1zomHwxv1gK0jSZFFXXb0sXXa-200-200.png",
+      title: "umi",
+      desc: "插件化的企业级前端应用框架。",
+      url: "https://umijs.org/zh-CN/docs",
+    },
+
+    {
+      icon: "https://gw.alipayobjects.com/zos/bmw-prod/8a74c1d3-16f3-4719-be63-15e467a68a24/km0cv8vn_w500_h500.png",
+      title: "qiankun",
+      desc: "可能是你见过最完善的微前端解决方案🧐",
+      url: "https://qiankun.umijs.org/",
+    },
+    {
+      icon: "https://gw.alipayobjects.com/zos/rmsportal/XuVpGqBFxXplzvLjJBZB.svg",
+      title: "语雀",
+      desc: "知识创作与分享工具",
+      url: "https://www.yuque.com/",
+    },
+    {
+      icon: "https://gw.alipayobjects.com/zos/rmsportal/LFooOLwmxGLsltmUjTAP.svg",
+      title: "Kitchen ",
+      desc: "Sketch 工具集",
+      url: "https://kitchen.alipay.com/",
+    },
+    {
+      icon: "https://gw.alipayobjects.com/zos/bmw-prod/d3e3eb39-1cd7-4aa5-827c-877deced6b7e/lalxt4g3_w256_h256.png",
+      title: "dumi",
+      desc: "为组件开发场景而生的文档工具",
+      url: "https://d.umijs.org/zh-CN",
+    },
+  ],
+};
+
+export default defaultProps;

+ 106 - 0
dashboard/src/pages/admin/index.tsx

@@ -0,0 +1,106 @@
+import {
+  GithubFilled,
+  InfoCircleFilled,
+  QuestionCircleFilled,
+} from "@ant-design/icons";
+import { ProConfigProvider, ProSettings } from "@ant-design/pro-components";
+import {
+  PageContainer,
+  ProLayout,
+  SettingDrawer,
+  ProCard,
+} from "@ant-design/pro-components";
+import { useState } from "react";
+import defaultProps from "./_defaultProps";
+
+const Widget = () => {
+  const [settings, setSetting] = useState<Partial<ProSettings> | undefined>({
+    layout: "side",
+  });
+
+  const [pathname, setPathname] = useState("/list/sub-page/sub-sub-page1");
+
+  return (
+    <div
+      id="test-pro-layout"
+      style={{
+        height: "100vh",
+      }}
+    >
+      <ProLayout
+        siderWidth={216}
+        bgLayoutImgList={[
+          {
+            src: "https://img.alicdn.com/imgextra/i2/O1CN01O4etvp1DvpFLKfuWq_!!6000000000279-2-tps-609-606.png",
+            left: 85,
+            bottom: 100,
+            height: "303px",
+          },
+          {
+            src: "https://img.alicdn.com/imgextra/i2/O1CN01O4etvp1DvpFLKfuWq_!!6000000000279-2-tps-609-606.png",
+            bottom: -68,
+            right: -45,
+            height: "303px",
+          },
+          {
+            src: "https://img.alicdn.com/imgextra/i3/O1CN018NxReL1shX85Yz6Cx_!!6000000005798-2-tps-884-496.png",
+            bottom: 0,
+            left: 0,
+            width: "331px",
+          },
+        ]}
+        {...defaultProps}
+        location={{
+          pathname,
+        }}
+        avatarProps={{
+          src: "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+          title: "七妮妮",
+          size: "small",
+        }}
+        actionsRender={(props) => {
+          if (props.isMobile) return [];
+          return [
+            <InfoCircleFilled key="InfoCircleFilled" />,
+            <QuestionCircleFilled key="QuestionCircleFilled" />,
+            <GithubFilled key="GithubFilled" />,
+          ];
+        }}
+        menuItemRender={(item, dom) => (
+          <div
+            onClick={() => {
+              setPathname(item.path || "/welcome");
+            }}
+          >
+            {dom}
+          </div>
+        )}
+        {...settings}
+      >
+        <PageContainer>
+          <ProCard
+            style={{
+              height: "100vh",
+              minHeight: 800,
+            }}
+          >
+            <div />
+          </ProCard>
+        </PageContainer>
+      </ProLayout>
+
+      <SettingDrawer
+        pathname={pathname}
+        enableDarkTheme
+        getContainer={() => document.getElementById("test-pro-layout")}
+        settings={settings}
+        onSettingChange={(changeSetting) => {
+          setSetting(changeSetting);
+        }}
+        disableUrlParams={false}
+      />
+    </div>
+  );
+};
+
+export default Widget;

+ 38 - 16
dashboard/src/pages/library/article/show.tsx

@@ -1,4 +1,4 @@
-import { Affix, Space } from "antd";
+import { Affix, Divider, Space } from "antd";
 import { Header } from "antd/lib/layout/layout";
 import { useState } from "react";
 import { useNavigate, useParams } from "react-router-dom";
@@ -12,10 +12,12 @@ import MainMenu from "../../../components/article/MainMenu";
 import ModeSwitch from "../../../components/article/ModeSwitch";
 import RightPanel, { TPanelName } from "../../../components/article/RightPanel";
 import RightToolsSwitch from "../../../components/article/RightToolsSwitch";
+import ToolButtonPr from "../../../components/article/ToolButtonPr";
 import ToolButtonSearch from "../../../components/article/ToolButtonSearch";
 import ToolButtonSetting from "../../../components/article/ToolButtonSetting";
 import ToolButtonTag from "../../../components/article/ToolButtonTag";
 import ToolButtonToc from "../../../components/article/ToolButtonToc";
+import Avatar from "../../../components/auth/Avatar";
 import { IChannel } from "../../../components/channel/Channel";
 
 /**
@@ -53,16 +55,15 @@ const Widget = () => {
             padding: "5px",
           }}
         >
-          <div>
-            <MainMenu />
-          </div>
+          <MainMenu />
           <div></div>
-          <div style={{ display: "flex" }}>
+          <div key="right" style={{ display: "flex" }}>
             <ModeSwitch
               onModeChange={(e: ArticleMode) => {
                 setArticleMode(e);
               }}
             />
+            <Divider type="vertical" />
             <RightToolsSwitch
               onModeChange={(open: TPanelName) => {
                 setRightPanel(open);
@@ -73,30 +74,51 @@ const Widget = () => {
       </Affix>
       <div style={{ width: "100%", display: "flex" }}>
         <Affix offsetTop={44}>
-          <div style={{ height: `calc(100% - 44px)`, padding: 10 }}>
-            <Space direction="vertical">
-              <ToolButtonToc type={type} articleId={id} />
-              <ToolButtonTag type={type} articleId={id} />
-              <ToolButtonSearch type={type} articleId={id} />
-              <ToolButtonSetting type={type} articleId={id} />
-              <ToolButtonTag type={type} articleId={id} />
-            </Space>
+          <div
+            style={{
+              height: `calc(100% - 44px)`,
+              padding: 8,
+              display: "flex",
+              flexDirection: "column",
+              justifyContent: "space-between",
+            }}
+          >
+            <div>
+              <Space direction="vertical">
+                <ToolButtonToc type={type} articleId={id} />
+                <ToolButtonTag type={type} articleId={id} />
+                <ToolButtonPr type={type} articleId={id} />
+                <ToolButtonSearch type={type} articleId={id} />
+                <ToolButtonSetting type={type} articleId={id} />
+              </Space>
+            </div>
+            <div>
+              <Space direction="vertical">
+                <Avatar placement="rightBottom" />
+              </Space>
+            </div>
           </div>
         </Affix>
         <div
+          key="main"
           style={{ width: `calc(100% - ${rightBarWidth})`, display: "flex" }}
         >
           <div
-            style={{ marginLeft: "auto", marginRight: "auto", maxWidth: 960 }}
+            key="Article"
+            style={{ marginLeft: "auto", marginRight: "auto", width: 960 }}
           >
             <Article
               active={true}
               type={type as ArticleType}
               articleId={id}
               mode={articleMode}
+              onArticleChange={(article: string) => {
+                console.log("article change", article);
+                navigate(`/article/${type}/${article}/${articleMode}`);
+              }}
             />
           </div>
-          <div>
+          <div key="RightPanel">
             <RightPanel
               curr={rightPanel}
               type={type as ArticleType}
@@ -108,7 +130,7 @@ const Widget = () => {
                   oldId ? oldId[0] : undefined,
                   ...e.map((item) => item.id),
                 ];
-                navigate(`/article/${type}/${newId.join("_")}/${mode}`);
+                navigate(`/article/${type}/${newId.join("_")}/${articleMode}`);
               }}
             />
           </div>

+ 1 - 6
dashboard/src/pages/library/discussion/list.tsx

@@ -39,12 +39,7 @@ const Widget = () => {
             return {
               id: item.id,
               resType: item.res_type,
-              user: {
-                id: item.editor.id,
-                nickName: item.editor.nickName,
-                realName: item.editor.userName,
-                avatar: item.editor.avatar,
-              },
+              user: item.editor,
               title: item.title,
               childrenCount: item.children_count,
               createdAt: item.created_at,

+ 8 - 8
dashboard/src/pages/studio/analysis/index.tsx

@@ -7,14 +7,14 @@ import { styleStudioContent } from "../style";
 const { Content } = Layout;
 
 const Widget = () => {
-	return (
-		<Layout>
-			<LeftSider selectedKeys="analysis" />
-			<Content style={styleStudioContent}>
-				<Outlet />
-			</Content>
-		</Layout>
-	);
+  return (
+    <Layout>
+      <LeftSider selectedKeys="analysis" />
+      <Content style={styleStudioContent}>
+        <Outlet />
+      </Content>
+    </Layout>
+  );
 };
 
 export default Widget;

+ 18 - 45
dashboard/src/pages/studio/analysis/list.tsx

@@ -1,55 +1,28 @@
+import { Space, Statistic } from "antd";
 import { useParams } from "react-router-dom";
+import Heatmap from "../../../components/exp/Heatmap";
+import ExpStatisticCard from "../../../components/exp/ExpStatisticCard";
 
-import { useState, useEffect } from "react";
-import { Line } from "@ant-design/plots";
+import StudyTimeDualAxes from "../../../components/exp/StudyTimeDualAxes";
+import { StatisticCard } from "@ant-design/pro-components";
 
-import { Calendar, momentLocalizer } from "react-big-calendar";
-import moment from "moment";
-
-const localizer = momentLocalizer(moment); // or globalizeLocalizer
 const Widget = () => {
   const { studioname } = useParams(); //url 参数
-  const [data, setData] = useState([]);
-
-  useEffect(() => {
-    asyncFetch();
-  }, []);
 
-  const myEventsList = [{ start: "2022-10-1", end: "2022-10-2" }];
-  const asyncFetch = () => {
-    fetch(
-      "https://gw.alipayobjects.com/os/bmw-prod/1d565782-dde4-4bb6-8946-ea6a38ccf184.json"
-    )
-      .then((response) => response.json())
-      .then((json) => setData(json))
-      .catch((error) => {
-        console.log("fetch data failed", error);
-      });
-  };
-  const config = {
-    data,
-    xField: "Date",
-    yField: "scales",
-    xAxis: {
-      tickCount: 5,
-    },
-    slider: {
-      start: 0.1,
-      end: 0.5,
-    },
-  };
   return (
-    <>
-      {studioname}
-      <Line {...config} />
-
-      <Calendar
-        localizer={localizer}
-        events={myEventsList}
-        startAccessor="start"
-        endAccessor="end"
-      />
-    </>
+    <div style={{ padding: "1em" }}>
+      <Space direction="vertical">
+        <ExpStatisticCard studioName={studioname} />
+        <StatisticCard
+          title="进步曲线"
+          chart={<StudyTimeDualAxes studioName={studioname} />}
+        />
+        <StatisticCard
+          title="进步日历"
+          chart={<Heatmap studioName={studioname} />}
+        />
+      </Space>
+    </div>
   );
 };
 

+ 22 - 0
dashboard/src/pages/studio/setting/index.tsx

@@ -0,0 +1,22 @@
+import { Layout } from "antd";
+
+import LeftSider from "../../../components/studio/LeftSider";
+import { styleStudioContent } from "../style";
+import SettingArticle from "../../../components/auth/setting/SettingArticle";
+
+const { Content } = Layout;
+
+const Widget = () => {
+  return (
+    <Layout>
+      <Layout>
+        <LeftSider selectedKeys="setting" />
+        <Content style={styleStudioContent}>
+          <SettingArticle />
+        </Content>
+      </Layout>
+    </Layout>
+  );
+};
+
+export default Widget;

+ 30 - 0
dashboard/src/reducers/accept-pr.ts

@@ -0,0 +1,30 @@
+/**
+ * 查字典,添加术语命令
+ */
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
+import { ISentence } from "../components/template/SentEdit";
+
+import type { RootState } from "../store";
+
+interface IState {
+  sentence?: ISentence;
+}
+
+const initialState: IState = {};
+
+export const slice = createSlice({
+  name: "accept-pr",
+  initialState,
+  reducers: {
+    accept: (state, action: PayloadAction<ISentence>) => {
+      state.sentence = action.payload;
+    },
+  },
+});
+
+export const { accept } = slice.actions;
+
+export const sentence = (state: RootState): ISentence | undefined =>
+  state.acceptPr.sentence;
+
+export default slice.reducer;

+ 2 - 0
dashboard/src/store.ts

@@ -12,6 +12,7 @@ import inlineDictReducer from "./reducers/inline-dict";
 import currentCourseReducer from "./reducers/current-course";
 import sentenceReducer from "./reducers/sentence";
 import themeReducer from "./reducers/theme";
+import acceptPrReducer from "./reducers/accept-pr";
 
 const store = configureStore({
   reducer: {
@@ -27,6 +28,7 @@ const store = configureStore({
     currentCourse: currentCourseReducer,
     sentence: sentenceReducer,
     theme: themeReducer,
+    acceptPr: acceptPrReducer,
   },
 });
 

+ 12 - 0
dashboard/src/theme/antpro.dark.css

@@ -8,3 +8,15 @@
 .dark-space-item {
   color: unset;
 }
+.dark-pro-table .dark-pro-table-search {
+  background-color: unset;
+}
+.dark-pro-table-alert-info {
+  color: unset;
+}
+.dark-pro-card-statistic .dark-statistic-title {
+  color: rgba(150, 150, 150, 0.88);
+}
+.dark-pro-card .dark-pro-card-title {
+  color: rgba(150, 150, 150, 0.88);
+}