Kaynağa Gözat

Merge pull request #1913 from visuddhinanda/agile

使用 article 组件渲染课本
visuddhinanda 2 yıl önce
ebeveyn
işleme
2cfbc0bf23

Dosya farkı çok büyük olduğundan ihmal edildi
+ 11 - 0
dashboard/src/assets/icon/index.tsx


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

@@ -1,6 +1,8 @@
 export interface IAttachmentRequest {
 export interface IAttachmentRequest {
   id: string;
   id: string;
+  name: string;
   filename: string;
   filename: string;
+  title: string;
   size: number;
   size: number;
   content_type: string;
   content_type: string;
   url: string;
   url: string;

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

@@ -25,6 +25,7 @@ export interface ICourseDataRequest {
   join: string;
   join: string;
   request_exp: string;
   request_exp: string;
 }
 }
+export type TCourseRole = "teacher" | "assistant" | "student";
 export type TCourseJoinMode = "invite" | "manual" | "open";
 export type TCourseJoinMode = "invite" | "manual" | "open";
 export type TCourseExpRequest = "none" | "begin-end" | "daily";
 export type TCourseExpRequest = "none" | "begin-end" | "daily";
 export interface ICourseDataResponse {
 export interface ICourseDataResponse {
@@ -130,13 +131,14 @@ export interface ICourseMemberDeleteResponse {
   data: boolean;
   data: boolean;
 }
 }
 
 
+export interface ICourseUser {
+  role: TCourseRole;
+  channel_id?: string | null;
+}
 export interface ICourseCurrUserResponse {
 export interface ICourseCurrUserResponse {
   ok: boolean;
   ok: boolean;
   message: string;
   message: string;
-  data: {
-    role: string;
-    channel_id: string;
-  };
+  data: ICourseUser;
 }
 }
 
 
 export interface IExerciseListData {
 export interface IExerciseListData {

+ 15 - 1
dashboard/src/components/article/Article.tsx

@@ -7,6 +7,7 @@ import "./article.css";
 import TypePage from "./TypePage";
 import TypePage from "./TypePage";
 import TypeCSPara from "./TypeCSPara";
 import TypeCSPara from "./TypeCSPara";
 import { ISearchParams } from "../../pages/library/article/show";
 import { ISearchParams } from "../../pages/library/article/show";
+import TypeCourse from "./TypeCourse";
 
 
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleType =
 export type ArticleType =
@@ -48,7 +49,7 @@ interface IWidget {
   book?: string | null;
   book?: string | null;
   para?: string | null;
   para?: string | null;
   anthologyId?: string | null;
   anthologyId?: string | null;
-  courseId?: string;
+  courseId?: string | null;
   exerciseId?: string;
   exerciseId?: string;
   userName?: string;
   userName?: string;
   active?: boolean;
   active?: boolean;
@@ -187,6 +188,19 @@ const ArticleWidget = ({
             }
             }
           }}
           }}
         />
         />
+      ) : type === "textbook" ? (
+        <TypeCourse
+          type={type}
+          articleId={articleId}
+          channelId={channelId}
+          courseId={courseId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string, target: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target);
+            }
+          }}
+        />
       ) : (
       ) : (
         <></>
         <></>
       )}
       )}

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

@@ -68,9 +68,8 @@ const TypeArticleWidget = ({
     let url = `/v2/article/${articleId}?mode=${srcDataMode}`;
     let url = `/v2/article/${articleId}?mode=${srcDataMode}`;
     url += channelId ? `&channel=${channelId}` : "";
     url += channelId ? `&channel=${channelId}` : "";
     url += anthologyId ? `&anthology=${anthologyId}` : "";
     url += anthologyId ? `&anthology=${anthologyId}` : "";
-    console.log("url", url);
+    console.info("article url", url);
     setLoading(true);
     setLoading(true);
-    console.log("url", url);
     get<IArticleResponse>(url)
     get<IArticleResponse>(url)
       .then((json) => {
       .then((json) => {
         console.log("article", json);
         console.log("article", json);
@@ -148,6 +147,7 @@ const TypeArticleWidget = ({
         console.error(e);
         console.error(e);
       });
       });
   }, [anthologyId, articleId]);
   }, [anthologyId, articleId]);
+
   let anthology: IFirstAnthology | undefined;
   let anthology: IFirstAnthology | undefined;
   if (articleData?.anthology_count && articleData.anthology_first) {
   if (articleData?.anthology_count && articleData.anthology_first) {
     anthology = {
     anthology = {

+ 158 - 40
dashboard/src/components/article/TypeCourse.tsx

@@ -1,20 +1,29 @@
 import { useEffect, useState } from "react";
 import { useEffect, useState } from "react";
-import { Divider, message, Result, Space, Tag } from "antd";
 
 
 import { get } from "../../request";
 import { get } from "../../request";
 import store from "../../store";
 import store from "../../store";
-import { IArticleDataResponse, IArticleResponse } from "../api/Article";
-import ArticleView from "./ArticleView";
-import { ICourseCurrUserResponse } from "../api/Course";
-import { ICourseUser, signIn } from "../../reducers/course-user";
-import { ITextbook, refresh } from "../../reducers/current-course";
-import ExerciseList from "./ExerciseList";
-import ExerciseAnswer from "../course/ExerciseAnswer";
+import { IArticleDataResponse } from "../api/Article";
+import {
+  ICourseCurrUserResponse,
+  ICourseDataResponse,
+  ICourseMemberListResponse,
+  ICourseResponse,
+  ICourseUser,
+} from "../api/Course";
+import { signIn } from "../../reducers/course-user";
+import {
+  ITextbook,
+  memberRefresh,
+  refresh,
+} from "../../reducers/current-course";
+
 import "./article.css";
 import "./article.css";
-import TocTree from "./TocTree";
-import PaliText from "../template/Wbw/PaliText";
-import { ITocPathNode } from "../corpus/TocPath";
+
 import { ArticleMode, ArticleType } from "./Article";
 import { ArticleMode, ArticleType } from "./Article";
+import TypeArticle from "./TypeArticle";
+import { useSearchParams } from "react-router-dom";
+
+import SelectChannel from "../course/SelectChannel";
 
 
 /**
 /**
  * 每种article type 对应的路由参数
  * 每种article type 对应的路由参数
@@ -36,7 +45,7 @@ interface IWidget {
   channelId?: string | null;
   channelId?: string | null;
   book?: string | null;
   book?: string | null;
   para?: string | null;
   para?: string | null;
-  courseId?: string;
+  courseId?: string | null;
   exerciseId?: string;
   exerciseId?: string;
   userName?: string;
   userName?: string;
   active?: boolean;
   active?: boolean;
@@ -63,9 +72,11 @@ const TypeCourseWidget = ({
   onLoading,
   onLoading,
   onError,
   onError,
 }: IWidget) => {
 }: IWidget) => {
-  const [articleData, setArticleData] = useState<IArticleDataResponse>();
-  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
-  const [extra, setExtra] = useState(<></>);
+  const [anthologyId, setAnthologyId] = useState<string>();
+  const [course, setCourse] = useState<ICourseDataResponse>();
+  const [currUser, setCurrUser] = useState<ICourseUser>();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [channelPickerOpen, setChannelPickerOpen] = useState(false);
 
 
   const channels = channelId?.split("_");
   const channels = channelId?.split("_");
 
 
@@ -73,37 +84,140 @@ const TypeCourseWidget = ({
     /**
     /**
      * 由课本进入查询当前用户的权限和channel
      * 由课本进入查询当前用户的权限和channel
      */
      */
-    if (
-      type === "textbook" ||
-      type === "exercise" ||
-      type === "exercise-list"
-    ) {
-      if (typeof articleId !== "undefined") {
-        const id = articleId.split("_");
-        get<ICourseCurrUserResponse>(`/v2/course-curr?course_id=${id[0]}`).then(
-          (response) => {
-            console.log("course user", response);
-            if (response.ok) {
-              const it: ICourseUser = {
+    console.debug(
+      "course 由课本进入查询当前用户的权限和channel",
+      type,
+      courseId
+    );
+    if (type === "textbook") {
+      if (typeof courseId !== "undefined") {
+        const url = `/v2/course-curr?course_id=${courseId}`;
+        console.debug("course url", url);
+        get<ICourseCurrUserResponse>(url).then((response) => {
+          console.log("course user", response);
+          if (response.ok) {
+            setCurrUser(response.data);
+            if (!response.data.channel_id) {
+              setChannelPickerOpen(true);
+            }
+            //我的角色
+            store.dispatch(
+              signIn({
                 channelId: response.data.channel_id,
                 channelId: response.data.channel_id,
                 role: response.data.role,
                 role: response.data.role,
-              };
-              store.dispatch(signIn(it));
-              /**
-               * redux发布课程信息
-               */
-              const ic: ITextbook = {
-                courseId: id[0],
-                articleId: id[1],
-              };
-              store.dispatch(refresh(ic));
+              })
+            );
+
+            //如果是老师查询学生列表
+            if (response.data.role) {
+              if (response.data.role !== "student") {
+                const url = `/v2/course-member?view=course&id=${courseId}`;
+                console.debug("course member url", url);
+                get<ICourseMemberListResponse>(url)
+                  .then((json) => {
+                    console.debug("course member data", json);
+                    if (json.ok) {
+                      store.dispatch(memberRefresh(json.data.rows));
+                    }
+                  })
+                  .catch((e) => console.error(e));
+              }
             }
             }
+          } else {
+            console.error(response.message);
           }
           }
-        );
+        });
       }
       }
     }
     }
-  }, [articleId, type]);
+  }, [courseId, type]);
+
+  useEffect(() => {
+    let output: any = { mode: mode };
+    searchParams.forEach((value, key) => {
+      console.log(value, key);
+      if (key !== "mode" && key !== "channel") {
+        output[key] = value;
+      }
+    });
+    if (currUser?.role === "student") {
+      if (typeof currUser.channel_id === "string") {
+        output["channel"] = currUser.channel_id;
+      }
+    } else {
+      output["channel"] = course?.channel_id;
+    }
+
+    setSearchParams(output);
+  }, [currUser, course, mode]);
 
 
+  useEffect(() => {
+    const url = `/v2/course/${courseId}`;
+    console.debug("course url", url);
+    get<ICourseResponse>(url).then((json) => {
+      console.debug("course data", json.data);
+      if (json.ok) {
+        setAnthologyId(json.data.anthology_id);
+        setCourse(json.data);
+        /**
+         * redux发布课程信息
+         */
+        if (courseId && articleId) {
+          const ic: ITextbook = {
+            courseId: courseId,
+            articleId: articleId,
+            channelId: json.data.channel_id,
+          };
+          store.dispatch(refresh(ic));
+        }
+      }
+    });
+  }, [articleId, courseId]);
+
+  let channelsId = "";
+  if (currUser && course) {
+    if (currUser.role === "student") {
+      if (currUser.channel_id) {
+        channelsId = currUser.channel_id + "_" + course?.channel_id;
+      } else {
+        channelsId = course?.channel_id;
+      }
+    } else {
+      channelsId = course?.channel_id;
+    }
+  }
+
+  return anthologyId && currUser ? (
+    <>
+      {!currUser.channel_id ? (
+        <SelectChannel
+          courseId={courseId}
+          open={channelPickerOpen}
+          onOpenChange={(visible: boolean) => {
+            setChannelPickerOpen(visible);
+          }}
+          onSelected={() => {
+            window.location.reload();
+          }}
+        />
+      ) : (
+        <></>
+      )}
+      <TypeArticle
+        type={"article"}
+        articleId={articleId}
+        channelId={channelsId}
+        mode={mode}
+        anthologyId={anthologyId}
+        active={true}
+        onArticleChange={(type: ArticleType, id: string, target: string) => {}}
+        onLoad={(data: IArticleDataResponse) => {}}
+        onAnthologySelect={(id: string) => {}}
+      />
+    </>
+  ) : (
+    <>loading</>
+  );
+  /*
   const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
   const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
   useEffect(() => {
   useEffect(() => {
     console.log("srcDataMode", srcDataMode);
     console.log("srcDataMode", srcDataMode);
@@ -119,6 +233,7 @@ const TypeCourseWidget = ({
             url = `/v2/article/${articleId}?view=textbook&course=${courseId}&mode=${srcDataMode}`;
             url = `/v2/article/${articleId}?view=textbook&course=${courseId}&mode=${srcDataMode}`;
           }
           }
           break;
           break;
+
         case "exercise":
         case "exercise":
           if (typeof articleId !== "undefined") {
           if (typeof articleId !== "undefined") {
             url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}&user=${userName}`;
             url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}&user=${userName}`;
@@ -144,6 +259,7 @@ const TypeCourseWidget = ({
             );
             );
           }
           }
           break;
           break;
+
       }
       }
 
 
       console.log("url", url);
       console.log("url", url);
@@ -227,7 +343,8 @@ const TypeCourseWidget = ({
     exerciseId,
     exerciseId,
     userName,
     userName,
   ]);
   ]);
-
+*/
+  /*
   return (
   return (
     <div>
     <div>
       <ArticleView
       <ArticleView
@@ -264,6 +381,7 @@ const TypeCourseWidget = ({
       <Divider />
       <Divider />
     </div>
     </div>
   );
   );
+  */
 };
 };
 
 
 export default TypeCourseWidget;
 export default TypeCourseWidget;

+ 2 - 1
dashboard/src/components/course/CourseInfoEdit.tsx

@@ -94,7 +94,8 @@ const CourseInfoEditWidget = ({
           } else if (typeof values.cover[0].response === "undefined") {
           } else if (typeof values.cover[0].response === "undefined") {
             _cover = values.cover[0].uid;
             _cover = values.cover[0].uid;
           } else {
           } else {
-            _cover = values.cover[0].response.data.id;
+            console.debug("upload ", values.cover[0].response);
+            _cover = values.cover[0].response.data.name;
           }
           }
 
 
           const res = await put<ICourseDataRequest, ICourseResponse>(
           const res = await put<ICourseDataRequest, ICourseResponse>(

+ 22 - 18
dashboard/src/components/course/SelectChannel.tsx

@@ -11,7 +11,7 @@ import { LockIcon } from "../../assets/icon";
 import { ICourseMemberData, ICourseMemberResponse } from "../api/Course";
 import { ICourseMemberData, ICourseMemberResponse } from "../api/Course";
 import { useNavigate, useParams } from "react-router-dom";
 import { useNavigate, useParams } from "react-router-dom";
 interface IWidget {
 interface IWidget {
-  courseId?: string;
+  courseId?: string | null;
   exerciseId?: string;
   exerciseId?: string;
   channel?: string;
   channel?: string;
   onSelected?: Function;
   onSelected?: Function;
@@ -29,7 +29,7 @@ const SelectChannelWidget = ({
   const user = useAppSelector(_currentUser);
   const user = useAppSelector(_currentUser);
   const { id } = useParams(); //url 参数
   const { id } = useParams(); //url 参数
   const navigate = useNavigate();
   const navigate = useNavigate();
-  //TODO 从哪里拿到courseId?
+
   return (
   return (
     <ModalForm<{
     <ModalForm<{
       channel: string;
       channel: string;
@@ -45,24 +45,23 @@ const SelectChannelWidget = ({
       onFinish={async (values) => {
       onFinish={async (values) => {
         console.log(values.channel);
         console.log(values.channel);
         console.log("id", id);
         console.log("id", id);
-        const mCourseId = id?.split("_")[0];
-        if (typeof user !== "undefined" && typeof mCourseId !== "undefined") {
+
+        if (user && courseId) {
+          const url = `/v2/course-member_set-channel`;
+          const data = {
+            user_id: user.id,
+            course_id: courseId,
+            channel_id: values.channel,
+          };
+          console.debug("course select channel", url, data);
           const json = await put<ICourseMemberData, ICourseMemberResponse>(
           const json = await put<ICourseMemberData, ICourseMemberResponse>(
-            `/v2/course-member_set-channel`,
-            {
-              user_id: user.id,
-              course_id: mCourseId,
-              channel_id: values.channel,
-            }
+            url,
+            data
           );
           );
           if (json.ok) {
           if (json.ok) {
-            if (json.data.channel_id === courseId) {
-              message.success("提交成功");
-              navigate(
-                `/article/exercise/${id}_${exerciseId}_${user.realName}/wbw`
-              );
-            } else {
-              message.error(json.data.channel_id);
+            message.success("提交成功");
+            if (typeof onSelected !== "undefined") {
+              onSelected();
             }
             }
           } else {
           } else {
             message.error(json.message);
             message.error(json.message);
@@ -73,9 +72,14 @@ const SelectChannelWidget = ({
 
 
         return true;
         return true;
       }}
       }}
+      onOpenChange={(visible: boolean) => {
+        if (typeof onOpenChange !== "undefined") {
+          onOpenChange(visible);
+        }
+      }}
     >
     >
       <div>
       <div>
-        您还没有选择版本。您将用一个版本保存自己的作业。这个版本将会被老师,助理老师看到。
+        您还没有选择版本。您将用一个版本保存自己的作业。这个版本里面的内容将会被老师,助理老师看到。
       </div>
       </div>
       <ProForm.Group>
       <ProForm.Group>
         <ProFormSelect
         <ProFormSelect

+ 12 - 2
dashboard/src/components/course/TextBook.tsx

@@ -1,6 +1,7 @@
 import { Col, Row } from "antd";
 import { Col, Row } from "antd";
 import { useNavigate } from "react-router-dom";
 import { useNavigate } from "react-router-dom";
 import AnthologyDetail from "../article/AnthologyDetail";
 import AnthologyDetail from "../article/AnthologyDetail";
+import { fullUrl } from "../../utils";
 
 
 interface IWidget {
 interface IWidget {
   anthologyId?: string;
   anthologyId?: string;
@@ -17,8 +18,17 @@ const TextBookWidget = ({ anthologyId, courseId }: IWidget) => {
         <Col flex="960px">
         <Col flex="960px">
           <AnthologyDetail
           <AnthologyDetail
             aid={anthologyId}
             aid={anthologyId}
-            onArticleSelect={(anthologyId: string, keys: string[]) => {
-              navigate(`/article/textbook/${courseId}_${keys[0]}?mode=read`);
+            onArticleClick={(
+              anthologyId: string,
+              articleId: string,
+              target: string
+            ) => {
+              const url = `/article/textbook/${articleId}?mode=read&course=${courseId}`;
+              if (target === "_blank") {
+                window.open(fullUrl(url), "_blank");
+              } else {
+                navigate(url);
+              }
             }}
             }}
           />
           />
         </Col>
         </Col>

+ 1 - 1
dashboard/src/components/general/Video.tsx

@@ -25,7 +25,7 @@ export const VideoWidget = ({ src, type }: IWidget) => {
   return (
   return (
     <VideoPlayer
     <VideoPlayer
       options={{
       options={{
-        autoplay: true,
+        autoplay: false,
         controls: true,
         controls: true,
         responsive: true,
         responsive: true,
         fluid: true,
         fluid: true,

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

@@ -13,6 +13,7 @@ import SentEdit from "./SentEdit";
 import SentRead from "./SentRead";
 import SentRead from "./SentRead";
 import Term from "./Term";
 import Term from "./Term";
 import Toggle from "./Toggle";
 import Toggle from "./Toggle";
+import Video from "./Video";
 import WbwSent from "./WbwSent";
 import WbwSent from "./WbwSent";
 import Wd from "./Wd";
 import Wd from "./Wd";
 
 
@@ -57,6 +58,8 @@ const Widget = ({ tpl, props, children }: IWidgetMdTpl) => {
       return <ParaShell props={props ? props : ""}>{children}</ParaShell>;
       return <ParaShell props={props ? props : ""}>{children}</ParaShell>;
     case "qa":
     case "qa":
       return <Qa props={props ? props : ""} />;
       return <Qa props={props ? props : ""} />;
+    case "video":
+      return <Video props={props ? props : ""} />;
     default:
     default:
       return <>未定义模版({tpl})</>;
       return <>未定义模版({tpl})</>;
   }
   }

+ 27 - 5
dashboard/src/components/template/SentEdit/SentWbw.tsx

@@ -4,6 +4,9 @@ import { useEffect, useState } from "react";
 import { get } from "../../../request";
 import { get } from "../../../request";
 import { ISentenceWbwListResponse } from "../../api/Corpus";
 import { ISentenceWbwListResponse } from "../../api/Corpus";
 import { IWidgetSentEditInner, SentEditInner } from "../SentEdit";
 import { IWidgetSentEditInner, SentEditInner } from "../SentEdit";
+import { useAppSelector } from "../../../hooks";
+import { courseInfo, memberInfo } from "../../../reducers/current-course";
+import { courseUser } from "../../../reducers/course-user";
 
 
 interface IWidget {
 interface IWidget {
   book: number;
   book: number;
@@ -24,16 +27,35 @@ const SentWbwWidget = ({
   onReload,
   onReload,
 }: IWidget) => {
 }: IWidget) => {
   const [initLoading, setInitLoading] = useState(true);
   const [initLoading, setInitLoading] = useState(true);
-
   const [sentData, setSentData] = useState<IWidgetSentEditInner[]>([]);
   const [sentData, setSentData] = useState<IWidgetSentEditInner[]>([]);
+  const course = useAppSelector(courseInfo);
+  const courseMember = useAppSelector(memberInfo);
+
+  const myCourse = useAppSelector(courseUser);
 
 
   const load = () => {
   const load = () => {
-    let url = `/v2/wbw-sentence?view=sent-can-read&book=${book}&para=${para}&wordStart=${wordStart}&wordEnd=${wordEnd}`;
-    if (channelsId && channelsId.length > 0) {
-      url += `&exclude=${channelsId[0]}`;
+    let url = `/v2/wbw-sentence?view=sent-can-read`;
+    url += `&book=${book}&para=${para}&wordStart=${wordStart}&wordEnd=${wordEnd}`;
+
+    console.debug("wbw sentence load", myCourse, course);
+    if (myCourse && course) {
+      url += `&course=${course.courseId}`;
+      if (myCourse.role === "student") {
+        url += `&channels=${course.channelId}`;
+      } else if (courseMember) {
+        console.debug("course member", courseMember);
+        const channels = courseMember
+          .filter((value) => typeof value.channel_id === "string")
+          .map((item) => item.channel_id);
+        url += `&channels=${channels.join(",")}`;
+      }
+    } else {
+      if (channelsId && channelsId.length > 0) {
+        url += `&exclude=${channelsId[0]}`;
+      }
     }
     }
 
 
-    console.log("url", url);
+    console.log("wbw sentence url", url);
     get<ISentenceWbwListResponse>(url)
     get<ISentenceWbwListResponse>(url)
       .then((json) => {
       .then((json) => {
         if (json.ok) {
         if (json.ok) {

+ 126 - 0
dashboard/src/components/template/Video.tsx

@@ -0,0 +1,126 @@
+import { Card, Collapse, Modal, Space } from "antd";
+import { Typography } from "antd";
+import { useState } from "react";
+
+import { Link } from "react-router-dom";
+import { TDisplayStyle } from "./Article";
+import Video from "../general/Video";
+import { VideoIcon } from "../../assets/icon";
+
+const { Text } = Typography;
+
+interface IVideoCtl {
+  url?: string;
+  title?: React.ReactNode;
+  style?: TDisplayStyle;
+}
+
+export const VideoCtl = ({ url, title, style = "modal" }: IVideoCtl) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  let output = <></>;
+  let articleLink = url ? url : "";
+
+  switch (style) {
+    case "modal":
+      output = (
+        <>
+          <Typography.Link
+            onClick={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
+              if (event.ctrlKey || event.metaKey) {
+                window.open(articleLink, "_blank");
+              } else {
+                showModal();
+              }
+            }}
+          >
+            <Space>
+              <VideoIcon />
+              {title}
+            </Space>
+          </Typography.Link>
+          <Modal
+            width={"90%"}
+            destroyOnClose
+            style={{ maxWidth: 1000, top: 20, height: 700 }}
+            title={
+              <div
+                style={{
+                  display: "flex",
+                  justifyContent: "space-between",
+                  marginRight: 30,
+                }}
+              >
+                <Text>{title}</Text>
+                <Text>
+                  <Link to={articleLink} target="_blank">
+                    {"新窗口打开"}
+                  </Link>
+                </Text>
+              </div>
+            }
+            open={isModalOpen}
+            onOk={handleOk}
+            onCancel={handleCancel}
+            footer={[]}
+          >
+            <div style={{ height: 550 }}>
+              <Video src={url} />
+            </div>
+          </Modal>
+        </>
+      );
+      break;
+    case "card":
+      output = (
+        <Card title={title}>
+          <Video src={url} />
+        </Card>
+      );
+      break;
+    case "toggle":
+      output = (
+        <Collapse bordered={false}>
+          <Collapse.Panel header={title} key="parent2">
+            <Video src={url} />
+          </Collapse.Panel>
+        </Collapse>
+      );
+      break;
+    case "link":
+      output = (
+        <Link to={articleLink} target="_blank">
+          <Space>
+            <VideoIcon />
+            {title}
+          </Space>
+        </Link>
+      );
+      break;
+    default:
+      break;
+  }
+  return output;
+};
+
+interface IWidget {
+  props: string;
+  children?: React.ReactNode;
+}
+const VideoWidget = ({ props, children }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IVideoCtl;
+  return <VideoCtl {...prop} />;
+};
+
+export default VideoWidget;

+ 13 - 5
dashboard/src/components/template/Wbw/WbwDetailParent.tsx

@@ -60,10 +60,17 @@ const WbwDetailParentWidget = ({ data, onChange }: IWidget) => {
         value: item,
         value: item,
       };
       };
     });
     });
-    setParentOptions([
-      ...parentOptions,
-      { label: data.real.value, value: data.real.value },
-    ]);
+    const findParent = parentOptions.find(
+      (value) => value.value === data.real.value
+    );
+    if (findParent) {
+      setParentOptions(parentOptions);
+    } else {
+      setParentOptions([
+        ...parentOptions,
+        { label: data.real.value, value: data.real.value },
+      ]);
+    }
   }, [inlineDict, data]);
   }, [inlineDict, data]);
 
 
   return (
   return (
@@ -71,8 +78,9 @@ const WbwDetailParentWidget = ({ data, onChange }: IWidget) => {
       options={parentOptions}
       options={parentOptions}
       value={data.parent?.value}
       value={data.parent?.value}
       onChange={(value: any, option: ValueType | ValueType[]) => {
       onChange={(value: any, option: ValueType | ValueType[]) => {
+        console.debug("wbw parent onChange", value);
         if (typeof onChange !== "undefined") {
         if (typeof onChange !== "undefined") {
-          onChange({ field: "parent", value: value });
+          onChange(value);
         }
         }
       }}
       }}
     >
     >

+ 165 - 114
dashboard/src/components/template/WbwSent.tsx

@@ -1,4 +1,4 @@
-import { Button, Dropdown, message, Tree } from "antd";
+import { Button, Dropdown, message, Progress, Tree } from "antd";
 import { useEffect, useState } from "react";
 import { useEffect, useState } from "react";
 import { MoreOutlined } from "@ant-design/icons";
 import { MoreOutlined } from "@ant-design/icons";
 
 
@@ -113,11 +113,46 @@ export const WbwSentCtl = ({
   const [displayMode, setDisplayMode] = useState<ArticleMode>();
   const [displayMode, setDisplayMode] = useState<ArticleMode>();
   const [magic, setMagic] = useState<string>();
   const [magic, setMagic] = useState<string>();
   const [loading, setLoading] = useState(false);
   const [loading, setLoading] = useState(false);
+  const [progress, setProgress] = useState(0);
+  const [showProgress, setShowProgress] = useState(false);
+
   const settings = useAppSelector(settingInfo);
   const settings = useAppSelector(settingInfo);
   const sysGrammar = useAppSelector(getGrammar)?.filter(
   const sysGrammar = useAppSelector(getGrammar)?.filter(
     (value) => value.tag === ":collocation:"
     (value) => value.tag === ":collocation:"
   );
   );
 
 
+  useEffect(() => {
+    //计算完成度
+    const allWord = wordData.filter(
+      (value) =>
+        value.real.value &&
+        value.real.value?.length > 0 &&
+        value.type?.value !== ".ctl."
+    );
+    const final = allWord.filter(
+      (value) =>
+        value.meaning?.value &&
+        value.factors?.value &&
+        value.factorMeaning?.value &&
+        value.case?.value
+    );
+    console.debug("wbw progress", allWord, final);
+    let finalLen: number = 0;
+    final.forEach((value) => {
+      if (value.real.value) {
+        finalLen += value.real.value?.length;
+      }
+    });
+    let allLen: number = 0;
+    allWord.forEach((value) => {
+      if (value.real.value) {
+        allLen += value.real.value?.length;
+      }
+    });
+    const progress = Math.round((finalLen * 100) / allLen);
+    setProgress(progress);
+  }, [wordData]);
+
   useEffect(() => {
   useEffect(() => {
     setMagic(magicDict);
     setMagic(magicDict);
   }, [magicDict]);
   }, [magicDict]);
@@ -125,6 +160,7 @@ export const WbwSentCtl = ({
   const newMode = useAppSelector(_mode);
   const newMode = useAppSelector(_mode);
 
 
   const update = (data: IWbw[]) => {
   const update = (data: IWbw[]) => {
+    console.debug("wbw update");
     setWordData(data);
     setWordData(data);
     if (typeof onChange !== "undefined") {
     if (typeof onChange !== "undefined") {
       onChange(data);
       onChange(data);
@@ -519,123 +555,138 @@ export const WbwSentCtl = ({
   };
   };
 
 
   return (
   return (
-    <div className={`layout-${layoutDirection}`}>
-      <Dropdown
-        menu={{
-          items: [
-            {
-              key: "magic-dict-current",
-              label: "神奇字典",
-            },
-            {
-              key: "wbw-dict-publish-all",
-              label: "发布全部单词",
-            },
-            {
-              type: "divider",
-            },
-            {
-              key: "copy-text",
-              label: intl.formatMessage({
-                id: "buttons.copy.pali.text",
-              }),
-            },
-          ],
-          onClick: ({ key }) => {
-            console.log(`Click on item ${key}`);
-            switch (key) {
-              case "magic-dict-current":
-                setLoading(true);
-                setMagic("curr");
-                break;
-              case "wbw-dict-publish-all":
-                wbwPublish(wordData);
-                break;
-              case "copy-text":
-                const paliText = wordData
-                  .filter((value) => value.type?.value !== ".ctl.")
-                  .map((item) => item.word.value)
-                  .join(" ");
-                navigator.clipboard.writeText(paliText).then(() => {
-                  message.success("已经拷贝到剪贴板");
-                });
-                break;
-            }
-          },
-        }}
-        placement="bottomLeft"
+    <div>
+      <div
+        className="progress"
+        style={{ display: showProgress ? "block" : "none" }}
       >
       >
-        <Button
-          loading={loading}
-          onClick={(e) => e.preventDefault()}
-          icon={<MoreOutlined />}
-          size="small"
-          type="text"
-          style={{ backgroundColor: "lightblue", opacity: 0.3 }}
-        />
-      </Dropdown>
-      {layoutDirection === "h" ? (
-        wordData
-          .map((item, index) => {
-            let newItem = item;
-            const spell = item.real.value;
-            if (spell) {
-              const matched = sysGrammar?.find((value) =>
-                value.word.split("...").includes(spell)
-              );
-              if (matched) {
-                console.debug("wbw sent grammar matched", matched);
-                newItem.grammarId = matched.guid;
+        <Progress percent={progress} size="small" />
+      </div>
+      <div className={`layout-${layoutDirection}`}>
+        <Dropdown
+          menu={{
+            items: [
+              {
+                key: "magic-dict-current",
+                label: "神奇字典",
+              },
+              {
+                key: "progress",
+                label: "显示/隐藏进度条",
+              },
+              {
+                key: "wbw-dict-publish-all",
+                label: "发布全部单词",
+              },
+              {
+                type: "divider",
+              },
+              {
+                key: "copy-text",
+                label: intl.formatMessage({
+                  id: "buttons.copy.pali.text",
+                }),
+              },
+            ],
+            onClick: ({ key }) => {
+              console.log(`Click on item ${key}`);
+              switch (key) {
+                case "magic-dict-current":
+                  setLoading(true);
+                  setMagic("curr");
+                  break;
+                case "wbw-dict-publish-all":
+                  wbwPublish(wordData);
+                  break;
+                case "copy-text":
+                  const paliText = wordData
+                    .filter((value) => value.type?.value !== ".ctl.")
+                    .map((item) => item.word.value)
+                    .join(" ");
+                  navigator.clipboard.writeText(paliText).then(() => {
+                    message.success("已经拷贝到剪贴板");
+                  });
+                  break;
+                case "progress":
+                  setShowProgress((origin) => !origin);
+                  break;
               }
               }
-            }
-            return newItem;
-          })
-          .map((item, id) => {
-            return wbwRender(item, id);
-          })
-      ) : (
-        <Tree
-          selectable={true}
-          blockNode
-          treeData={wordData
-            .filter((value) => value.sn.length === 1)
-            .map((item, id) => {
-              const children = wordData.filter(
-                (value) =>
-                  value.sn.length === 2 &&
-                  value.sn.slice(0, 1).join() === wordData[id].sn.join()
-              );
-
-              return {
-                title: wbwRender(item, id),
-                key: item.sn.join(),
-                isLeaf: !item.factors?.value?.includes("+"),
-                children:
-                  children.length > 0
-                    ? children.map((item, id) => {
-                        return {
-                          title: wbwRender(item, id),
-                          key: item.sn.join(),
-                          isLeaf: true,
-                        };
-                      })
-                    : undefined,
-              };
-            })}
-          loadData={({ key, children }: any) =>
-            new Promise<void>((resolve) => {
-              console.log("key", key, children);
-              if (children) {
-                resolve();
-                return;
+            },
+          }}
+          placement="bottomLeft"
+        >
+          <Button
+            loading={loading}
+            onClick={(e) => e.preventDefault()}
+            icon={<MoreOutlined />}
+            size="small"
+            type="text"
+            style={{ backgroundColor: "lightblue", opacity: 0.3 }}
+          />
+        </Dropdown>
+        {layoutDirection === "h" ? (
+          wordData
+            .map((item, index) => {
+              let newItem = item;
+              const spell = item.real.value;
+              if (spell) {
+                const matched = sysGrammar?.find((value) =>
+                  value.word.split("...").includes(spell)
+                );
+                if (matched) {
+                  console.debug("wbw sent grammar matched", matched);
+                  newItem.grammarId = matched.guid;
+                }
               }
               }
-
-              wordSplit(key, "");
-              resolve();
+              return newItem;
             })
             })
-          }
-        />
-      )}
+            .map((item, id) => {
+              return wbwRender(item, id);
+            })
+        ) : (
+          <Tree
+            selectable={true}
+            blockNode
+            treeData={wordData
+              .filter((value) => value.sn.length === 1)
+              .map((item, id) => {
+                const children = wordData.filter(
+                  (value) =>
+                    value.sn.length === 2 &&
+                    value.sn.slice(0, 1).join() === wordData[id].sn.join()
+                );
+
+                return {
+                  title: wbwRender(item, id),
+                  key: item.sn.join(),
+                  isLeaf: !item.factors?.value?.includes("+"),
+                  children:
+                    children.length > 0
+                      ? children.map((item, id) => {
+                          return {
+                            title: wbwRender(item, id),
+                            key: item.sn.join(),
+                            isLeaf: true,
+                          };
+                        })
+                      : undefined,
+                };
+              })}
+            loadData={({ key, children }: any) =>
+              new Promise<void>((resolve) => {
+                console.log("key", key, children);
+                if (children) {
+                  resolve();
+                  return;
+                }
+
+                wordSplit(key, "");
+                resolve();
+              })
+            }
+          />
+        )}
+      </div>
     </div>
     </div>
   );
   );
 };
 };

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

@@ -366,6 +366,7 @@ const Widget = () => {
               para={searchParams.get("par")}
               para={searchParams.get("par")}
               channelId={searchParams.get("channel")}
               channelId={searchParams.get("channel")}
               focus={searchParams.get("focus")}
               focus={searchParams.get("focus")}
+              courseId={searchParams.get("course")}
               articleId={id}
               articleId={id}
               anthologyId={searchParams.get("anthology")}
               anthologyId={searchParams.get("anthology")}
               mode={searchParams.get("mode") as ArticleMode}
               mode={searchParams.get("mode") as ArticleMode}

+ 3 - 2
dashboard/src/pages/studio/course/list.tsx

@@ -148,6 +148,7 @@ const Widget = () => {
             key: "title",
             key: "title",
             tip: "过长会自动收缩",
             tip: "过长会自动收缩",
             ellipsis: true,
             ellipsis: true,
+            width: 300,
             render: (text, row, index, action) => {
             render: (text, row, index, action) => {
               return (
               return (
                 <Space key={index}>
                 <Space key={index}>
@@ -210,7 +211,7 @@ const Widget = () => {
             }),
             }),
             dataIndex: "type",
             dataIndex: "type",
             key: "type",
             key: "type",
-            width: 100,
+            width: 80,
             search: false,
             search: false,
             filters: true,
             filters: true,
             onFilter: true,
             onFilter: true,
@@ -350,7 +351,7 @@ const Widget = () => {
           console.log("url", url);
           console.log("url", url);
 
 
           const res = await get<ICourseListResponse>(url);
           const res = await get<ICourseListResponse>(url);
-          console.log("api data", res);
+          console.debug("course data", res);
           const items: DataItem[] = res.data.rows.map((item, id) => {
           const items: DataItem[] = res.data.rows.map((item, id) => {
             return {
             return {
               sn: id + offset + 1,
               sn: id + offset + 1,

+ 3 - 2
dashboard/src/reducers/course-user.ts

@@ -1,6 +1,7 @@
 import { createSlice, PayloadAction } from "@reduxjs/toolkit";
 import { createSlice, PayloadAction } from "@reduxjs/toolkit";
 
 
 import type { RootState } from "../store";
 import type { RootState } from "../store";
+import { TCourseRole } from "../components/api/Course";
 
 
 export const ROLE_ROOT = "root";
 export const ROLE_ROOT = "root";
 export const ROLE_ASSISTANT = "assistant";
 export const ROLE_ASSISTANT = "assistant";
@@ -23,8 +24,8 @@ const remove = () => {
 };
 };
 
 
 export interface ICourseUser {
 export interface ICourseUser {
-  channelId: string;
-  role: string;
+  channelId?: string | null;
+  role: TCourseRole;
 }
 }
 
 
 interface IState {
 interface IState {

+ 10 - 1
dashboard/src/reducers/current-course.ts

@@ -1,13 +1,16 @@
 import { createSlice, PayloadAction } from "@reduxjs/toolkit";
 import { createSlice, PayloadAction } from "@reduxjs/toolkit";
 
 
 import type { RootState } from "../store";
 import type { RootState } from "../store";
+import { ICourseMemberData } from "../components/api/Course";
 
 
 export interface ITextbook {
 export interface ITextbook {
   courseId: string;
   courseId: string;
   articleId: string;
   articleId: string;
+  channelId: string;
 }
 }
 interface IState {
 interface IState {
   course?: ITextbook;
   course?: ITextbook;
+  member?: ICourseMemberData[];
 }
 }
 
 
 const initialState: IState = {};
 const initialState: IState = {};
@@ -19,14 +22,20 @@ export const slice = createSlice({
     refresh: (state, action: PayloadAction<ITextbook>) => {
     refresh: (state, action: PayloadAction<ITextbook>) => {
       state.course = action.payload;
       state.course = action.payload;
     },
     },
+    memberRefresh: (state, action: PayloadAction<ICourseMemberData[]>) => {
+      state.member = action.payload;
+    },
   },
   },
 });
 });
 
 
-export const { refresh } = slice.actions;
+export const { refresh, memberRefresh } = slice.actions;
 
 
 export const currentCourse = (state: RootState): IState => state.currentCourse;
 export const currentCourse = (state: RootState): IState => state.currentCourse;
 
 
 export const courseInfo = (state: RootState): ITextbook | undefined =>
 export const courseInfo = (state: RootState): ITextbook | undefined =>
   state.currentCourse.course;
   state.currentCourse.course;
 
 
+export const memberInfo = (state: RootState): ICourseMemberData[] | undefined =>
+  state.currentCourse.member;
+
 export default slice.reducer;
 export default slice.reducer;

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor