Răsfoiți Sursa

Merge pull request #2052 from visuddhinanda/agile

课程支持报名时间
visuddhinanda 1 an în urmă
părinte
comite
2c8e196b19
68 a modificat fișierele cu 1199 adăugiri și 712 ștergeri
  1. 87 5
      dashboard/src/assets/icon/index.tsx
  2. 1 0
      dashboard/src/components/api/Auth.ts
  3. 16 0
      dashboard/src/components/api/Comment.ts
  4. 6 2
      dashboard/src/components/api/Course.ts
  5. 2 0
      dashboard/src/components/api/Term.ts
  6. 2 0
      dashboard/src/components/article/Article.tsx
  7. 1 1
      dashboard/src/components/article/TypeArticleReaderToolbar.tsx
  8. 0 2
      dashboard/src/components/article/TypeCourse.tsx
  9. 31 10
      dashboard/src/components/channel/ChannelMy.tsx
  10. 5 2
      dashboard/src/components/channel/ChannelPickerTable.tsx
  11. 20 14
      dashboard/src/components/channel/ChannelSelect.tsx
  12. 25 33
      dashboard/src/components/channel/ChannelTable.tsx
  13. 61 0
      dashboard/src/components/corpus/SentMyEditList.tsx
  14. 0 71
      dashboard/src/components/course/AddLesson.tsx
  15. 38 15
      dashboard/src/components/course/CourseHead.tsx
  16. 19 27
      dashboard/src/components/course/CourseInfoEdit.tsx
  17. 3 1
      dashboard/src/components/course/CourseMemberList.tsx
  18. 0 80
      dashboard/src/components/course/LessonSelect.tsx
  19. 86 20
      dashboard/src/components/course/RolePower.ts
  20. 17 5
      dashboard/src/components/course/Status.tsx
  21. 7 7
      dashboard/src/components/discussion/DiscussionAnchor.tsx
  22. 82 0
      dashboard/src/components/discussion/DiscussionButton.tsx
  23. 62 0
      dashboard/src/components/discussion/DiscussionCount.tsx
  24. 16 7
      dashboard/src/components/discussion/DiscussionCreate.tsx
  25. 6 3
      dashboard/src/components/discussion/DiscussionEdit.tsx
  26. 20 8
      dashboard/src/components/discussion/DiscussionListCard.tsx
  27. 23 10
      dashboard/src/components/discussion/DiscussionShow.tsx
  28. 4 1
      dashboard/src/components/discussion/DiscussionTopicChildren.tsx
  29. 2 2
      dashboard/src/components/discussion/DiscussionTopicInfo.tsx
  30. 2 2
      dashboard/src/components/discussion/QaList.tsx
  31. 28 28
      dashboard/src/components/exp/ExpStatisticCard.tsx
  32. 42 0
      dashboard/src/components/exp/ExpTime.tsx
  33. 7 39
      dashboard/src/components/export/ShareButton.tsx
  34. 15 0
      dashboard/src/components/general/NetStatus.tsx
  35. 1 1
      dashboard/src/components/nut/users/NonSignInSharedLinks.tsx
  36. 6 1
      dashboard/src/components/studio/PublicitySelect.tsx
  37. 9 7
      dashboard/src/components/template/SentEdit/SentCell.tsx
  38. 13 0
      dashboard/src/components/template/SentEdit/SentContent.tsx
  39. 2 1
      dashboard/src/components/template/SentEdit/SentWbw.tsx
  40. 4 33
      dashboard/src/components/template/SentEdit/SuggestionToolbar.tsx
  41. 2 1
      dashboard/src/components/template/SentRead.tsx
  42. 74 57
      dashboard/src/components/template/Term.tsx
  43. 12 21
      dashboard/src/components/template/Wbw/WbwDetail.tsx
  44. 13 42
      dashboard/src/components/template/Wbw/WbwPali.tsx
  45. 41 0
      dashboard/src/components/template/Wbw/WbwPaliDiscussionIcon.tsx
  46. 4 0
      dashboard/src/components/template/Wbw/WbwWord.tsx
  47. 6 2
      dashboard/src/components/template/WbwSent.tsx
  48. 76 35
      dashboard/src/components/term/TermEdit.tsx
  49. 7 1
      dashboard/src/components/term/TermItem.tsx
  50. 10 6
      dashboard/src/components/users/SignUp.tsx
  51. 9 3
      dashboard/src/layouts/anonymous/index.tsx
  52. 1 0
      dashboard/src/locales/en-US/auth/index.ts
  53. 1 1
      dashboard/src/locales/en-US/course/index.ts
  54. 3 0
      dashboard/src/locales/en-US/label.ts
  55. 1 0
      dashboard/src/locales/en-US/term/index.ts
  56. 1 0
      dashboard/src/locales/zh-Hans/auth/index.ts
  57. 4 1
      dashboard/src/locales/zh-Hans/label.ts
  58. 1 0
      dashboard/src/locales/zh-Hans/term/index.ts
  59. 5 1
      dashboard/src/pages/library/course/course.tsx
  60. 2 0
      dashboard/src/pages/library/discussion/list.tsx
  61. 7 14
      dashboard/src/pages/studio/article/edit.tsx
  62. 16 60
      dashboard/src/pages/studio/course/list.tsx
  63. 0 27
      dashboard/src/pages/studio/home.tsx
  64. 66 0
      dashboard/src/pages/studio/home/index.tsx
  65. 1 1
      dashboard/src/pages/users/sign-up.tsx
  66. 44 0
      dashboard/src/reducers/discussion-count.ts
  67. 19 1
      dashboard/src/reducers/term-change.ts
  68. 2 0
      dashboard/src/store.ts

+ 87 - 5
dashboard/src/assets/icon/index.tsx

@@ -43,25 +43,52 @@ const SuggestionSvg = () => (
 
 const LockSvg = () => (
   <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
     xmlns="http://www.w3.org/2000/svg"
+    p-id="4416"
     width="1em"
     height="1em"
-    fill="currentColor"
-    viewBox="0 0 16 16"
   >
-    <path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zM5 8h6a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z" />
+    <path
+      d="M512.804571 73.142857a207.238095 207.238095 0 0 1 207.238096 207.238095l-0.024381 60.952381H828.952381a73.142857 73.142857 0 0 1 73.142857 73.142857v438.857143a73.142857 73.142857 0 0 1-73.142857 73.142857H195.047619a73.142857 73.142857 0 0 1-73.142857-73.142857V414.47619a73.142857 73.142857 0 0 1 73.142857-73.142857h110.494476v-60.952381a207.238095 207.238095 0 0 1 207.238095-207.238095zM828.952381 414.47619H195.047619v438.857143h633.904762V414.47619z m-273.383619 121.904762v195.047619h-73.142857v-195.047619h73.142857zM512.804571 146.285714a134.095238 134.095238 0 0 0-134.095238 134.095238V341.333333h268.190477v-60.952381a134.095238 134.095238 0 0 0-134.095239-134.095238z"
+      p-id="4417"
+      fill="currentColor"
+    ></path>
   </svg>
 );
 
 const UnLockSvg = () => (
   <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="7678"
+    width="1em"
+    height="1em"
+    fill="currentColor"
+  >
+    <path
+      d="M524.190476 73.142857a207.238095 207.238095 0 0 1 207.238095 207.238095v12.190477h-73.142857v-12.190477a134.095238 134.095238 0 0 0-268.190476 0V341.333333h438.857143a73.142857 73.142857 0 0 1 73.142857 73.142857v438.857143a73.142857 73.142857 0 0 1-73.142857 73.142857H195.047619a73.142857 73.142857 0 0 1-73.142857-73.142857V414.47619a73.142857 73.142857 0 0 1 73.142857-73.142857h121.904762v-60.952381A207.238095 207.238095 0 0 1 524.190476 73.142857zM828.952381 414.47619H195.047619v438.857143h633.904762V414.47619z m-268.190476 121.904762v195.047619h-73.142857v-195.047619h73.142857z"
+      p-id="7679"
+    ></path>
+  </svg>
+);
+
+const UnLockFillSvg = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
     xmlns="http://www.w3.org/2000/svg"
+    p-id="7846"
     width="1em"
     height="1em"
     fill="currentColor"
-    viewBox="0 0 16 16"
   >
-    <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" />
+    <path
+      d="M524.190476 73.142857a207.238095 207.238095 0 0 1 207.238095 207.238095v12.190477h-73.142857v-12.190477a134.095238 134.095238 0 0 0-268.190476 0V341.333333h438.857143a73.142857 73.142857 0 0 1 73.142857 73.142857v438.857143a73.142857 73.142857 0 0 1-73.142857 73.142857H195.047619a73.142857 73.142857 0 0 1-73.142857-73.142857V414.47619a73.142857 73.142857 0 0 1 73.142857-73.142857h121.904762v-60.952381A207.238095 207.238095 0 0 1 524.190476 73.142857zM560.761905 536.380952h-73.142857v195.047619h73.142857v-195.047619z"
+      p-id="7847"
+    ></path>
   </svg>
 );
 
@@ -349,6 +376,32 @@ const CommentOutLined = () => (
   </svg>
 );
 
+const CommentFill = () => (
+  <svg
+    version="1.1"
+    id="Layer_1"
+    xmlns="http://www.w3.org/2000/svg"
+    x="0px"
+    y="0px"
+    width="1em"
+    height="1em"
+    viewBox="0 0 12 12"
+  >
+    <path
+      d="M10.26,0.543H1.74c-0.852,0-1.521,0.669-1.521,1.521v6.694c0,0.853,0.669,1.521,1.521,1.521h2.647l1.399,1.43
+	C5.848,11.771,5.909,11.802,6,11.802c0.092,0,0.152-0.031,0.213-0.092l1.43-1.43h2.617c0.821,0,1.521-0.669,1.521-1.521V2.064
+	C11.781,1.243,11.081,0.543,10.26,0.543z"
+      fill="currentColor"
+    />
+    <path
+      d="M3.079,6.02c-0.426,0-0.761-0.334-0.761-0.761s0.334-0.761,0.761-0.761c0.426,0,0.761,0.334,0.761,0.761
+	S3.504,6.02,3.079,6.02z M6,6.02c-0.426,0-0.761-0.334-0.761-0.761S5.573,4.499,6,4.499s0.761,0.334,0.761,0.761S6.426,6.02,6,6.02z
+	 M8.86,6.02C8.435,6.02,8.1,5.686,8.1,5.259S8.435,4.499,8.86,4.499s0.761,0.334,0.761,0.761S9.286,6.02,8.86,6.02z"
+      fill="#FFFFFF"
+    />
+  </svg>
+);
+
 const TranslationOutLined = () => (
   <svg
     viewBox="0 0 1024 1024"
@@ -707,6 +760,23 @@ const TabSvg = () => (
     ></path>
   </svg>
 );
+
+const LockFillSvg = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="7512"
+    width="1em"
+    height="1em"
+    fill="currentColor"
+  >
+    <path
+      d="M512.804571 73.142857a207.238095 207.238095 0 0 1 207.238096 207.238095l-0.024381 60.952381H828.952381a73.142857 73.142857 0 0 1 73.142857 73.142857v438.857143a73.142857 73.142857 0 0 1-73.142857 73.142857H195.047619a73.142857 73.142857 0 0 1-73.142857-73.142857V414.47619a73.142857 73.142857 0 0 1 73.142857-73.142857h110.494476v-60.952381a207.238095 207.238095 0 0 1 207.238095-207.238095z m42.764191 463.238095h-73.142857v195.047619h73.142857v-195.047619zM512.804571 146.285714a134.095238 134.095238 0 0 0-134.095238 134.095238V341.333333h268.190477v-60.952381a134.095238 134.095238 0 0 0-134.095239-134.095238z"
+      p-id="7513"
+    ></path>
+  </svg>
+);
 export const DictIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={DictSvg} {...props} />
 );
@@ -777,6 +847,10 @@ export const CommentOutlinedIcon = (
   props: Partial<CustomIconComponentProps>
 ) => <Icon component={CommentOutLined} {...props} />;
 
+export const CommentFillIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={CommentFill} {...props} />
+);
+
 export const TranslationOutLinedIcon = (
   props: Partial<CustomIconComponentProps>
 ) => <Icon component={TranslationOutLined} {...props} />;
@@ -843,3 +917,11 @@ export const AdminIcon = (props: Partial<CustomIconComponentProps>) => (
 export const TabIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={TabSvg} {...props} />
 );
+
+export const LockFillIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={LockFillSvg} {...props} />
+);
+
+export const UnLockFillIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={UnLockFillSvg} {...props} />
+);

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

@@ -84,6 +84,7 @@ export interface IInviteRequest {
   email: string;
   lang: string;
   studio: string;
+  subject?: string;
   dashboard?: string;
 }
 export interface IInviteResponse {

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

@@ -62,3 +62,19 @@ export interface ICommentAnchorResponse {
   message: string;
   data: string;
 }
+
+export interface IDiscussionCountRequest {
+  course_id?: string | null;
+  sentences: string[][];
+}
+export interface IDiscussionCountData {
+  id: string;
+  res_id: string;
+  type: string;
+  editor_uid: string;
+}
+export interface IDiscussionCountResponse {
+  ok: boolean;
+  message: string;
+  data: IDiscussionCountData[];
+}

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

@@ -15,7 +15,7 @@ export interface ICourseDataRequest {
   title: string; //标题
   subtitle?: string; //副标题
   summary?: string; //副标题
-  content?: string;
+  content?: string | null;
   cover?: string; //封面图片文件名
   teacher_id?: string; //UserID
   publicity: number; //类型-公开/内部
@@ -23,10 +23,12 @@ export interface ICourseDataRequest {
   channel_id?: string; //标准答案channel
   start_at?: string; //课程开始时间
   end_at?: string; //课程结束时间
+  sign_up_start_at: string | null; //报名开始时间
+  sign_up_end_at: string | null; //报名结束时间
   join: string;
   request_exp: string;
 }
-export type TCourseRole = "teacher" | "assistant" | "student";
+export type TCourseRole = "teacher" | "manager" | "assistant" | "student";
 export type TCourseJoinMode = "invite" | "manual" | "open";
 export type TCourseExpRequest = "none" | "begin-end" | "daily";
 export interface ICourseDataResponse {
@@ -45,6 +47,8 @@ export interface ICourseDataResponse {
   channel_owner?: IStudio; //文集拥有者
   start_at: string; //课程开始时间
   end_at: string; //课程结束时间
+  sign_up_start_at: string; //报名开始时间
+  sign_up_end_at: string; //报名结束时间
   content: string; //简介
   cover: string; //封面图片文件名
   cover_url?: string[]; //封面图片文件名

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

@@ -37,6 +37,8 @@ export interface ITermDataResponse {
   exp?: number;
   language: string;
   community?: boolean;
+  summary?: string;
+  summary_is_community?: boolean;
   created_at: string;
   updated_at: string;
 }

+ 2 - 0
dashboard/src/components/article/Article.tsx

@@ -11,6 +11,7 @@ import TypeCourse from "./TypeCourse";
 import { useEffect, useState } from "react";
 import { fullUrl } from "../../utils";
 import TypeSeries from "./TypeSeries";
+import DiscussionCount from "../discussion/DiscussionCount";
 
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleType =
@@ -87,6 +88,7 @@ const ArticleWidget = ({
 
   return (
     <div>
+      <DiscussionCount courseId={type === "textbook" ? courseId : undefined} />
       {type === "article" ? (
         <TypeArticle
           type={type}

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

@@ -139,7 +139,7 @@ const TypeArticleReaderToolbarWidget = ({
                     setAddToAnthologyOpen(true);
                     break;
                   case "fork":
-                    const url = `/studio/${user?.nickName}/article/create?parent=${articleId}`;
+                    const url = `/studio/${user?.realName}/article/create?parent=${articleId}`;
                     window.open(fullUrl(url), "_blank");
                     break;
                   case "tpl":

+ 0 - 2
dashboard/src/components/article/TypeCourse.tsx

@@ -79,8 +79,6 @@ const TypeCourseWidget = ({
   const [channelPickerOpen, setChannelPickerOpen] = useState(false);
   const navigate = useNavigate();
 
-  const channels = channelId?.split("_");
-
   useEffect(() => {
     /**
      * 由课本进入查询当前用户的权限和channel

+ 31 - 10
dashboard/src/components/channel/ChannelMy.tsx

@@ -9,6 +9,7 @@ import {
   Select,
   Skeleton,
   Space,
+  Tooltip,
   Tree,
 } from "antd";
 import {
@@ -26,7 +27,7 @@ import {
   ISentInChapterListResponse,
 } from "../api/Channel";
 import { IItem, IProgressRequest } from "./ChannelPickerTable";
-import { LockIcon } from "../../assets/icon";
+import { LockFillIcon, LockIcon } from "../../assets/icon";
 import StudioName from "../auth/Studio";
 import ProgressSvg from "./ProgressSvg";
 
@@ -35,6 +36,17 @@ import CopyToModal from "./CopyToModal";
 import { ArticleType } from "../article/Article";
 import { ChannelInfoModal } from "./ChannelInfo";
 
+export const getSentIdInArticle = () => {
+  let sentList: string[] = [];
+  const sentElement = document.querySelectorAll(".pcd_sent");
+  for (let index = 0; index < sentElement.length; index++) {
+    const element = sentElement[index];
+    const id = element.id.split("_")[1];
+    sentList.push(id);
+  }
+  return sentList;
+};
+
 interface ChannelTreeNode {
   key: string;
   title: string | React.ReactNode;
@@ -157,13 +169,7 @@ const ChannelMy = ({
           });
       }
     } else {
-      const sentElement = document.querySelectorAll(".pcd_sent");
-      for (let index = 0; index < sentElement.length; index++) {
-        const element = sentElement[index];
-        const id = element.id.split("_")[1];
-        sentList.push(id);
-      }
-      setSentencesId(sentList);
+      setSentencesId(getSentIdInArticle());
       loadChannel(sentList);
     }
   };
@@ -323,11 +329,26 @@ const ChannelMy = ({
             titleRender={(node: ChannelTreeNode) => {
               let pIcon = <></>;
               switch (node.channel.publicity) {
+                case 5:
+                  pIcon = (
+                    <Tooltip title={"私有不可公开"}>
+                      <LockFillIcon />
+                    </Tooltip>
+                  );
+                  break;
                 case 10:
-                  pIcon = <LockIcon />;
+                  pIcon = (
+                    <Tooltip title={"私有"}>
+                      <LockIcon />
+                    </Tooltip>
+                  );
                   break;
                 case 30:
-                  pIcon = <GlobalOutlined />;
+                  pIcon = (
+                    <Tooltip title={"公开"}>
+                      <GlobalOutlined />
+                    </Tooltip>
+                  );
                   break;
               }
               const badge = selectedRowKeys.findIndex(

+ 5 - 2
dashboard/src/components/channel/ChannelPickerTable.tsx

@@ -14,7 +14,7 @@ import {
 import { IApiResponseChannelList, IFinal, TChannelType } from "../api/Channel";
 import { post } from "../../request";
 import { LockIcon } from "../../assets/icon";
-import StudioName, { IStudio } from "../auth/Studio";
+import Studio, { IStudio } from "../auth/Studio";
 import ProgressSvg from "./ProgressSvg";
 import { IChannel } from "./Channel";
 import { ArticleType } from "../article/Article";
@@ -293,6 +293,9 @@ const ChannelPickerTableWidget = ({
             render(dom, entity, index, action, schema) {
               let pIcon = <></>;
               switch (entity.publicity) {
+                case 5:
+                  pIcon = <LockIcon />;
+                  break;
                 case 10:
                   pIcon = <LockIcon />;
                   break;
@@ -336,7 +339,7 @@ const ChannelPickerTableWidget = ({
                       }}
                     >
                       <Space>
-                        <StudioName data={entity.studio} hideName />
+                        <Studio data={entity.studio} hideName />
                         {entity.title}
                       </Space>
                     </Button>

+ 20 - 14
dashboard/src/components/channel/ChannelSelect.tsx

@@ -6,6 +6,7 @@ import { currentUser } from "../../reducers/current-user";
 import { get } from "../../request";
 import { IApiResponseChannelList } from "../api/Channel";
 import { IStudio } from "../auth/Studio";
+import { useIntl } from "react-intl";
 
 interface IOption {
   value: string;
@@ -20,6 +21,7 @@ interface IWidget {
   name?: string;
   tooltip?: string;
   label?: string;
+  allowClear?: boolean;
   parentChannelId?: string;
   parentStudioId?: string;
   placeholder?: string;
@@ -34,21 +36,25 @@ const ChannelSelectWidget = ({
   parentChannelId,
   parentStudioId,
   placeholder,
+  allowClear = true,
   onSelect,
 }: IWidget) => {
   const user = useAppSelector(currentUser);
+  const intl = useIntl();
   return (
     <ProFormCascader
       width={width}
       name={name}
       tooltip={tooltip}
       label={label}
+      allowClear={allowClear}
       placeholder={placeholder}
       request={async ({ keyWords }) => {
-        console.log("keyWord", keyWords);
-        const json = await get<IApiResponseChannelList>(
-          `/v2/channel?view=user-edit&key=${keyWords}`
-        );
+        console.debug("keyWord", keyWords);
+        const url = `/v2/channel?view=user-edit&key=${keyWords}`;
+        console.info("ChannelSelect api request", url);
+        const json = await get<IApiResponseChannelList>(url);
+        console.debug("ChannelSelect api response", json);
         if (json.ok) {
           //获取studio list
           let studio = new Map<string, string>();
@@ -61,7 +67,14 @@ const ChannelSelectWidget = ({
           let channels: IOption[] = [];
 
           if (user && user.id === parentStudioId) {
-            channels.push({ value: "", label: "通用于此Studio" });
+            if (!user.roles?.includes("basic")) {
+              channels.push({
+                value: "",
+                label: intl.formatMessage({
+                  id: "term.general-in-studio",
+                }),
+              });
+            }
           }
 
           if (typeof parentChannelId === "string") {
@@ -107,16 +120,9 @@ const ChannelSelectWidget = ({
               };
               return node;
             });
-          channels = [
-            {
-              value: "",
-              label: "通用于此Studio",
-            },
-            ...channels,
-            ...others,
-          ];
+          channels = [...channels, ...others];
 
-          console.log("json", channels);
+          console.debug("ChannelSelect json", channels);
           return channels;
         } else {
           message.error(json.message);

+ 25 - 33
dashboard/src/components/channel/ChannelTable.tsx

@@ -1,5 +1,5 @@
 import { ActionType, ProTable } from "@ant-design/pro-components";
-import { useIntl } from "react-intl";
+import { FormattedMessage, useIntl } from "react-intl";
 import { Link } from "react-router-dom";
 import { Alert, Badge, message, Modal, Typography } from "antd";
 import { Button, Dropdown, Popover } from "antd";
@@ -31,6 +31,29 @@ import { TransferOutLinedIcon } from "../../assets/icon";
 
 const { Text } = Typography;
 
+export const channelTypeFilter = {
+  all: {
+    text: <FormattedMessage id="channel.type.all.title" />,
+    status: "Default",
+  },
+  translation: {
+    text: <FormattedMessage id="channel.type.translation.label" />,
+    status: "Success",
+  },
+  nissaya: {
+    text: <FormattedMessage id="channel.type.nissaya.label" />,
+    status: "Processing",
+  },
+  commentary: {
+    text: <FormattedMessage id="channel.type.commentary.label" />,
+    status: "Default",
+  },
+  original: {
+    text: <FormattedMessage id="channel.type.original.label" />,
+    status: "Default",
+  },
+};
+
 export interface IResNumberResponse {
   ok: boolean;
   message: string;
@@ -269,38 +292,7 @@ const ChannelTableWidget = ({
             search: false,
             filters: true,
             onFilter: true,
-            valueEnum: {
-              all: {
-                text: intl.formatMessage({
-                  id: "channel.type.all.title",
-                }),
-                status: "Default",
-              },
-              translation: {
-                text: intl.formatMessage({
-                  id: "channel.type.translation.label",
-                }),
-                status: "Success",
-              },
-              nissaya: {
-                text: intl.formatMessage({
-                  id: "channel.type.nissaya.label",
-                }),
-                status: "Processing",
-              },
-              commentary: {
-                text: intl.formatMessage({
-                  id: "channel.type.commentary.label",
-                }),
-                status: "Default",
-              },
-              original: {
-                text: intl.formatMessage({
-                  id: "channel.type.original.label",
-                }),
-                status: "Default",
-              },
-            },
+            valueEnum: channelTypeFilter,
           },
           {
             title: intl.formatMessage({

+ 61 - 0
dashboard/src/components/corpus/SentMyEditList.tsx

@@ -0,0 +1,61 @@
+import { ProList } from "@ant-design/pro-components";
+import { ISentenceData, ISentenceListResponse } from "../api/Corpus";
+import { get } from "../../request";
+import { channelTypeFilter } from "../channel/ChannelTable";
+import MdView from "../template/MdView";
+
+const SentMyEditList = () => {
+  return (
+    <ProList<ISentenceData>
+      rowKey="id"
+      search={{
+        filterType: "light",
+      }}
+      options={{
+        search: false,
+      }}
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let url = `/v2/sentence?view=my-edit&html=true`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : 20);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        url += params.keyword ? "&search=" + params.keyword : "";
+        console.info("api request", url);
+        const res = await get<ISentenceListResponse>(url);
+        console.debug("api response", res);
+        return {
+          total: res.data.count,
+          succcess: true,
+          data: res.data.rows,
+        };
+      }}
+      pagination={{
+        pageSize: 10,
+      }}
+      metas={{
+        title: {
+          dataIndex: "html",
+          title: "译文",
+          render(dom, entity, index, action, schema) {
+            return <MdView html={entity.html} />;
+          },
+        },
+        description: {
+          dataIndex: "title",
+          search: false,
+        },
+
+        status: {
+          // 自己扩展的字段,主要用于筛选,不在列表中显示
+          title: "状态",
+          valueType: "select",
+          valueEnum: channelTypeFilter,
+        },
+      }}
+    />
+  );
+};
+
+export default SentMyEditList;

+ 0 - 71
dashboard/src/components/course/AddLesson.tsx

@@ -1,71 +0,0 @@
-import { useIntl } from "react-intl";
-import { ProForm, ProFormSelect } from "@ant-design/pro-components";
-import { Button, message, Popover } from "antd";
-import { UserAddOutlined } from "@ant-design/icons";
-import { get } from "../../request";
-
-import { IUserListResponse } from "../api/Auth";
-
-interface IFormData {
-  userId: string;
-}
-
-interface IWidget {
-  groupId?: string;
-}
-const AddLessonWidget = ({ groupId }: IWidget) => {
-  const intl = useIntl();
-
-  const form = (
-    <ProForm<IFormData>
-      onFinish={async (values: IFormData) => {
-        console.log(values);
-        message.success(intl.formatMessage({ id: "flashes.success" }));
-      }}
-    >
-      <ProForm.Group>
-        <ProFormSelect
-          name="userId"
-          label={intl.formatMessage({ id: "forms.fields.user.label" })}
-          showSearch
-          debounceTime={300}
-          request={async ({ keyWord }) => {
-            console.log("keyWord", keyWord);
-            const json = await get<IUserListResponse>(`/v2/user?view=key&key=`);
-            const userList = json.data.rows.map((item) => {
-              return {
-                value: item.id,
-                label: `${item.userName}-${item.nickName}`,
-              };
-            });
-            console.log("json", userList);
-            return userList;
-          }}
-          placeholder={intl.formatMessage({ id: "forms.fields.user.required" })}
-          rules={[
-            {
-              required: true,
-              message: intl.formatMessage({
-                id: "forms.message.user.required",
-              }),
-            },
-          ]}
-        />
-      </ProForm.Group>
-    </ProForm>
-  );
-  return (
-    <Popover
-      placement="bottom"
-      arrowPointAtCenter
-      content={form}
-      trigger="click"
-    >
-      <Button icon={<UserAddOutlined />} key="add" type="primary">
-        {intl.formatMessage({ id: "buttons.lesson.add.lesson" })}
-      </Button>
-    </Popover>
-  );
-};
-
-export default AddLessonWidget;

+ 38 - 15
dashboard/src/components/course/CourseHead.tsx

@@ -1,6 +1,6 @@
 //课程详情图片标题按钮主讲人组合
 import { Link } from "react-router-dom";
-import { Image, Space, Col, Row, Breadcrumb } from "antd";
+import { Image, Space, Col, Row, Breadcrumb, Tag } from "antd";
 import { Typography } from "antd";
 import { HomeOutlined } from "@ant-design/icons";
 
@@ -23,7 +23,7 @@ const courseDuration = (startAt?: string, endAt?: string) => {
   } else {
     labelDuration = "已经结束";
   }
-  return labelDuration;
+  return <Tag>{labelDuration}</Tag>;
 };
 
 interface IWidget {
@@ -33,6 +33,8 @@ interface IWidget {
   coverUrl?: string[];
   startAt?: string;
   endAt?: string;
+  signUpStartAt?: string;
+  signUpEndAt?: string;
   teacher?: IUser;
   join?: TCourseJoinMode;
 }
@@ -44,10 +46,20 @@ const CourseHeadWidget = ({
   teacher,
   startAt,
   endAt,
+  signUpStartAt,
+  signUpEndAt,
   join,
 }: IWidget) => {
   const intl = useIntl();
   const duration = courseDuration(startAt, endAt);
+  let signUp = "";
+  if (moment().isBefore(moment(signUpStartAt))) {
+    signUp = "未开始";
+  } else if (moment().isBetween(moment(signUpStartAt), moment(signUpEndAt))) {
+    signUp = "可报名";
+  } else if (moment().isAfter(moment(signUpEndAt))) {
+    signUp = "已结束";
+  }
   return (
     <>
       <Row>
@@ -79,12 +91,22 @@ const CourseHeadWidget = ({
               <Space direction="vertical">
                 <Title level={3}>{title}</Title>
                 <Title level={5}>{subtitle}</Title>
-
                 <Text>
-                  {moment(startAt).format("YYYY-MM-DD")}——
-                  {moment(endAt).format("YYYY-MM-DD")}
+                  <Space>
+                    {"报名时间:"}
+                    {moment(signUpStartAt).format("YYYY-MM-DD")}——
+                    {moment(signUpEndAt).format("YYYY-MM-DD")}
+                    <Tag>{signUp}</Tag>
+                  </Space>
+                </Text>
+                <Text>
+                  <Space>
+                    {"课程时间:"}
+                    {moment(startAt).format("YYYY-MM-DD")}——
+                    {moment(endAt).format("YYYY-MM-DD")}
+                    {duration}
+                  </Space>
                 </Text>
-                <Text>{duration}</Text>
                 <Text>
                   {join
                     ? intl.formatMessage({
@@ -92,15 +114,16 @@ const CourseHeadWidget = ({
                       })
                     : undefined}
                 </Text>
-                {id ? (
-                  <Status
-                    courseId={id}
-                    courseName={title}
-                    joinMode={join}
-                    startAt={startAt}
-                    endAt={endAt}
-                  />
-                ) : undefined}
+
+                <Status
+                  courseId={id}
+                  courseName={title}
+                  joinMode={join}
+                  startAt={startAt}
+                  endAt={endAt}
+                  signUpStartAt={signUpStartAt}
+                  signUpEndAt={signUpEndAt}
+                />
               </Space>
             </Space>
 

+ 19 - 27
dashboard/src/components/course/CourseInfoEdit.tsx

@@ -34,12 +34,13 @@ interface IFormData {
   title: string;
   subtitle: string;
   summary?: string;
-  content?: string;
+  content?: string | null;
   cover?: UploadFile<IAttachmentResponse>[];
   teacherId?: string;
   anthologyId?: string;
   channelId?: string;
-  dateRange?: Date[];
+  dateRange?: string[];
+  signUp?: string[];
   status: number;
   join: string;
   exp: string;
@@ -69,23 +70,12 @@ const CourseInfoEditWidget = ({
       <ProForm<IFormData>
         formKey="course_edit"
         onFinish={async (values: IFormData) => {
-          console.log("all data", values);
-          let startAt: string, endAt: string;
+          console.log("course put all data", values);
           let _cover: string = "";
-          if (typeof values.dateRange === "undefined") {
-            startAt = "";
-            endAt = "";
-          } else if (
-            typeof values.dateRange[0] === "string" &&
-            typeof values.dateRange[1] === "string"
-          ) {
-            startAt = values.dateRange[0];
-            endAt = values.dateRange[1];
-          } else {
-            startAt = courseData ? courseData.start_at : "";
-            endAt = courseData ? courseData.end_at : "";
-          }
-
+          const startAt = values.dateRange ? values.dateRange[0] : "";
+          const endAt = values.dateRange ? values.dateRange[1] : "";
+          const signUpStartAt = values.signUp ? values.signUp[0] : null;
+          const signUpEndAt = values.signUp ? values.signUp[1] : null;
           if (
             typeof values.cover === "undefined" ||
             values.cover.length === 0
@@ -98,7 +88,7 @@ const CourseInfoEditWidget = ({
             _cover = values.cover[0].response.data.name;
           }
           const url = `/v2/course/${courseId}`;
-          const postData = {
+          const postData: ICourseDataRequest = {
             title: values.title, //标题
             subtitle: values.subtitle, //副标题
             summary: values.summary,
@@ -110,6 +100,8 @@ const CourseInfoEditWidget = ({
             channel_id: values.channelId,
             start_at: startAt, //课程开始时间
             end_at: endAt, //课程结束时间
+            sign_up_start_at: signUpStartAt,
+            sign_up_end_at: signUpEndAt,
             join: values.join,
             request_exp: values.exp,
           };
@@ -162,7 +154,7 @@ const CourseInfoEditWidget = ({
             title: res.data.title,
             subtitle: res.data.subtitle,
             summary: res.data.summary,
-            content: res.data.content,
+            content: res.data.content ?? "",
             cover: res.data.cover
               ? [
                   {
@@ -178,10 +170,8 @@ const CourseInfoEditWidget = ({
             teacherId: res.data.teacher?.id,
             anthologyId: res.data.anthology_id,
             channelId: res.data.channel_id,
-            dateRange:
-              res.data.start_at && res.data.end_at
-                ? [new Date(res.data.start_at), new Date(res.data.end_at)]
-                : undefined,
+            dateRange: [res.data.start_at, res.data.end_at],
+            signUp: [res.data.sign_up_start_at, res.data.sign_up_end_at],
             status: res.data.publicity,
             join: res.data.join,
             exp: res.data.request_exp,
@@ -262,13 +252,15 @@ const CourseInfoEditWidget = ({
               id: "forms.fields.teacher.label",
             })}
           />
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormDateRangePicker width="md" name="signUp" label="报名时间" />
           <ProFormDateRangePicker
             width="md"
             name="dateRange"
-            label="课程间"
+            label="课程间"
           />
         </ProForm.Group>
-
         <ProForm.Group>
           <ProFormSelect
             options={textbookOption}
@@ -322,7 +314,7 @@ const CourseInfoEditWidget = ({
           />
         </ProForm.Group>
         <ProForm.Group>
-          <PublicitySelect width="md" />
+          <PublicitySelect width="md" disable={["blocked"]} />
           <ProFormDependency name={["status"]}>
             {({ status }) => {
               const option = [

+ 3 - 1
dashboard/src/components/course/CourseMemberList.tsx

@@ -156,7 +156,9 @@ const CourseMemberListWidget = ({ courseId, onSelect }: IWidget) => {
                     course?.start_at,
                     course?.end_at,
                     course?.join,
-                    row.status
+                    row.status,
+                    course?.sign_up_start_at,
+                    course?.sign_up_end_at
                   ),
                 };
               });

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

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

+ 86 - 20
dashboard/src/components/course/RolePower.ts

@@ -8,6 +8,7 @@ import {
 export interface IAction {
   mode: TCourseJoinMode[];
   status: TCourseMemberStatus;
+  signUp?: TCourseMemberAction[];
   before: TCourseMemberAction[];
   duration: TCourseMemberAction[];
   after: TCourseMemberAction[];
@@ -17,9 +18,20 @@ export const getStudentActionsByStatus = (
   status?: TCourseMemberStatus,
   mode?: TCourseJoinMode,
   startAt?: string,
-  endAt?: string
+  endAt?: string,
+  signUpStartAt?: string,
+  signUpEndAt?: string
 ): TCourseMemberAction[] | undefined => {
-  const output = getActionsByStatus(studentData, status, mode, startAt, endAt);
+  const output = getActionsByStatus(
+    studentData,
+    status,
+    mode,
+    startAt,
+    endAt,
+    signUpStartAt,
+    signUpEndAt
+  );
+  console.log("getStudentActionsByStatus", output);
   return output;
 };
 const getActionsByStatus = (
@@ -27,32 +39,54 @@ const getActionsByStatus = (
   status?: TCourseMemberStatus,
   mode?: TCourseJoinMode,
   startAt?: string,
-  endAt?: string
+  endAt?: string,
+  signUpStartAt?: string,
+  signUpEndAt?: string
 ): TCourseMemberAction[] | undefined => {
+  console.debug("getActionsByStatus start");
   if (!startAt || !endAt || !mode || !status) {
     return undefined;
   }
+  const inSignUp = moment().isBetween(
+    moment(signUpStartAt),
+    moment(signUpEndAt)
+  );
   const actions = data.find((value) => {
     if (value.mode.includes(mode) && value.status === status) {
+      console.debug(
+        "getActionsByStatus value",
+        value,
+        signUpStartAt,
+        signUpEndAt,
+        inSignUp
+      );
+      if (inSignUp) {
+        if (value.signUp && value.signUp.length > 0) {
+          console.debug("getActionsByStatus got it", value.signUp);
+          return true;
+        }
+      }
       if (moment().isBefore(moment(startAt))) {
         if (value.before && value.before.length > 0) {
           return true;
         }
-      } else if (moment().isBefore(moment(endAt))) {
+      }
+      if (moment().isBefore(moment(endAt))) {
         if (value.duration && value.duration.length > 0) {
           return true;
         }
-      } else {
-        if (value.after && value.after.length > 0) {
-          return true;
-        }
+      }
+      if (value.after && value.after.length > 0) {
+        return true;
       }
     }
-    return undefined;
+    return false;
   });
 
   if (actions) {
-    if (moment().isBefore(moment(startAt))) {
+    if (inSignUp && actions.signUp && actions.signUp.length > 0) {
+      return actions.signUp;
+    } else if (moment().isBefore(moment(startAt))) {
       return actions.before;
     } else if (moment().isBefore(moment(endAt))) {
       return actions.duration;
@@ -70,7 +104,9 @@ export const test = (
   startAt?: string,
   endAt?: string,
   mode?: TCourseJoinMode,
-  status?: TCourseMemberStatus
+  status?: TCourseMemberStatus,
+  signUpStartAt?: string,
+  signUpEndAt?: string
 ): boolean => {
   if (!startAt || !endAt || !mode || !status) {
     return false;
@@ -80,7 +116,9 @@ export const test = (
     status,
     mode,
     startAt,
-    endAt
+    endAt,
+    signUpStartAt,
+    signUpEndAt
   )?.includes(action);
 
   if (canDo) {
@@ -95,13 +133,24 @@ export const managerCanDo = (
   startAt?: string,
   endAt?: string,
   mode?: TCourseJoinMode,
-  status?: TCourseMemberStatus
+  status?: TCourseMemberStatus,
+  signUpStartAt?: string,
+  signUpEndAt?: string
 ): boolean => {
   if (!startAt || !endAt || !mode || !status) {
     return false;
   }
 
-  return test(managerData, action, startAt, endAt, mode, status);
+  return test(
+    managerData,
+    action,
+    startAt,
+    endAt,
+    mode,
+    status,
+    signUpStartAt,
+    signUpEndAt
+  );
 };
 
 export const studentCanDo = (
@@ -109,13 +158,24 @@ export const studentCanDo = (
   startAt?: string,
   endAt?: string,
   mode?: TCourseJoinMode,
-  status?: TCourseMemberStatus
+  status?: TCourseMemberStatus,
+  signUpStartAt?: string,
+  signUpEndAt?: string
 ): boolean => {
   if (!startAt || !endAt || !mode || !status) {
     return false;
   }
 
-  return test(studentData, action, startAt, endAt, mode, status);
+  return test(
+    studentData,
+    action,
+    startAt,
+    endAt,
+    mode,
+    status,
+    signUpStartAt,
+    signUpEndAt
+  );
 };
 
 interface IStatusColor {
@@ -146,8 +206,9 @@ const studentData: IAction[] = [
   {
     mode: ["open"],
     status: "none",
+    signUp: ["join"],
     before: [],
-    duration: ["join"],
+    duration: [],
     after: [],
   },
   {
@@ -167,6 +228,7 @@ const studentData: IAction[] = [
   {
     mode: ["open"],
     status: "left",
+    signUp: ["join"],
     before: [],
     duration: ["join"],
     after: [],
@@ -174,13 +236,15 @@ const studentData: IAction[] = [
   {
     mode: ["manual", "invite"],
     status: "none",
-    before: ["apply"],
+    signUp: ["apply"],
+    before: [],
     duration: [],
     after: [],
   },
   {
     mode: ["manual", "invite"],
     status: "invited",
+    signUp: ["agree", "disagree"],
     before: ["agree", "disagree"],
     duration: [],
     after: [],
@@ -188,7 +252,8 @@ const studentData: IAction[] = [
   {
     mode: ["manual", "invite"],
     status: "revoked",
-    before: ["apply"],
+    signUp: ["apply"],
+    before: [],
     duration: [],
     after: [],
   },
@@ -223,7 +288,8 @@ const studentData: IAction[] = [
   {
     mode: ["manual", "invite"],
     status: "canceled",
-    before: ["apply"],
+    signUp: ["apply"],
+    before: [],
     duration: [],
     after: [],
   },

+ 17 - 5
dashboard/src/components/course/Status.tsx

@@ -24,27 +24,35 @@ import { getStatusColor, getStudentActionsByStatus } from "./RolePower";
 const { Paragraph } = Typography;
 
 interface IWidget {
-  courseId: string;
+  courseId?: string;
   courseName?: string;
   startAt?: string;
   endAt?: string;
+  signUpStartAt?: string;
+  signUpEndAt?: string;
   joinMode?: TCourseJoinMode;
 }
 const StatusWidget = ({
   courseId,
   courseName,
-  joinMode,
   startAt,
   endAt,
+  signUpStartAt,
+  signUpEndAt,
+  joinMode,
 }: IWidget) => {
   const intl = useIntl();
   const [currMember, setCurrMember] = useState<ICourseMemberData>();
   const user = useAppSelector(currentUser);
 
+  console.debug("course status", signUpStartAt, signUpEndAt);
   useEffect(() => {
     /**
      * 获取该课程我的报名状态
      */
+    if (typeof courseId === "undefined") {
+      return;
+    }
     const url = `/v2/course-member/${courseId}`;
     console.info("api request", url);
     get<ICourseMemberResponse>(url).then((json) => {
@@ -67,9 +75,11 @@ const StatusWidget = ({
     currStatus,
     joinMode,
     startAt,
-    endAt
+    endAt,
+    signUpStartAt,
+    signUpEndAt
   );
-  console.debug("getStudentActionsByStatus", currStatus, actions);
+  console.debug("getStudentActionsByStatus", currStatus, joinMode, actions);
   if (user) {
     labelStatus = intl.formatMessage({
       id: `course.member.status.${currStatus}.label`,
@@ -107,11 +117,13 @@ const StatusWidget = ({
     );
   }
 
-  return (
+  return courseId ? (
     <Paragraph>
       <div style={{ color: getStatusColor(currStatus) }}>{labelStatus}</div>
       {operation}
     </Paragraph>
+  ) : (
+    <></>
   );
 };
 

+ 7 - 7
dashboard/src/components/discussion/DiscussionAnchor.tsx

@@ -32,14 +32,14 @@ const DiscussionAnchorWidget = ({
 
   useEffect(() => {
     if (typeof topicId === "string") {
-      get<ICommentAnchorResponse>(`/v2/discussion-anchor/${topicId}`).then(
-        (json) => {
-          console.log(json);
-          if (json.ok) {
-            setContent(json.data);
-          }
+      const url = `/v2/discussion-anchor/${topicId}`;
+      console.info("api request", url);
+      get<ICommentAnchorResponse>(url).then((json) => {
+        console.debug("api response", json);
+        if (json.ok) {
+          setContent(json.data);
         }
-      );
+      });
     }
   }, [topicId]);
 

+ 82 - 0
dashboard/src/components/discussion/DiscussionButton.tsx

@@ -0,0 +1,82 @@
+import { Space, Tooltip } from "antd";
+import store from "../../store";
+import { IShowDiscussion, show } from "../../reducers/discussion";
+import { openPanel } from "../../reducers/right-panel";
+import { CommentFillIcon, CommentOutlinedIcon } from "../../assets/icon";
+import { TResType } from "./DiscussionListCard";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { discussionList } from "../../reducers/discussion-count";
+
+interface IWidget {
+  initCount?: number;
+  resId?: string;
+  resType?: TResType;
+  hideCount?: boolean;
+  hideInZero?: boolean;
+  onlyMe?: boolean;
+}
+const DiscussionButton = ({
+  initCount = 0,
+  resId,
+  resType = "sentence",
+  hideCount = false,
+  hideInZero = false,
+  onlyMe = false,
+}: IWidget) => {
+  const user = useAppSelector(currentUser);
+  const discussions = useAppSelector(discussionList);
+
+  console.debug("discussions", discussions);
+
+  const all = discussions?.filter((value) => value.res_id === resId);
+  const my = all?.filter((value) => value.editor_uid === user?.id);
+  let currCount = initCount;
+  if (onlyMe) {
+    if (my) {
+      currCount = my.length;
+    } else {
+      currCount = 0;
+    }
+  } else {
+    if (all) {
+      currCount = all.length;
+    } else {
+      currCount = 0;
+    }
+  }
+
+  let myCount = false;
+  if (my && my.length > 0) {
+    myCount = true;
+  }
+
+  return hideInZero && currCount === 0 ? (
+    <></>
+  ) : (
+    <Tooltip title="讨论">
+      <Space
+        size={"small"}
+        style={{
+          cursor: "pointer",
+          color: currCount && currCount > 0 ? "#1890ff" : "unset",
+        }}
+        onClick={(event) => {
+          const data: IShowDiscussion = {
+            type: "discussion",
+            resId: resId,
+            resType: resType,
+          };
+          console.debug("discussion show", data);
+          store.dispatch(show(data));
+          store.dispatch(openPanel("discussion"));
+        }}
+      >
+        {myCount ? <CommentFillIcon /> : <CommentOutlinedIcon />}
+        {hideCount ? <></> : currCount}
+      </Space>
+    </Tooltip>
+  );
+};
+
+export default DiscussionButton;

+ 62 - 0
dashboard/src/components/discussion/DiscussionCount.tsx

@@ -0,0 +1,62 @@
+import { useEffect } from "react";
+import { useAppSelector } from "../../hooks";
+import { publish, upgrade } from "../../reducers/discussion-count";
+import { sentenceList } from "../../reducers/sentence";
+import {
+  IDiscussionCountRequest,
+  IDiscussionCountResponse,
+} from "../api/Comment";
+import { get, post } from "../../request";
+import store from "../../store";
+
+export const discussionCountUpgrade = (resId?: string) => {
+  if (typeof resId === "undefined") {
+    return;
+  }
+  const url = `/v2/discussion-count/${resId}`;
+  console.info("discussion-count api request", url);
+  get<IDiscussionCountResponse>(url).then((json) => {
+    console.debug("discussion-count api response", json);
+    if (json.ok) {
+      store.dispatch(upgrade({ resId: resId, data: json.data }));
+    } else {
+      console.error(json.message);
+    }
+  });
+};
+
+interface IWidget {
+  courseId?: string | null;
+}
+
+const DiscussionCount = ({ courseId }: IWidget) => {
+  const sentences = useAppSelector(sentenceList);
+
+  console.debug("sentences", sentences);
+
+  useEffect(() => {
+    const sentId: string[] = sentences
+      .filter((value) => typeof value.id != "undefined")
+      .map((item) => item.id);
+    if (sentId.length === 0) {
+      return;
+    }
+    const url = "/v2/discussion-count";
+    const data: IDiscussionCountRequest = {
+      course_id: courseId ?? undefined,
+      sentences: sentId.map((item) => item.split("-")),
+    };
+    console.info("discussion-count api request", url, data);
+    post<IDiscussionCountRequest, IDiscussionCountResponse>(url, data).then(
+      (json) => {
+        console.debug("discussion-count api response", json);
+        if (json.ok) {
+          store.dispatch(publish(json.data));
+        }
+      }
+    );
+  }, [courseId, sentences]);
+  return <></>;
+};
+
+export default DiscussionCount;

+ 16 - 7
dashboard/src/components/discussion/DiscussionCreate.tsx

@@ -21,6 +21,7 @@ import { currentUser as _currentUser } from "../../reducers/current-user";
 import { useEffect, useRef, useState } from "react";
 import MDEditor from "@uiw/react-md-editor";
 import { TDiscussionType } from "./Discussion";
+import { discussionCountUpgrade } from "./DiscussionCount";
 
 export type TContentType = "text" | "markdown" | "html" | "json";
 
@@ -85,6 +86,10 @@ const DiscussionCreateWidget = ({
               let newParent: string | undefined;
               if (typeof currParent === "undefined") {
                 if (typeof topic !== "undefined" && topic.tplId) {
+                  /**
+                   * 在模版下跟帖
+                   * 先建立模版topic,再建立跟帖
+                   */
                   const topicData: ICommentRequest = {
                     res_id: resId,
                     res_type: resType,
@@ -94,12 +99,14 @@ const DiscussionCreateWidget = ({
                     content_type: "markdown",
                     type: topic.type,
                   };
-                  console.log("create topic", topicData);
+                  const url = `/v2/discussion`;
+                  console.log("create topic api request", url, topicData);
                   const newTopic = await post<
                     ICommentRequest,
                     ICommentResponse
-                  >(`/v2/discussion`, topicData);
+                  >(url, topicData);
                   if (newTopic.ok) {
+                    discussionCountUpgrade(resId);
                     setCurrParent(newTopic.data.id);
                     newParent = newTopic.data.id;
                     if (typeof onTopicCreated !== "undefined") {
@@ -111,9 +118,8 @@ const DiscussionCreateWidget = ({
                   }
                 }
               }
-              console.log("parent", currParent);
-
-              post<ICommentRequest, ICommentResponse>(`/v2/discussion`, {
+              const url = `/v2/discussion`;
+              const data: ICommentRequest = {
                 res_id: resId,
                 res_type: resType,
                 parent: newParent ? newParent : currParent,
@@ -122,11 +128,14 @@ const DiscussionCreateWidget = ({
                 content: values.content,
                 content_type: contentType,
                 type: topic ? topic.type : type,
-              })
+              };
+              console.info("api request", url, data);
+              post<ICommentRequest, ICommentResponse>(url, data)
                 .then((json) => {
-                  console.log("new discussion", json);
+                  console.debug("new discussion api response", json);
                   if (json.ok) {
                     formRef.current?.resetFields();
+                    discussionCountUpgrade(resId);
                     if (typeof onCreated !== "undefined") {
                       onCreated(toIComment(json.data));
                     }

+ 6 - 3
dashboard/src/components/discussion/DiscussionEdit.tsx

@@ -54,12 +54,15 @@ const DiscussionEditWidget = ({
           },
         }}
         onFinish={async (values) => {
-          put<ICommentRequest, ICommentResponse>(`/v2/discussion/${data.id}`, {
+          const url = `/v2/discussion/${data.id}`;
+          const newData: ICommentRequest = {
             title: values.title,
             content: values.content,
-          })
+          };
+          console.info("DiscussionEdit api request", url, newData);
+          put<ICommentRequest, ICommentResponse>(url, newData)
             .then((json) => {
-              console.log(json);
+              console.debug("DiscussionEdit api response", json);
               if (json.ok) {
                 console.log(intl.formatMessage({ id: "flashes.success" }));
                 if (typeof onUpdated !== "undefined") {

+ 20 - 8
dashboard/src/components/discussion/DiscussionListCard.tsx

@@ -28,8 +28,10 @@ interface IWidget {
   resId?: string;
   resType?: TResType;
   topicId?: string;
+  userId?: string;
   changedAnswerCount?: IAnswerCount;
   type?: TDiscussionType;
+  pageSize?: number;
   onSelect?: Function;
   onItemCountChange?: Function;
   onReply?: Function;
@@ -39,9 +41,11 @@ const DiscussionListCardWidget = ({
   resId,
   resType,
   topicId,
+  userId,
   onSelect,
   changedAnswerCount,
   type = "discussion",
+  pageSize = 10,
   onItemCountChange,
   onReply,
   onReady,
@@ -64,7 +68,11 @@ const DiscussionListCardWidget = ({
     ref.current?.reload();
   }, [changedAnswerCount]);
 
-  if (typeof resId === "undefined" && typeof topicId === "undefined") {
+  if (
+    typeof resId === "undefined" &&
+    typeof topicId === "undefined" &&
+    typeof userId === "undefined"
+  ) {
     return (
       <Typography.Paragraph>
         该资源尚未创建,不能发表讨论。
@@ -110,9 +118,7 @@ const DiscussionListCardWidget = ({
             search: false,
             render(dom, entity, index, action, schema) {
               return (
-                <span key={index}>
-                  {entity.summary ? entity.summary : entity.content}
-                </span>
+                <span key={index}>{entity.summary ?? entity.content}</span>
               );
             },
           },
@@ -135,6 +141,8 @@ const DiscussionListCardWidget = ({
             url += `view=question-by-topic&id=${topicId}`;
           } else if (typeof resId !== "undefined") {
             url += `view=question&id=${resId}`;
+          } else if (typeof userId !== "undefined") {
+            url += `view=topic-by-user`;
           } else {
             return {
               total: 0,
@@ -143,12 +151,13 @@ const DiscussionListCardWidget = ({
           }
           const offset =
             ((params.current ? params.current : 1) - 1) *
-            (params.pageSize ? params.pageSize : 20);
+            (params.pageSize ? params.pageSize : pageSize);
           url += `&limit=${params.pageSize}&offset=${offset}`;
           url += params.keyword ? "&search=" + params.keyword : "";
           url += activeKey ? "&status=" + activeKey : "";
-          console.log("url", url);
+          console.log("DiscussionListCard api request", url);
           const res = await get<ICommentListResponse>(url);
+          console.debug("DiscussionListCard api response", res);
           setCount(res.data.active);
           setCanCreate(res.data.can_create);
           const items: IComment[] = res.data.rows.map((item, id) => {
@@ -171,7 +180,10 @@ const DiscussionListCardWidget = ({
           });
 
           let topicTpl: IComment[] = [];
-          if (activeKey !== "close") {
+          if (
+            activeKey !== "close" &&
+            user?.roles?.includes("basic") === false
+          ) {
             //获取channel模版
             let studioName: string | undefined;
             switch (resType) {
@@ -230,7 +242,7 @@ const DiscussionListCardWidget = ({
         pagination={{
           showQuickJumper: true,
           showSizeChanger: true,
-          pageSize: 20,
+          pageSize: pageSize,
         }}
         search={false}
         options={{

+ 23 - 10
dashboard/src/components/discussion/DiscussionShow.tsx

@@ -33,6 +33,7 @@ import { ICommentRequest, ICommentResponse } from "../api/Comment";
 import { useState } from "react";
 import MdView from "../template/MdView";
 import { TDiscussionType } from "./Discussion";
+import { discussionCountUpgrade } from "./DiscussionCount";
 
 const { Text } = Typography;
 
@@ -58,7 +59,7 @@ const DiscussionShowWidget = ({
 }: IWidget) => {
   const intl = useIntl();
   const [closed, setClosed] = useState(data.status);
-  const showDeleteConfirm = (id: string, title: string) => {
+  const showDeleteConfirm = (id: string, resId: string, title: string) => {
     Modal.confirm({
       icon: <ExclamationCircleOutlined />,
       title:
@@ -78,11 +79,14 @@ const DiscussionShowWidget = ({
         id: "buttons.no",
       }),
       onOk() {
-        console.log("delete", id);
-        return delete_<IDeleteResponse>(`/v2/discussion/${id}`)
+        const url = `/v2/discussion/${id}`;
+        console.info("Discussion delete api request", url);
+        return delete_<IDeleteResponse>(url)
           .then((json) => {
+            console.debug("api response", json);
             if (json.ok) {
               message.success("删除成功");
+              discussionCountUpgrade(resId);
               if (typeof onDelete !== "undefined") {
                 onDelete(id);
               }
@@ -96,29 +100,38 @@ const DiscussionShowWidget = ({
   };
 
   const close = (value: boolean) => {
-    put<ICommentRequest, ICommentResponse>(`/v2/discussion/${data.id}`, {
+    const url = `/v2/discussion/${data.id}`;
+    const newData: ICommentRequest = {
       title: data.title,
       content: data.content,
       status: value ? "close" : "active",
-    }).then((json) => {
+    };
+    console.info("api request", url, newData);
+    put<ICommentRequest, ICommentResponse>(url, newData).then((json) => {
       console.log(json);
       if (json.ok) {
         setClosed(json.data.status);
+        discussionCountUpgrade(data.resId);
         if (typeof onClose !== "undefined") {
           onClose(value);
         }
+      } else {
+        message.error(json.message);
       }
     });
   };
 
   const convert = (newType: TDiscussionType) => {
-    put<ICommentRequest, ICommentResponse>(`/v2/discussion/${data.id}`, {
+    const url = `/v2/discussion/${data.id}`;
+    const newData: ICommentRequest = {
       title: data.title,
       content: data.content,
       status: data.status,
       type: newType,
-    }).then((json) => {
-      console.log(json);
+    };
+    console.debug("api response", url, newData);
+    put<ICommentRequest, ICommentResponse>(url, newData).then((json) => {
+      console.debug("api response", json);
       if (json.ok) {
         notification.info({ message: "转换成功" });
         if (typeof onConvert !== "undefined") {
@@ -174,8 +187,8 @@ const DiscussionShowWidget = ({
         convert("discussion");
         break;
       case "delete":
-        if (data.id) {
-          showDeleteConfirm(data.id, data.title ? data.title : "");
+        if (data.id && data.resId) {
+          showDeleteConfirm(data.id, data.resId, data.title ?? "");
         }
         break;
       default:

+ 4 - 1
dashboard/src/components/discussion/DiscussionTopicChildren.tsx

@@ -150,8 +150,11 @@ const DiscussionTopicChildrenWidget = ({
       return;
     }
     setLoading(true);
-    get<ICommentListResponse>(`/v2/discussion?view=answer&id=${topicId}`)
+    const url = `/v2/discussion?view=answer&id=${topicId}`;
+    console.info("api request", url);
+    get<ICommentListResponse>(url)
       .then((json) => {
+        console.debug("api response", json);
         if (json.ok) {
           const discussions: IComment[] = json.data.rows.map((item) => {
             return {

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

@@ -46,11 +46,11 @@ const DiscussionTopicInfoWidget = ({
       return;
     }
     const url = `/v2/discussion/${topicId}`;
-    console.log("discussion url", url);
+    console.info("discussion api request", url);
     get<ICommentResponse>(url)
       .then((json) => {
+        console.debug("api response", json);
         if (json.ok) {
-          console.log("flashes.success");
           const item = json.data;
           const discussion: IComment = {
             id: item.id,

+ 2 - 2
dashboard/src/components/discussion/QaList.tsx

@@ -19,10 +19,10 @@ const QaListWidget = ({ resId, resType, onSelect, onReply }: IWidget) => {
     }
     let url: string = `/v2/discussion?res_type=${resType}&view=res_id&id=${resId}`;
     url += "&dir=asc&type=qa&status=active,close";
-    console.log("url", url);
+    console.info("api request", url);
     get<ICommentListResponse>(url).then((json) => {
       if (json.ok) {
-        console.debug("discussion fetch qa", json);
+        console.debug("discussion api response", json);
         const items: IComment[] = json.data.rows.map((item, id) => {
           return {
             id: item.id,

+ 28 - 28
dashboard/src/components/exp/ExpStatisticCard.tsx

@@ -22,35 +22,35 @@ const ExpStatisticCardWidget = ({ studioName }: IWidget) => {
   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);
-          setTranslationPieData([
-            { type: "公开", value: json.data.translation.count_pub },
-            {
-              type: "未公开",
-              value:
-                json.data.translation.count - json.data.translation.count_pub,
-            },
-          ]);
-          setTermCount(json.data.term.count);
-          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);
-        }
+    const url = `/v2/user-statistic/${studioName}`;
+    console.info("api request", url);
+    get<IUserStatisticResponse>(url).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);
+        setTranslationPieData([
+          { type: "公开", value: json.data.translation.count_pub },
+          {
+            type: "未公开",
+            value:
+              json.data.translation.count - json.data.translation.count_pub,
+          },
+        ]);
+        setTermCount(json.data.term.count);
+        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 (

+ 42 - 0
dashboard/src/components/exp/ExpTime.tsx

@@ -0,0 +1,42 @@
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import { IUserStatisticResponse } from "../api/Exp";
+import { Skeleton } from "antd";
+
+interface IWidget {
+  userName?: string;
+}
+const ExpTime = ({ userName }: IWidget) => {
+  const [expSum, setExpSum] = useState<number>();
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    if (typeof userName === "undefined") {
+      return;
+    }
+    const url = `/v2/user-statistic/${userName}?view=exp-sum`;
+    console.info("api request", url);
+    setLoading(true);
+    get<IUserStatisticResponse>(url)
+      .then((json) => {
+        console.debug("api response", json);
+        if (json.ok) {
+          setExpSum(Math.ceil(json.data.exp.sum / 1000 / 60 / 60));
+        }
+      })
+      .finally(() => {
+        setLoading(false);
+      })
+      .catch((e) => console.error(e));
+  }, [userName]);
+  return loading ? (
+    <Skeleton.Button active={true} size={"small"} shape={"default"} />
+  ) : (
+    <>
+      {expSum}
+      {"小时"}
+    </>
+  );
+};
+
+export default ExpTime;

+ 7 - 39
dashboard/src/components/export/ShareButton.tsx

@@ -1,18 +1,9 @@
 import { useState } from "react";
 import { Button, Dropdown, Space, Typography } from "antd";
-import {
-  ShareAltOutlined,
-  ExportOutlined,
-  ForkOutlined,
-  InboxOutlined,
-} from "@ant-design/icons";
+import { ShareAltOutlined, ExportOutlined } from "@ant-design/icons";
 
 import ExportModal from "./ExportModal";
 import { ArticleType } from "../article/Article";
-import AddToAnthology from "../article/AddToAnthology";
-import { useAppSelector } from "../../hooks";
-import { currentUser } from "../../reducers/current-user";
-import { fullUrl } from "../../utils";
 
 const { Text } = Typography;
 
@@ -33,8 +24,6 @@ const ShareButtonWidget = ({
   anthologyId,
 }: IWidget) => {
   const [exportOpen, setExportOpen] = useState(false);
-  const [addToAnthologyOpen, setAddToAnthologyOpen] = useState(false);
-  const user = useAppSelector(currentUser);
 
   return (
     <>
@@ -54,38 +43,24 @@ const ShareButtonWidget = ({
               key: "export",
               icon: <ExportOutlined />,
             },
-            {
-              label: "添加到文集",
-              key: "add_to_anthology",
-              icon: <InboxOutlined />,
-              disabled: type === "article" ? false : true,
-            },
-            {
-              label: "创建副本",
-              key: "fork",
-              icon: <ForkOutlined />,
-              disabled: user && type === "article" ? false : true,
-            },
           ],
           onClick: ({ key }) => {
             switch (key) {
               case "export":
                 setExportOpen(true);
                 break;
-              case "add_to_anthology":
-                setAddToAnthologyOpen(true);
-                break;
-              case "fork":
-                const url = `/studio/${user?.nickName}/article/create?parent=${articleId}`;
-                window.open(fullUrl(url), "_blank");
-                break;
               default:
                 break;
             }
           },
         }}
       >
-        <Button type="text" icon={<ShareAltOutlined color="#fff" />} />
+        <Button
+          type="text"
+          icon={
+            <ShareAltOutlined style={{ color: "white", cursor: "pointer" }} />
+          }
+        />
       </Dropdown>
       <ExportModal
         type={type}
@@ -97,13 +72,6 @@ const ShareButtonWidget = ({
         open={exportOpen}
         onClose={() => setExportOpen(false)}
       />
-      {articleId ? (
-        <AddToAnthology
-          open={addToAnthologyOpen}
-          onClose={(isOpen: boolean) => setAddToAnthologyOpen(isOpen)}
-          articleIds={[articleId]}
-        />
-      ) : undefined}
     </>
   );
 };

+ 15 - 0
dashboard/src/components/general/NetStatus.tsx

@@ -15,6 +15,20 @@ const NetStatusWidget = ({ style }: IWidget) => {
 
   const _netStatus = useAppSelector(netStatus);
 
+  useEffect(() => {
+    // 监听网络连接状态变化
+    const onOnline = () => console.info("网络连接已恢复");
+    const onOffline = () => console.info("网络连接已中断");
+
+    window.addEventListener("online", onOnline);
+    window.addEventListener("offline", onOffline);
+
+    return () => {
+      window.removeEventListener("online", onOnline);
+      window.removeEventListener("offline", onOffline);
+    };
+  }, []);
+
   useEffect(() => {
     console.log("net status", _netStatus);
     switch (_netStatus?.status) {
@@ -34,6 +48,7 @@ const NetStatusWidget = ({ style }: IWidget) => {
       setLabel(_netStatus?.message);
     }
   }, [_netStatus]);
+
   return (
     <>
       <Button

+ 1 - 1
dashboard/src/components/nut/users/NonSignInSharedLinks.tsx

@@ -9,7 +9,7 @@ const Widget = () => {
         <FormattedMessage id="nut.users.sign-in.title" />
       </Link>
       <Divider type="vertical" />
-      <Link hidden to="/users/sign-up">
+      <Link to="/users/sign-up">
         <FormattedMessage id="nut.users.sign-up.title" />
       </Link>
       <Divider type="vertical" />

+ 6 - 1
dashboard/src/components/studio/PublicitySelect.tsx

@@ -1,7 +1,12 @@
 import { ProFormSelect } from "@ant-design/pro-components";
 import { useIntl } from "react-intl";
 
-export type TPublicity = "disable" | "private" | "public_no_list" | "public";
+export type TPublicity =
+  | "disable"
+  | "blocked"
+  | "private"
+  | "public_no_list"
+  | "public";
 
 interface IWidget {
   width?: number | "md" | "sm" | "xl" | "xs" | "lg";

+ 9 - 7
dashboard/src/components/template/SentEdit/SentCell.tsx

@@ -130,13 +130,15 @@ const SentCellWidget = ({
       return;
     }
 
-    const found = acceptPr.findIndex((value) => {
-      const vId = `${value.book}_${value.para}_${value.wordStart}_${value.wordEnd}_${value.channel.id}`;
-      return vId === sid;
-    });
-    if (found !== -1) {
-      console.debug("sent cell sentence apply", uuid, found, acceptPr[found]);
-      setSentData(acceptPr[found]);
+    const found = acceptPr
+      .filter((value) => typeof value !== "undefined")
+      .find((value) => {
+        const vId = `${value.book}_${value.para}_${value.wordStart}_${value.wordEnd}_${value.channel.id}`;
+        return vId === sid;
+      });
+    if (typeof found !== "undefined") {
+      console.debug("sent cell sentence apply", uuid, found, found);
+      setSentData(found);
       store.dispatch(done(uuid));
     }
   }, [acceptPr, sentData, isPr, uuid, changedSent, sid]);

+ 13 - 0
dashboard/src/components/template/SentEdit/SentContent.tsx

@@ -9,6 +9,8 @@ import { mode as _mode } from "../../../reducers/article-mode";
 import { IWbw } from "../Wbw/WbwWord";
 import { ArticleMode } from "../../article/Article";
 import SuggestionFocus from "./SuggestionFocus";
+import store from "../../../store";
+import { push } from "../../../reducers/sentence";
 
 interface ILayoutFlex {
   left: number;
@@ -56,6 +58,16 @@ const SentContentWidget = ({
   const settings = useAppSelector(settingInfo);
   const [divShellWidth, setDivShellWidth] = useState<number>();
 
+  useEffect(() => {
+    store.dispatch(
+      push({
+        id: `${book}-${para}-${wordStart}-${wordEnd}`,
+        origin: origin?.map((item) => item.html),
+        translation: translation?.map((item) => item.html),
+      })
+    );
+  }, [book, origin, para, translation, wordEnd, wordStart]);
+
   useEffect(() => {
     const width = divShell.current?.offsetWidth;
     if (width && width < 550) {
@@ -141,6 +153,7 @@ const SentContentWidget = ({
                 para={para}
                 wordStart={wordStart}
                 wordEnd={wordEnd}
+                studio={item.studio}
                 channelId={item.channel.id}
                 channelType={item.channel.type}
                 channelLang={item.channel.lang}

+ 2 - 1
dashboard/src/components/template/SentEdit/SentWbw.tsx

@@ -43,6 +43,7 @@ const SentWbwWidget = ({
     if (myCourse && course) {
       url += `&course=${course.courseId}`;
       if (myCourse.role === "student") {
+        //学生,仅列出答案channel
         url += `&channels=${course.channelId}`;
       } else if (courseMember) {
         console.debug("course member", courseMember);
@@ -57,7 +58,7 @@ const SentWbwWidget = ({
       }
     }
 
-    console.log("wbw sentence url", url);
+    console.log("wbw sentence api request", url);
     get<ISentenceWbwListResponse>(url)
       .then((json) => {
         if (json.ok) {

+ 4 - 33
dashboard/src/components/template/SentEdit/SuggestionToolbar.tsx

@@ -2,17 +2,17 @@ import { Divider, Popconfirm, Space, Tooltip, Typography } from "antd";
 import { LikeOutlined, DeleteOutlined } from "@ant-design/icons";
 import { ISentence } from "../SentEdit";
 import { useEffect, useState } from "react";
-import CommentBox from "../../discussion/DiscussionDrawer";
 import PrAcceptButton from "./PrAcceptButton";
-import { CommentOutlinedIcon, HandOutlinedIcon } from "../../../assets/icon";
+import { HandOutlinedIcon } from "../../../assets/icon";
 import store from "../../../store";
 import { count, show } from "../../../reducers/discussion";
 import { useAppSelector } from "../../../hooks";
 import { openPanel } from "../../../reducers/right-panel";
 import { useIntl } from "react-intl";
 import SuggestionPopover from "./SuggestionPopover";
+import DiscussionButton from "../../discussion/DiscussionButton";
 
-const { Text, Paragraph } = Typography;
+const { Paragraph } = Typography;
 
 interface IWidget {
   data: ISentence;
@@ -120,36 +120,7 @@ const SuggestionToolbarWidget = ({
             {prNumber}
           </Space>
           {compact ? undefined : <Divider type="vertical" />}
-          <Tooltip title="讨论">
-            <Space
-              size={"small"}
-              style={{
-                cursor: "pointer",
-                color: CommentCount && CommentCount > 0 ? "#1890ff" : "unset",
-              }}
-              onClick={(event) => {
-                store.dispatch(
-                  show({
-                    type: "discussion",
-                    resId: data.id,
-                    resType: "sentence",
-                  })
-                );
-                store.dispatch(openPanel("discussion"));
-              }}
-            >
-              <CommentOutlinedIcon />
-              {CommentCount}
-            </Space>
-          </Tooltip>
-          <CommentBox
-            resId={data.id}
-            resType="sentence"
-            trigger={<></>}
-            onCommentCountChange={(count: number) => {
-              setCommentCount(count);
-            }}
-          />
+          <DiscussionButton initCount={CommentCount} resId={data.id} />
         </Space>
       )}
     </Paragraph>

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

@@ -43,6 +43,7 @@ const SentReadFrame = ({
   const settings = useAppSelector(settingInfo);
   const boxOrg = useRef<HTMLDivElement>(null);
   const boxSent = useRef<HTMLDivElement>(null);
+
   useEffect(() => {
     store.dispatch(
       push({
@@ -51,7 +52,7 @@ const SentReadFrame = ({
         translation: translation?.map((item) => item.html),
       })
     );
-  }, []);
+  }, [book, origin, para, translation, wordEnd, wordStart]);
 
   useEffect(() => {
     const displayOriginal = GetUserSetting(

+ 74 - 57
dashboard/src/components/template/Term.tsx

@@ -1,14 +1,19 @@
 import { useEffect, useState } from "react";
 import { Link } from "react-router-dom";
-import { Button, Popover, Skeleton, Space } from "antd";
+import { Button, Popover, Skeleton, Space, Tag } from "antd";
 import { Typography } from "antd";
 import { SearchOutlined, EditOutlined } from "@ant-design/icons";
 
 import store from "../../store";
 import TermModal from "../term/TermModal";
 import { ITerm } from "../term/TermEdit";
-import { ITermDataResponse } from "../api/Term";
-import { changedTerm, refresh } from "../../reducers/term-change";
+import { ITermDataResponse, ITermResponse } from "../api/Term";
+import {
+  changedTerm,
+  refresh,
+  termCache,
+  upgrade,
+} from "../../reducers/term-change";
 import { useAppSelector } from "../../hooks";
 import { get } from "../../request";
 import { fullUrl } from "../../utils";
@@ -18,6 +23,19 @@ import { click } from "../../reducers/term-click";
 
 const { Text, Title } = Typography;
 
+const dataMap = (input?: ITermDataResponse): ITerm => {
+  return {
+    id: input?.guid,
+    word: input?.word,
+    meaning: input?.meaning,
+    meaning2: input?.other_meaning?.split(","),
+    summary: input?.summary ?? "",
+    channelId: input?.channal,
+    studioId: input?.studio.id,
+    summary_is_community: input?.summary_is_community,
+  };
+};
+
 interface ITermExtra {
   pali?: string;
   meaning2?: string[];
@@ -32,12 +50,6 @@ const TermExtra = ({ pali, meaning2 }: ITermExtra) => (
   </>
 );
 
-interface ITermSummary {
-  ok: boolean;
-  message: string;
-  data: string;
-}
-
 export interface IWidgetTermCtl {
   id?: string;
   word?: string;
@@ -71,11 +83,14 @@ export const TermCtl = ({
     summary: summary,
     channelId: channel,
   });
-  const [content, setContent] = useState<string>();
+  const [isInit, setIsInit] = useState(true);
+  const [loading, setLoading] = useState(false);
   const [community, setCommunity] = useState(isCommunity);
   const newTerm: ITermDataResponse | undefined = useAppSelector(changedTerm);
+  const cache = useAppSelector(termCache);
+
   const [isFirst, setIsFirst] = useState(true);
-  const [uid, setUid] = useState<string>(
+  const [uid] = useState<string>(
     lodash.times(20, () => lodash.random(35).toString(36)).join("")
   );
   const termOrder = useAppSelector(order);
@@ -89,7 +104,7 @@ export const TermCtl = ({
       };
       store.dispatch(push(currTerm));
     }
-  }, []);
+  }, [parentChannelId, uid, word]);
 
   useEffect(() => {
     const index = termOrder?.findIndex(
@@ -108,37 +123,56 @@ export const TermCtl = ({
 
   useEffect(() => {
     if (newTerm?.word === word && parentStudioId === newTerm?.studio.id) {
-      console.log("studio 匹配");
-      const newData = {
-        id: newTerm?.guid,
-        word: newTerm?.word,
-        meaning: newTerm?.meaning,
-        meaning2: newTerm?.other_meaning?.split(","),
-        summary: newTerm?.note ? newTerm?.note : "",
-        channelId: newTerm?.channal,
-        studioId: newTerm?.studio.id,
-      };
+      console.debug("Term studio 匹配", newTerm);
+      const newData = dataMap(newTerm);
       if (
         termData.channelId &&
         termData.channelId !== "" &&
         newTerm?.channal === termData.channelId
       ) {
-        console.log("channel 匹配");
+        console.debug("Term channel 匹配");
         setTermData(newData);
+        setIsInit(false);
         setCommunity(false);
       } else {
-        console.log("channel 不 匹配");
+        console.debug("Term channel 不 匹配");
         setTermData(newData);
+        setIsInit(false);
         setCommunity(false);
       }
     }
-  }, [newTerm, parentStudioId, word]);
+  }, [newTerm, parentStudioId, termData.channelId, word]);
 
   const onModalClose = () => {
     if (document.getElementsByTagName("body")[0].hasAttribute("style")) {
       document.getElementsByTagName("body")[0].removeAttribute("style");
     }
   };
+  const onPopoverOpen = (visible: boolean) => {
+    setOpenPopover(visible);
+    if (visible && isInit && typeof id !== "undefined") {
+      const term = cache?.find((value) => value.guid === id);
+      if (term) {
+        setTermData(dataMap(term));
+        setIsInit(false);
+        return;
+      } else {
+        const url = `/v2/terms/${id}?community_summary=1`;
+        console.info("api request", url);
+        setLoading(true);
+        get<ITermResponse>(url)
+          .then((json) => {
+            if (json.ok) {
+              setTermData(dataMap(json.data));
+              setIsInit(false);
+              store.dispatch(upgrade(json.data));
+            }
+          })
+          .finally(() => setLoading(false));
+      }
+    }
+  };
+
   if (typeof termData?.id === "string") {
     return (
       <>
@@ -146,7 +180,10 @@ export const TermCtl = ({
         <Popover
           title={
             <Space style={{ justifyContent: "space-between", width: "100%" }}>
-              <Text strong>{termData.meaning}</Text>
+              <span>
+                <Text strong>{termData.meaning}</Text>{" "}
+                {community ? <Tag>{"社区"}</Tag> : undefined}
+              </span>
               <Space>
                 <Button
                   onClick={() => {
@@ -162,6 +199,7 @@ export const TermCtl = ({
                 <TermModal
                   onUpdate={(value: ITermDataResponse) => {
                     onModalClose();
+                    sessionStorage.removeItem(`term/summary/${value.guid}`);
                     store.dispatch(refresh(value));
                   }}
                   onClose={() => {
@@ -189,29 +227,7 @@ export const TermCtl = ({
             </Space>
           }
           open={openPopover}
-          onOpenChange={(visible) => {
-            setOpenPopover(visible);
-            if (
-              visible &&
-              typeof content === "undefined" &&
-              typeof id !== "undefined"
-            ) {
-              const value = sessionStorage.getItem(`term/summary/${id}`);
-              if (value !== null) {
-                setContent(value);
-                return;
-              } else {
-                const url = `/v2/term-summary/${id}`;
-                console.log("url", url);
-                get<ITermSummary>(url).then((json) => {
-                  if (json.ok) {
-                    setContent(json.data !== "" ? json.data : " ");
-                    sessionStorage.setItem(`term/summary/${id}`, json.data);
-                  }
-                });
-              }
-            }
-          }}
+          onOpenChange={onPopoverOpen}
           content={
             <div style={{ maxWidth: 500, minWidth: 300 }}>
               <Title level={5}>
@@ -219,14 +235,19 @@ export const TermCtl = ({
                   {word}
                 </Link>
               </Title>
-              {content ? (
-                content
-              ) : (
+              {loading ? (
                 <Skeleton
                   title={{ width: 200 }}
                   paragraph={{ rows: 4 }}
                   active
                 />
+              ) : (
+                <>
+                  <div>{termData.summary}</div>
+                  <div style={{ textAlign: "right" }}>
+                    {termData.summary_is_community ? "社区解释" : ""}
+                  </div>
+                </>
               )}
             </div>
           }
@@ -239,11 +260,7 @@ export const TermCtl = ({
               store.dispatch(click(termData));
             }}
           >
-            {termData?.meaning
-              ? termData?.meaning
-              : termData?.word
-              ? termData?.word
-              : "unknown"}
+            {termData?.meaning ?? termData?.word ?? "unknown"}
           </Typography.Link>
         </Popover>
         {isFirst && !compact ? (

+ 12 - 21
dashboard/src/components/template/Wbw/WbwDetail.tsx

@@ -8,23 +8,19 @@ import WbwDetailBasic from "./WbwDetailBasic";
 import WbwDetailBookMark from "./WbwDetailBookMark";
 import WbwDetailNote from "./WbwDetailNote";
 import WbwDetailAdvance from "./WbwDetailAdvance";
-import {
-  CommentOutlinedIcon,
-  LockIcon,
-  UnLockIcon,
-} from "../../../assets/icon";
+import { LockIcon, UnLockIcon } from "../../../assets/icon";
 import { IAttachmentRequest } from "../../api/Attachments";
 import WbwDetailAttachment from "./WbwDetailAttachment";
-import CommentBox from "../../discussion/DiscussionDrawer";
 import { useAppSelector } from "../../../hooks";
 import { currentUser } from "../../../reducers/current-user";
+import DiscussionButton from "../../discussion/DiscussionButton";
+import { courseUser } from "../../../reducers/course-user";
 
 interface IWidget {
   data: IWbw;
   visible?: boolean;
   onClose?: Function;
   onSave?: Function;
-  onCommentCountChange?: Function;
   onAttachmentSelectOpen?: Function;
 }
 const WbwDetailWidget = ({
@@ -32,7 +28,6 @@ const WbwDetailWidget = ({
   visible = true,
   onClose,
   onSave,
-  onCommentCountChange,
   onAttachmentSelectOpen,
 }: IWidget) => {
   const intl = useIntl();
@@ -108,7 +103,9 @@ const WbwDetailWidget = ({
     console.debug("origin", origin);
     setCurrWbwData(origin);
   }
-
+  const userInCourse = useAppSelector(courseUser);
+  if (userInCourse && userInCourse.role === "student") {
+  }
   return (
     <div
       style={{
@@ -119,18 +116,12 @@ const WbwDetailWidget = ({
         size="small"
         type="card"
         tabBarExtraContent={
-          data.uid ? (
-            <CommentBox
-              resId={data.uid}
-              resType="wbw"
-              trigger={<Button icon={<CommentOutlinedIcon />} type="text" />}
-              onCommentCountChange={(count: number) => {
-                if (typeof onCommentCountChange !== "undefined") {
-                  onCommentCountChange(count);
-                }
-              }}
-            />
-          ) : undefined
+          <DiscussionButton
+            initCount={data.hasComment ? 1 : 0}
+            hideCount
+            resId={data.uid}
+            resType="wbw"
+          />
         }
         onChange={(activeKey: string) => {
           setTabKey(activeKey);

+ 13 - 42
dashboard/src/components/template/Wbw/WbwPali.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useRef, useState } from "react";
-import { Popover, Space, Tooltip, Typography } from "antd";
+import { Popover, Space, Typography } from "antd";
 import {
   TagTwoTone,
   InfoCircleOutlined,
@@ -13,7 +13,6 @@ import WbwDetail from "./WbwDetail";
 import { IWbw, IWbwAttachment, TWbwDisplayMode } from "./WbwWord";
 import { bookMarkColor } from "./WbwDetailBookMark";
 import WbwVideoButton from "./WbwVideoButton";
-import CommentBox from "../../discussion/DiscussionDrawer";
 import PaliText from "./PaliText";
 import store from "../../../store";
 import { grammarId, lookup } from "../../../reducers/command";
@@ -21,8 +20,9 @@ import { useAppSelector } from "../../../hooks";
 import { add, relationAddParam } from "../../../reducers/relation-add";
 import { ArticleMode } from "../../article/Article";
 import { anchor, showWbw } from "../../../reducers/wbw";
-import { CommentOutlinedIcon } from "../../../assets/icon";
 import { ParaLinkCtl } from "../ParaLink";
+import { IStudio } from "../../auth/Studio";
+import WbwPaliDiscussionIcon from "./WbwPaliDiscussionIcon";
 
 //生成视频播放按钮
 interface IVideoIcon {
@@ -50,15 +50,22 @@ const VideoIcon = ({ attachments }: IVideoIcon) => {
 const { Paragraph } = Typography;
 interface IWidget {
   data: IWbw;
+  studio?: IStudio;
   channelId: string;
   display?: TWbwDisplayMode;
   mode?: ArticleMode;
   onSave?: Function;
 }
-const WbwPaliWidget = ({ data, channelId, mode, display, onSave }: IWidget) => {
+const WbwPaliWidget = ({
+  data,
+  channelId,
+  mode,
+  display,
+  studio,
+  onSave,
+}: IWidget) => {
   const [popOpen, setPopOpen] = useState(false);
   const [paliColor, setPaliColor] = useState("unset");
-  const [hasComment, setHasComment] = useState(data.hasComment);
   const divShell = useRef<HTMLDivElement>(null);
   const wbwAnchor = useAppSelector(anchor);
   const addParam = useAppSelector(relationAddParam);
@@ -181,13 +188,6 @@ const WbwPaliWidget = ({ data, channelId, mode, display, onSave }: IWidget) => {
           setPaliColor("unset");
         }
       }}
-      onCommentCountChange={(count: number) => {
-        if (count > 0) {
-          setHasComment(true);
-        } else {
-          setHasComment(false);
-        }
-      }}
       onAttachmentSelectOpen={(open: boolean) => {
         setPopOpen(!open);
       }}
@@ -295,35 +295,6 @@ const WbwPaliWidget = ({ data, channelId, mode, display, onSave }: IWidget) => {
     </span>
   );
 
-  let commentShellStyle: React.CSSProperties = {
-    display: "inline-block",
-  };
-
-  const DiscussionIcon = () => {
-    return hasComment ? (
-      <div style={commentShellStyle}>
-        <CommentBox
-          resId={data.uid}
-          resType="wbw"
-          trigger={
-            <Tooltip title="讨论">
-              <CommentOutlinedIcon style={{ cursor: "pointer" }} />
-            </Tooltip>
-          }
-          onCommentCountChange={(count: number) => {
-            if (count > 0) {
-              setHasComment(true);
-            } else {
-              setHasComment(false);
-            }
-          }}
-        />
-      </div>
-    ) : (
-      <></>
-    );
-  };
-
   if (typeof data.real !== "undefined" && data.real.value !== "") {
     //非标点符号
     //单词在右侧时,为了不遮挡字典,Popover向左移动
@@ -382,7 +353,7 @@ const WbwPaliWidget = ({ data, channelId, mode, display, onSave }: IWidget) => {
           <NoteIcon />
           <BookMarkIcon />
           <RelationIcon />
-          <DiscussionIcon />
+          <WbwPaliDiscussionIcon data={data} studio={studio} />
         </Space>
       </div>
     );

+ 41 - 0
dashboard/src/components/template/Wbw/WbwPaliDiscussionIcon.tsx

@@ -0,0 +1,41 @@
+import { useAppSelector } from "../../../hooks";
+import { courseUser } from "../../../reducers/course-user";
+import { currentUser } from "../../../reducers/current-user";
+import { IStudio } from "../../auth/Studio";
+import DiscussionButton from "../../discussion/DiscussionButton";
+import { IWbw } from "./WbwWord";
+
+interface IWidget {
+  data: IWbw;
+  studio?: IStudio;
+}
+const WbwPaliDiscussionIcon = ({ data, studio }: IWidget) => {
+  const userInCourse = useAppSelector(courseUser);
+  const currUser = useAppSelector(currentUser);
+
+  let onlyMe = false;
+  if (userInCourse) {
+    if (userInCourse.role === "student") {
+      if (studio?.id === currUser?.id) {
+        //我自己的wbw channel 显示全部
+        onlyMe = false;
+      } else {
+        //其他channel 只显示自己的
+        onlyMe = true;
+      }
+    }
+  }
+  console.debug("WbwPaliDiscussionIcon render", studio, data, onlyMe);
+  return (
+    <DiscussionButton
+      initCount={data.hasComment ? 1 : 0}
+      hideCount
+      hideInZero
+      onlyMe={onlyMe}
+      resId={data.uid}
+      resType="wbw"
+    />
+  );
+};
+
+export default WbwPaliDiscussionIcon;

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

@@ -19,6 +19,7 @@ import WbwRelationAdd from "./WbwRelationAdd";
 import { ArticleMode } from "../../article/Article";
 import WbwReal from "./WbwReal";
 import WbwDetailFm from "./WbwDetailFm";
+import { IStudio } from "../../auth/Studio";
 
 export type TFieldName =
   | "word"
@@ -108,6 +109,7 @@ interface IWidget {
   fields?: IWbwFields;
   mode?: ArticleMode;
   wordDark?: boolean;
+  studio?: IStudio;
   onChange?: Function;
   onSplit?: Function;
 }
@@ -125,6 +127,7 @@ const WbwWordWidget = ({
     case: true,
   },
   wordDark = false,
+  studio,
   onChange,
   onSplit,
 }: IWidget) => {
@@ -249,6 +252,7 @@ const WbwWordWidget = ({
           channelId={channelId}
           mode={mode}
           display={display}
+          studio={studio}
           onSave={(e: IWbw, isPublish: boolean, isPublic: boolean) => {
             const newData: IWbw = JSON.parse(JSON.stringify(e));
             setWordData(newData);

+ 6 - 2
dashboard/src/components/template/WbwSent.tsx

@@ -24,6 +24,7 @@ import { getGrammar } from "../../reducers/term-vocabulary";
 import modal from "antd/lib/modal";
 import { UserWbwPost } from "../dict/MyCreate";
 import { currentUser } from "../../reducers/current-user";
+import { IStudio } from "../auth/Studio";
 
 export const paraMark = (wbwData: IWbw[]): IWbw[] => {
   //处理段落标记,支持点击段落引用弹窗
@@ -148,6 +149,7 @@ interface IWidget {
   refreshable?: boolean;
   mode?: ArticleMode;
   wbwProgress?: boolean;
+  studio?: IStudio;
   onMagicDictDone?: Function;
   onChange?: Function;
 }
@@ -166,6 +168,7 @@ export const WbwSentCtl = ({
   mode,
   refreshable = false,
   wbwProgress = false,
+  studio,
   onChange,
   onMagicDictDone,
 }: IWidget) => {
@@ -528,7 +531,7 @@ export const WbwSentCtl = ({
         }
       });
   };
-  const wbwRender = (item: IWbw, id: number) => {
+  const wbwRender = (item: IWbw, id: number, studio?: IStudio) => {
     return (
       <WbwWord
         data={item}
@@ -537,6 +540,7 @@ export const WbwSentCtl = ({
         mode={displayMode}
         display={wbwMode}
         fields={fieldDisplay}
+        studio={studio}
         onChange={(e: IWbw, isPublish: boolean, isPublic: boolean) => {
           let newData = [...wordData];
           newData.forEach((value, index, array) => {
@@ -772,7 +776,7 @@ export const WbwSentCtl = ({
               return newItem;
             })
             .map((item, id) => {
-              return wbwRender(item, id);
+              return wbwRender(item, id, studio);
             })
         ) : (
           <Tree

+ 76 - 35
dashboard/src/components/term/TermEdit.tsx

@@ -49,6 +49,7 @@ export interface ITerm {
   meaning2?: string[];
   note?: string;
   summary?: string;
+  summary_is_community?: boolean;
   channelId?: string;
   studioId?: string;
   lang?: string;
@@ -86,13 +87,13 @@ const TermEditWidget = ({
   const [isSaveAs, setIsSaveAs] = useState(false);
   const [currChannel, setCurrChannel] = useState<ValueType[]>([]);
   const user = useAppSelector(_currentUser);
-  //console.log("word", id, word, channelId, studioName);
 
   const [form] = Form.useForm<ITerm>();
   const formRef = useRef<ProFormInstance>();
   useEffect(() => {
     if (word) {
       const url = `/v2/terms?view=word&word=${word}`;
+      console.info("api request", url);
       get<ITermListResponse>(url).then((json) => {
         const meaning = json.data.rows.map((item) => item.meaning);
         let meaningMap = new Map<string, number>();
@@ -124,6 +125,17 @@ const TermEditWidget = ({
     }
   }, [word]);
 
+  let channelDisable = false;
+  if (community) {
+    channelDisable = true;
+  }
+  if (readonly) {
+    channelDisable = true;
+  }
+  if (id) {
+    channelDisable = true;
+  }
+
   return (
     <>
       {community ? (
@@ -161,11 +173,10 @@ const TermEditWidget = ({
           ) {
             return;
           }
-          const copy_channel = values.copy_channel
-            ? values.copy_channel[values.copy_channel.length - 1]
-              ? values.copy_channel[values.copy_channel.length - 1]
-              : ""
-            : "";
+          let copy_channel = "";
+          if (values.copy_channel && values.copy_channel.length > 0) {
+            copy_channel = values.copy_channel[values.copy_channel.length - 1];
+          }
           const newValue = {
             id: values.id,
             word: values.word,
@@ -183,16 +194,15 @@ const TermEditWidget = ({
           console.log("value", newValue);
           let res: ITermResponse;
           if (typeof values.id === "undefined" || community || values.save_as) {
-            res = await post<ITermDataRequest, ITermResponse>(
-              `/v2/terms`,
-              newValue
-            );
+            const url = `/v2/terms?community_summary=1`;
+            console.info("api request", url, newValue);
+            res = await post<ITermDataRequest, ITermResponse>(url, newValue);
           } else {
-            res = await put<ITermDataRequest, ITermResponse>(
-              `/v2/terms/${values.id}`,
-              newValue
-            );
+            const url = `/v2/terms/${values.id}?community_summary=1`;
+            console.info("api request", url, newValue);
+            res = await put<ITermDataRequest, ITermResponse>(url, newValue);
           }
+          console.debug("api response", res);
 
           if (res.ok) {
             message.success("提交成功");
@@ -222,8 +232,9 @@ const TermEditWidget = ({
           if (typeof id !== "undefined") {
             // 如果是编辑,就从服务器拉取数据。
             url = "/v2/terms/" + id;
-            console.log("有id", url);
+            console.info("TermEdit is edit api request", url);
             const res = await get<ITermResponse>(url);
+            console.debug("TermEdit is edit api response", res);
             if (res.ok) {
               let meaning2: string[] = [];
               if (res.data.other_meaning) {
@@ -252,6 +263,14 @@ const TermEditWidget = ({
                   ]);
                 }
               }
+              let copyToChannel: string[] = [];
+              if (parentChannelId) {
+                if (user?.roles?.includes("basic")) {
+                  copyToChannel = [parentChannelId];
+                } else {
+                  copyToChannel = [""];
+                }
+              }
 
               data = {
                 id: res.data.guid,
@@ -262,20 +281,31 @@ const TermEditWidget = ({
                 note: res.data.note ? res.data.note : "",
                 lang: res.data.language,
                 channelId: realChannelId,
-                copy_channel: res.data.channel
-                  ? [res.data.studio.id, res.data.channel?.id]
-                  : undefined,
+                copy_channel: copyToChannel,
               };
               if (res.data.role === "reader" || res.data.role === "unknown") {
                 setReadonly(true);
               }
             }
           } else if (typeof parentChannelId !== "undefined") {
-            //在channel新建
+            /**
+             * 在channel新建
+             * basic:仅保存在这个版本
+             * pro: 默认studio通用
+             */
             url = `/v2/terms?view=create-by-channel&channel=${parentChannelId}&word=${word}`;
-            console.log("在channel新建", url);
+            console.info("api request 在channel新建", url);
             const res = await get<ITermCreateResponse>(url);
-            console.log(res);
+            console.debug("api response", res);
+            let channelId = "";
+            let copyToChannel: string[] = [];
+            if (user?.roles?.includes("basic")) {
+              channelId = parentChannelId;
+              copyToChannel = [parentChannelId];
+            } else {
+              channelId = user?.id === parentStudioId ? "" : parentChannelId;
+              copyToChannel = [res.data.studio.id, parentChannelId];
+            }
             data = {
               word: word ? word : "",
               tag: tags?.join(),
@@ -283,13 +313,14 @@ const TermEditWidget = ({
               meaning2: [],
               note: "",
               lang: res.data.language,
-              channelId: user?.id === parentStudioId ? "" : parentChannelId,
-              copy_channel: [res.data.studio.id, parentChannelId],
+              channelId: channelId,
+              copy_channel: copyToChannel,
             };
           } else if (typeof studioName !== "undefined") {
             //在studio新建
+
             url = `/v2/terms?view=create-by-studio&studio=${studioName}&word=${word}`;
-            console.log("在 studio 新建", url);
+            console.debug("在 studio 新建", url);
           }
 
           return data;
@@ -372,19 +403,21 @@ const TermEditWidget = ({
             allowClear
             label="版本(已经建立的术语,版本不可修改。可以选择另存为复制到另一个版本。)"
             width="md"
-            placeholder="通用于此Studio"
-            disabled={
-              (!community && readonly) ||
-              (!community && typeof id !== "undefined")
-            }
+            placeholder={intl.formatMessage({
+              id: "term.general-in-studio",
+            })}
+            disabled={channelDisable}
             options={[
               {
                 value: "",
-                label: "通用于我的Studio",
-                disabled: user?.id !== parentStudioId,
+                label: intl.formatMessage({
+                  id: "term.general-in-studio",
+                }),
+                disabled:
+                  user?.id !== parentStudioId || user?.roles?.includes("basic"),
               },
               {
-                value: parentChannelId ? parentChannelId : channelId,
+                value: parentChannelId ?? channelId,
                 label: "仅用于此版本",
                 disabled: !community && readonly,
               },
@@ -400,7 +433,10 @@ const TermEditWidget = ({
                   : true
                 : false;
               return (
-                <LangSelect disabled={hasChannel} required={!hasChannel} />
+                <LangSelect
+                  disabled={hasChannel || channelDisable}
+                  required={!hasChannel}
+                />
               );
             }}
           </ProFormDependency>
@@ -435,7 +471,10 @@ const TermEditWidget = ({
             parentStudioId={parentStudioId}
             width="md"
             name="copy_channel"
-            placeholder="通用于此Studio"
+            placeholder={intl.formatMessage({
+              id: "term.general-in-studio",
+            })}
+            allowClear={user?.roles?.includes("basic") ? false : true}
             tooltip={intl.formatMessage({
               id: "term.fields.channel.tooltip",
             })}
@@ -461,7 +500,9 @@ const TermEditWidget = ({
           </ProFormDependency>
         </ProForm.Group>
         <ProForm.Group style={{ display: isSaveAs ? "block" : "none" }}>
-          <ProFormCheckbox name="pr">同时提交修改建议</ProFormCheckbox>
+          <ProFormCheckbox disabled name="pr">
+            同时提交修改建议
+          </ProFormCheckbox>
         </ProForm.Group>
       </ProForm>
     </>

+ 7 - 1
dashboard/src/components/term/TermItem.tsx

@@ -18,6 +18,7 @@ import { click, clickedTerm } from "../../reducers/term-click";
 import store from "../../store";
 import "../article/article.css";
 import Discussion from "../discussion/Discussion";
+import { useIntl } from "react-intl";
 
 const { Text } = Typography;
 
@@ -30,6 +31,7 @@ const TermItemWidget = ({ data, onTermClick }: IWidget) => {
   const [showDiscussion, setShowDiscussion] = useState(false);
   const navigate = useNavigate();
   const termClicked = useAppSelector(clickedTerm);
+  const intl = useIntl();
 
   useEffect(() => {
     console.debug("on redux", termClicked, data);
@@ -58,7 +60,11 @@ const TermItemWidget = ({ data, onTermClick }: IWidget) => {
             </Space>
             <Space style={{ fontSize: "80%" }}>
               <StudioName data={data?.studio} />
-              {data?.channel ? data.channel.name : "通用于此studio"}
+              {data?.channel
+                ? data.channel.name
+                : intl.formatMessage({
+                    id: "term.general-in-studio",
+                  })}
               <Text type="secondary">
                 <UserName {...data?.editor} />
               </Text>

+ 10 - 6
dashboard/src/components/users/SignUp.tsx

@@ -58,7 +58,7 @@ const SingUpWidget = () => {
         name: string;
       }>
         name="welcome"
-        title={intl.formatMessage({ id: "labels.sign-in" })}
+        title={intl.formatMessage({ id: "labels.sign-up" })}
         stepProps={{
           description: "注册wikipali基础版",
         }}
@@ -145,9 +145,9 @@ const SingUpWidget = () => {
         checkbox: string;
       }>
         name="checkbox"
-        title="邮箱验证"
+        title={intl.formatMessage({ id: "auth.sign-up.email-certification" })}
         stepProps={{
-          description: "填入您的注册邮箱",
+          description: " ",
         }}
         onFinish={async () => {
           const values = formRef.current?.getFieldsValue();
@@ -155,6 +155,7 @@ const SingUpWidget = () => {
           const data: IInviteRequest = {
             email: values.email,
             lang: getUiLang(),
+            subject: intl.formatMessage({ id: "labels.email.sign-up.subject" }),
             studio: "",
             dashboard: dashboardBasePath(),
           };
@@ -191,11 +192,14 @@ const SingUpWidget = () => {
         </ProForm.Group>
       </StepsForm.StepForm>
 
-      <StepsForm.StepForm name="finish" title="完成注册">
+      <StepsForm.StepForm
+        name="finish"
+        title={intl.formatMessage({ id: "labels.done" })}
+      >
         <Result
           status="success"
-          title="验证码已经成功发送"
-          subTitle="验证邮件已经发送到您的邮箱。请查收邮件,根据提示完成注册。"
+          title="注册邮件已经成功发送"
+          subTitle="请查收邮件,根据提示完成注册。"
         />
       </StepsForm.StepForm>
     </StepsForm>

+ 9 - 3
dashboard/src/layouts/anonymous/index.tsx

@@ -2,6 +2,7 @@ import { Outlet } from "react-router-dom";
 import { Col, Row } from "antd";
 import UiLangSelect from "../../components/general/UiLangSelect";
 import img_banner from "../../assets/library/images/wikipali_logo_library.svg";
+import FooterBar from "../../components/library/FooterBar";
 
 const Widget = () => {
   return (
@@ -9,19 +10,24 @@ const Widget = () => {
       <div style={{ textAlign: "right", backgroundColor: "#3e3e3e" }}>
         <UiLangSelect />
       </div>
-      <div style={{ paddingTop: "3em", backgroundColor: "#3e3e3e" }}>
+      <div
+        style={{
+          paddingTop: "3em",
+          backgroundColor: "#3e3e3e",
+        }}
+      >
         <Row>
           <Col flex="auto"></Col>
           <Col flex="400px" style={{ padding: "1em" }}>
             <img alt="logo" style={{ height: "5em" }} src={img_banner} />
           </Col>
-          <Col flex="400px" style={{ padding: "1em" }}>
+          <Col flex="400px" style={{ padding: "1em", paddingBottom: 200 }}>
             <Outlet />
           </Col>
           <Col flex="auto"></Col>
         </Row>
       </div>
-      <div>anonymous layout footer</div>
+      <FooterBar />
     </>
   );
 };

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

@@ -12,6 +12,7 @@ const items = {
   "auth.role.reader": "reader",
   "auth.type.user": "user",
   "auth.type.group": "group",
+  "auth.sign-up.email-certification": "E-Mail certification",
 };
 
 export default items;

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

@@ -3,7 +3,7 @@ const items = {
   "course.exp.start.label": "起始经验",
   "course.exp.end.label": "结束经验",
   "course.exp.current.label": "当前经验",
-  "course.member.status.none.label": "",
+  "course.member.status.none.label": " ",
   "course.member.status.normal.label": "报名成功",
   "course.member.status.joined.label": "已经参加了",
   "course.member.status.join.button": "参加",

+ 3 - 0
dashboard/src/locales/en-US/label.ts

@@ -43,6 +43,9 @@ const items = {
   "labels.table-of-content": "table of content",
   "labels.this-studio": "this studio",
   "labels.feedback": "feedback",
+  "labels.email.sign-up.subject": "welcome join wikipali",
+  "labels.sign-up": "Sign Up",
+  "labels.done": "Done",
 };
 
 export default items;

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

@@ -11,6 +11,7 @@ const items = {
   "term.fields.meaning2.label": "other meanings",
   "term.fields.meaning2.tooltip": "其他意思将出现在后面的括号里",
   "term.fields.note.label": "note",
+  "term.general-in-studio": "general in studio",
 };
 
 export default items;

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

@@ -12,6 +12,7 @@ const items = {
   "auth.role.reader": "阅读者",
   "auth.type.user": "用户",
   "auth.type.group": "群组",
+  "auth.sign-up.email-certification": "邮箱验证",
 };
 
 export default items;

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

@@ -11,7 +11,7 @@ const items = {
   "labels.link": "链接",
   "labels.upload": "上传",
   "labels.first-term": "第一个术语",
-  "labels.sign-in": "注册",
+  "labels.sign-in": "登录",
   "labels.first-wbw": "第一个逐词解析",
   "labels.first-translation": "第一个译文",
   "labels.first-course": "第一个课程",
@@ -48,6 +48,9 @@ const items = {
   "labels.table-of-content": "目录",
   "labels.this-studio": "此工作室",
   "labels.feedback": "问题反馈",
+  "labels.email.sign-up.subject": "欢迎注册wikipali",
+  "labels.sign-up": "注册",
+  "labels.done": "完成",
 };
 
 export default items;

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

@@ -11,6 +11,7 @@ const items = {
   "term.fields.meaning2.label": "其他意思",
   "term.fields.meaning2.tooltip": "其他意思将出现在后面的括号里",
   "term.fields.note.label": "注释",
+  "term.general-in-studio": "通用于我的Studio",
 };
 
 export default items;

+ 5 - 1
dashboard/src/pages/library/course/course.tsx

@@ -29,6 +29,8 @@ export interface ICourse {
   channelId?: string;
   startAt?: string; //课程开始时间
   endAt?: string; //课程结束时间
+  signUpStartAt?: string; //报名开始时间
+  signUpEndAt?: string; //报名结束时间
   intro?: string; //简介
   coverUrl?: string[]; //封面图片文件名
   join?: TCourseJoinMode;
@@ -47,7 +49,7 @@ const Widget = () => {
     get<ICourseResponse>(url)
       .then((json) => {
         if (json.ok) {
-          console.log(json.data);
+          console.log("api response", json.data);
           const course: ICourse = {
             id: json.data.id,
             title: json.data.title,
@@ -60,6 +62,8 @@ const Widget = () => {
             channelId: json.data.channel_id,
             startAt: json.data.start_at,
             endAt: json.data.end_at,
+            signUpStartAt: json.data.sign_up_start_at,
+            signUpEndAt: json.data.sign_up_end_at,
             intro: json.data.content,
             coverUrl: json.data.cover_url,
             join: json.data.join,

+ 2 - 0
dashboard/src/pages/library/discussion/list.tsx

@@ -28,7 +28,9 @@ const Widget = () => {
       request={async (params = {}, sorter, filter) => {
         console.log(params, sorter, filter);
         const url = `/v2/discussion?view=all`;
+        console.info("api request", url);
         const json = await get<ICommentListResponse>(url);
+        console.debug("api response", json);
         if (!json.ok) {
           message.error(json.message);
         }

+ 7 - 14
dashboard/src/pages/studio/article/edit.tsx

@@ -26,20 +26,13 @@ const Widget = () => {
           </Space>
         }
         extra={
-          <Space>
-            <ArticleEditTools
-              studioName={studioname}
-              articleId={articleId}
-              title={title}
-            />
-            <Button
-              onClick={() => setShowParent((origin) => !origin)}
-              style={{ display: parent ? "inline-block" : "none" }}
-            >
-              源文件
-              {showParent ? <DoubleRightOutlined /> : <DoubleLeftOutlined />}
-            </Button>
-          </Space>
+          <Button
+            onClick={() => setShowParent((origin) => !origin)}
+            style={{ display: parent ? "inline-block" : "none" }}
+          >
+            源文件
+            {showParent ? <DoubleRightOutlined /> : <DoubleLeftOutlined />}
+          </Button>
         }
       >
         <ArticleEdit

+ 16 - 60
dashboard/src/pages/studio/course/list.tsx

@@ -22,12 +22,11 @@ import {
 import CourseCreate from "../../../components/course/CourseCreate";
 import { API_HOST, delete_, get } from "../../../request";
 import {
+  ICourseDataResponse,
   ICourseListResponse,
   ICourseMemberData,
   ICourseNumberResponse,
-  TCourseJoinMode,
   TCourseMemberAction,
-  TCourseMemberStatus,
   actionMap,
 } from "../../../components/api/Course";
 import { PublicityValueEnum } from "../../../components/studio/table";
@@ -42,29 +41,6 @@ import { ISetStatus, setStatus } from "../../../components/course/UserAction";
 import { useAppSelector } from "../../../hooks";
 import { currentUser } from "../../../reducers/current-user";
 
-interface DataItem {
-  sn: number;
-  id: string; //课程ID
-  title: string; //标题
-  subtitle: string; //副标题
-  teacher?: string; //UserID
-  course_count?: number; //课程数
-  member_count: number; //成员数量
-  type: number; //类型-公开/内部
-  join: TCourseJoinMode; //报名方式
-  created_at: string; //创建时间
-  updated_at?: string; //修改时间
-  article_id?: string; //文集ID
-  start_at?: string; //课程开始时间
-  end_at?: string; //课程结束时间
-  intro_markdown?: string; //简介
-  coverId: string;
-  coverUrl?: string[]; //封面图片文件名
-  myStatus?: TCourseMemberStatus;
-  myStatusId?: string;
-  countProgressing?: number;
-}
-
 const renderBadge = (count: number, active = false) => {
   return (
     <Badge
@@ -144,7 +120,7 @@ const Widget = () => {
 
   return (
     <>
-      <ProTable<DataItem>
+      <ProTable<ICourseDataResponse>
         actionRef={ref}
         columns={[
           {
@@ -171,14 +147,14 @@ const Widget = () => {
                 <Space key={index}>
                   <Image
                     src={
-                      row.coverUrl && row.coverUrl.length > 1
-                        ? row.coverUrl[1]
+                      row.cover_url && row.cover_url.length > 1
+                        ? row.cover_url[1]
                         : ""
                     }
                     preview={{
                       src:
-                        row.coverUrl && row.coverUrl.length > 0
-                          ? row.coverUrl[0]
+                        row.cover_url && row.cover_url.length > 0
+                          ? row.cover_url[0]
                           : "",
                     }}
                     width={64}
@@ -279,10 +255,10 @@ const Widget = () => {
                   mainButton = (
                     <span
                       key={index}
-                      style={{ color: getStatusColor(row.myStatus) }}
+                      style={{ color: getStatusColor(row.my_status) }}
                     >
                       {intl.formatMessage({
-                        id: `course.member.status.${row.myStatus}.label`,
+                        id: `course.member.status.${row.my_status}.label`,
                       })}
                     </span>
                   );
@@ -291,10 +267,10 @@ const Widget = () => {
                   mainButton = (
                     <span
                       key={index}
-                      style={{ color: getStatusColor(row.myStatus) }}
+                      style={{ color: getStatusColor(row.my_status) }}
                     >
                       {intl.formatMessage({
-                        id: `course.member.status.${row.myStatus}.label`,
+                        id: `course.member.status.${row.my_status}.label`,
                       })}
                     </span>
                   );
@@ -323,7 +299,9 @@ const Widget = () => {
                       row.start_at,
                       row.end_at,
                       row.join,
-                      row.myStatus
+                      row.my_status,
+                      row.sign_up_start_at,
+                      row.sign_up_end_at
                     ),
                   };
                 });
@@ -356,7 +334,7 @@ const Widget = () => {
                         const newStatus = actionMap(currAction);
                         if (newStatus) {
                           const actionParam: ISetStatus = {
-                            courseMemberId: row.myStatusId,
+                            courseMemberId: row.my_status_id,
                             message: intl.formatMessage(
                               {
                                 id: `course.member.status.${currAction}.message`,
@@ -397,34 +375,12 @@ const Widget = () => {
           }
           url += getSorterUrl(sorter);
           console.info("api request", url);
-
           const res = await get<ICourseListResponse>(url);
-          console.debug("course data", res);
-          const items: DataItem[] = res.data.rows.map((item, id) => {
-            return {
-              sn: id + offset + 1,
-              id: item.id,
-              title: item.title,
-              subtitle: item.subtitle,
-              teacher: item.teacher?.nickName,
-              coverId: item.cover,
-              coverUrl: item.cover_url,
-              type: item.publicity,
-              join: item.join,
-              member_count: item.member_count,
-              myStatus: item.my_status,
-              myStatusId: item.my_status_id,
-              countProgressing: item.count_progressing,
-              created_at: item.created_at,
-              start_at: item.start_at,
-              end_at: item.end_at,
-            };
-          });
-          console.debug("data covert", items);
+          console.debug("api response", res);
           return {
             total: res.data.count,
             succcess: true,
-            data: items,
+            data: res.data.rows,
           };
         }}
         rowKey="id"

+ 0 - 27
dashboard/src/pages/studio/home.tsx

@@ -1,27 +0,0 @@
-import { useParams, Link } from "react-router-dom";
-import { useIntl } from "react-intl";
-import { Layout } from "antd";
-
-import LeftSider from "../../components/studio/LeftSider";
-
-const { Content } = Layout;
-
-const Widget = () => {
-	const intl = useIntl(); //i18n
-	const { studioname } = useParams(); //url 参数
-	return (
-		<Layout>
-			<LeftSider />
-			<Content>
-				<h2>
-					{intl.formatMessage({ id: "columns.studio.title" })}/{studioname}/首页
-				</h2>
-				<div>
-					<Link to=""> </Link>
-				</div>
-			</Content>
-		</Layout>
-	);
-};
-
-export default Widget;

+ 66 - 0
dashboard/src/pages/studio/home/index.tsx

@@ -0,0 +1,66 @@
+import { Link, useParams } from "react-router-dom";
+import { Layout, Space } from "antd";
+
+import LeftSider from "../../../components/studio/LeftSider";
+import { styleStudioContent } from "../style";
+import { ProCard } from "@ant-design/pro-components";
+import { useState } from "react";
+import DiscussionListCard from "../../../components/discussion/DiscussionListCard";
+import ExpTime from "../../../components/exp/ExpTime";
+import SentMyEditList from "../../../components/corpus/SentMyEditList";
+
+const { Content } = Layout;
+
+const Widget = () => {
+  const { studioname } = useParams(); //url 参数
+  const [responsive, setResponsive] = useState(false);
+  return (
+    <Layout>
+      <LeftSider selectedKeys="invite" />
+      <Content style={styleStudioContent}>
+        <ProCard
+          title={studioname}
+          extra={
+            <Space>
+              {"经验"}
+              <ExpTime userName={studioname} />
+              <Link to={`/studio/${studioname}/exp/list`}>{"详情"}</Link>
+            </Space>
+          }
+          split={responsive ? "horizontal" : "vertical"}
+          bordered
+          headerBordered
+        >
+          <ProCard split="horizontal" colSpan="50%">
+            <ProCard title="最近打开"></ProCard>
+            <ProCard title="新手入门"></ProCard>
+          </ProCard>
+          <ProCard
+            split="horizontal"
+            colSpan="50%"
+            tabs={{
+              items: [
+                {
+                  label: `讨论`,
+                  key: "discussion",
+                  children: (
+                    <div style={{ minHeight: 360 }}>
+                      <DiscussionListCard userId="ddd" />
+                    </div>
+                  ),
+                },
+                {
+                  label: `最近翻译`,
+                  key: "sentence",
+                  children: <SentMyEditList />,
+                },
+              ],
+            }}
+          />
+        </ProCard>
+      </Content>
+    </Layout>
+  );
+};
+
+export default Widget;

+ 1 - 1
dashboard/src/pages/users/sign-up.tsx

@@ -15,7 +15,7 @@ const Widget = () => {
         marginRight: "auto",
       }}
     >
-      <Card title={intl.formatMessage({ id: "labels.sign-in" })}>
+      <Card title={intl.formatMessage({ id: "labels.sign-up" })}>
         <SignUp />
       </Card>
     </div>

+ 44 - 0
dashboard/src/reducers/discussion-count.ts

@@ -0,0 +1,44 @@
+/**
+ *
+ */
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
+
+import type { RootState } from "../store";
+import { IDiscussionCountData } from "../components/api/Comment";
+
+export interface IUpgrade {
+  resId: string;
+  data: IDiscussionCountData[];
+}
+
+interface IState {
+  list: IDiscussionCountData[];
+}
+
+const initialState: IState = { list: [] };
+
+export const slice = createSlice({
+  name: "discussion-count",
+  initialState,
+  reducers: {
+    publish: (state, action: PayloadAction<IDiscussionCountData[]>) => {
+      console.debug("discussion-count publish", action.payload);
+      state.list = action.payload;
+    },
+    upgrade: (state, action: PayloadAction<IUpgrade>) => {
+      console.debug("discussion-count publish", action.payload);
+      const old = state.list.filter(
+        (value) => value.res_id !== action.payload.resId
+      );
+      state.list = [...old, ...action.payload.data];
+    },
+  },
+});
+
+export const { publish, upgrade } = slice.actions;
+
+export const discussionList = (
+  state: RootState
+): IDiscussionCountData[] | undefined => state.discussionCount.list;
+
+export default slice.reducer;

+ 19 - 1
dashboard/src/reducers/term-change.ts

@@ -5,6 +5,7 @@ import type { RootState } from "../store";
 
 interface IState {
   word?: ITermDataResponse;
+  termCache?: ITermDataResponse[];
 }
 
 const initialState: IState = {};
@@ -15,13 +16,30 @@ export const slice = createSlice({
   reducers: {
     refresh: (state, action: PayloadAction<ITermDataResponse>) => {
       state.word = action.payload;
+      upgrade(action.payload);
+    },
+    upgrade: (state, action: PayloadAction<ITermDataResponse>) => {
+      if (state.termCache) {
+        if (
+          state.termCache.find(
+            (value) => value.word === action.payload.word
+          ) === undefined
+        ) {
+          state.termCache.push(action.payload);
+        }
+      } else {
+        state.termCache = [action.payload];
+      }
     },
   },
 });
 
-export const { refresh } = slice.actions;
+export const { refresh, upgrade } = slice.actions;
 
 export const changedTerm = (state: RootState): ITermDataResponse | undefined =>
   state.termChange.word;
 
+export const termCache = (state: RootState): ITermDataResponse[] | undefined =>
+  state.termChange.termCache;
+
 export default slice.reducer;

+ 2 - 0
dashboard/src/store.ts

@@ -29,6 +29,7 @@ import focusReducer from "./reducers/focus";
 import prLoadReducer from "./reducers/pr-load";
 import termClickReducer from "./reducers/term-click";
 import cartModeReducer from "./reducers/cart-mode";
+import discussionCountReducer from "./reducers/discussion-count";
 
 const store = configureStore({
   reducer: {
@@ -61,6 +62,7 @@ const store = configureStore({
     prLoad: prLoadReducer,
     termClick: termClickReducer,
     cartMode: cartModeReducer,
+    discussionCount: discussionCountReducer,
   },
 });