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

Merge branch 'iapt-platform:agile' into agile

Bhikkhu China Kosalla 1 год назад
Родитель
Сommit
e2c6cade4c
29 измененных файлов с 461 добавлено и 246 удалено
  1. 17 4
      dashboard/src/components/api/Course.ts
  2. 8 6
      dashboard/src/components/auth/Account.tsx
  3. 6 2
      dashboard/src/components/course/AddMember.tsx
  4. 31 49
      dashboard/src/components/course/CourseHead.tsx
  5. 41 8
      dashboard/src/components/course/CourseInfoEdit.tsx
  6. 9 20
      dashboard/src/components/course/CourseList.tsx
  7. 14 13
      dashboard/src/components/course/CourseMemberList.tsx
  8. 8 19
      dashboard/src/components/course/LecturerList.tsx
  9. 8 0
      dashboard/src/components/course/RolePower.ts
  10. 33 31
      dashboard/src/components/course/Status.tsx
  11. 0 2
      dashboard/src/components/discussion/DiscussionButton.tsx
  12. 24 14
      dashboard/src/components/general/NissayaCard.tsx
  13. 5 16
      dashboard/src/components/home/CourseNewList.tsx
  14. 59 8
      dashboard/src/components/template/Wbw/WbwDetail.tsx
  15. 3 0
      dashboard/src/components/template/Wbw/WbwDetailOrder.tsx
  16. 43 2
      dashboard/src/components/template/Wbw/WbwPali.tsx
  17. 1 1
      dashboard/src/components/template/Wbw/WbwPaliDiscussionIcon.tsx
  18. 1 0
      dashboard/src/components/template/Wbw/wbw.css
  19. 1 0
      dashboard/src/components/term/TermEdit.tsx
  20. 1 0
      dashboard/src/load.ts
  21. 6 47
      dashboard/src/pages/library/course/course.tsx
  22. 2 2
      dashboard/src/pages/studio/course/edit.tsx
  23. 20 1
      dashboard/src/reducers/setting.ts
  24. 1 1
      deploy/roles/mint-v2/templates/v2/env.j2
  25. 4 0
      rpc/tutorials/php/.gitignore
  26. 9 0
      rpc/tutorials/php/README.md
  27. 7 0
      rpc/tutorials/php/composer.json
  28. 11 0
      rpc/tutorials/php/config-orig.yml
  29. 88 0
      rpc/tutorials/php/lily.php

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

@@ -27,10 +27,21 @@ export interface ICourseDataRequest {
   sign_up_end_at: string | null; //报名结束时间
   join: string;
   request_exp: string;
-}
-export type TCourseRole = "teacher" | "manager" | "assistant" | "student";
+  number: number;
+}
+export type TCourseRole =
+  | "owner"
+  | "teacher"
+  | "manager"
+  | "assistant"
+  | "student";
 export type TCourseJoinMode = "invite" | "manual" | "open";
 export type TCourseExpRequest = "none" | "begin-end" | "daily";
+
+export interface IMember {
+  role: TCourseRole;
+  status: TCourseMemberStatus;
+}
 export interface ICourseDataResponse {
   id: string; //课程ID
   title: string; //标题
@@ -58,6 +69,8 @@ export interface ICourseDataResponse {
   my_status?: TCourseMemberStatus;
   my_status_id?: string;
   count_progressing?: number;
+  number: number;
+  members?: IMember[];
   created_at: string; //创建时间
   updated_at: string; //修改时间
 }
@@ -153,7 +166,7 @@ export interface ICourseMemberData {
   course_id: string;
   channel_id?: string;
   channel?: IChannel;
-  role?: string;
+  role?: TCourseRole;
   operating?: "invite" | "sign_up";
   user?: IUser;
   editor?: IUser;
@@ -171,7 +184,7 @@ export interface ICourseMemberListResponse {
   message: string;
   data: {
     rows: ICourseMemberData[];
-    role: TRole;
+    role: TCourseRole;
     count: number;
   };
 }

+ 8 - 6
dashboard/src/components/auth/Account.tsx

@@ -53,12 +53,14 @@ const AccountWidget = ({ userId, onLoad }: IWidget) => {
       <ProFormText width="md" readonly name="nickName" label="Nick Name" />
       <ProFormText width="md" readonly name="email" label="Email" />
       <ProFormSelect
-        options={["administrator", "member", "uploader"].map((item) => {
-          return {
-            value: item,
-            label: item,
-          };
-        })}
+        options={["administrator", "member", "uploader", "basic"].map(
+          (item) => {
+            return {
+              value: item,
+              label: item,
+            };
+          }
+        )}
         fieldProps={{
           mode: "tags",
         }}

+ 6 - 2
dashboard/src/components/course/AddMember.tsx

@@ -6,11 +6,15 @@ import { UserAddOutlined } from "@ant-design/icons";
 import { get, post } from "../../request";
 import { IUserListResponse } from "../api/Auth";
 import { useState } from "react";
-import { ICourseMemberData, ICourseMemberResponse } from "../api/Course";
+import {
+  ICourseMemberData,
+  ICourseMemberResponse,
+  TCourseRole,
+} from "../api/Course";
 
 interface IFormData {
   userId: string;
-  role: string;
+  role: TCourseRole;
 }
 
 interface IWidget {

+ 31 - 49
dashboard/src/components/course/CourseHead.tsx

@@ -4,10 +4,9 @@ import { Image, Space, Col, Row, Breadcrumb, Tag } from "antd";
 import { Typography } from "antd";
 import { HomeOutlined } from "@ant-design/icons";
 
-import { IUser } from "../auth/User";
 import { API_HOST } from "../../request";
 import UserName from "../auth/UserName";
-import { TCourseJoinMode } from "../api/Course";
+import { ICourseDataResponse } from "../api/Course";
 import { useIntl } from "react-intl";
 import Status from "./Status";
 import moment from "moment";
@@ -27,37 +26,22 @@ const courseDuration = (startAt?: string, endAt?: string) => {
 };
 
 interface IWidget {
-  id?: string;
-  title?: string;
-  subtitle?: string;
-  coverUrl?: string[];
-  startAt?: string;
-  endAt?: string;
-  signUpStartAt?: string;
-  signUpEndAt?: string;
-  teacher?: IUser;
-  join?: TCourseJoinMode;
+  data?: ICourseDataResponse;
 }
-const CourseHeadWidget = ({
-  id,
-  title,
-  subtitle,
-  coverUrl,
-  teacher,
-  startAt,
-  endAt,
-  signUpStartAt,
-  signUpEndAt,
-  join,
-}: IWidget) => {
+const CourseHeadWidget = ({ data }: IWidget) => {
   const intl = useIntl();
-  const duration = courseDuration(startAt, endAt);
+  const duration = courseDuration(data?.start_at, data?.end_at);
   let signUp = "";
-  if (moment().isBefore(moment(signUpStartAt))) {
+  if (moment().isBefore(moment(data?.sign_up_start_at))) {
     signUp = "未开始";
-  } else if (moment().isBetween(moment(signUpStartAt), moment(signUpEndAt))) {
+  } else if (
+    moment().isBetween(
+      moment(data?.sign_up_start_at),
+      moment(data?.sign_up_end_at)
+    )
+  ) {
     signUp = "可报名";
-  } else if (moment().isAfter(moment(signUpEndAt))) {
+  } else if (moment().isAfter(moment(data?.sign_up_end_at))) {
     signUp = "已结束";
   }
   return (
@@ -75,62 +59,60 @@ const CourseHeadWidget = ({
                   <Text>课程</Text>
                 </Link>
               </Breadcrumb.Item>
-              <Breadcrumb.Item>{title}</Breadcrumb.Item>
+              <Breadcrumb.Item>{data?.title}</Breadcrumb.Item>
             </Breadcrumb>
             <Space>
               <Image
                 width={200}
                 style={{ borderRadius: 12 }}
-                src={coverUrl && coverUrl.length > 1 ? coverUrl[1] : undefined}
+                src={
+                  data?.cover_url && data?.cover_url.length > 1
+                    ? data?.cover_url[1]
+                    : undefined
+                }
                 preview={{
                   src:
-                    coverUrl && coverUrl.length > 0 ? coverUrl[0] : undefined,
+                    data?.cover_url && data?.cover_url.length > 0
+                      ? data?.cover_url[0]
+                      : undefined,
                 }}
                 fallback={`${API_HOST}/app/course/img/default.jpg`}
               />
               <Space direction="vertical">
-                <Title level={3}>{title}</Title>
-                <Title level={5}>{subtitle}</Title>
+                <Title level={3}>{data?.title}</Title>
+                <Title level={5}>{data?.subtitle}</Title>
                 <Text>
                   <Space>
                     {"报名时间:"}
-                    {moment(signUpStartAt).format("YYYY-MM-DD")}——
-                    {moment(signUpEndAt).format("YYYY-MM-DD")}
+                    {moment(data?.sign_up_start_at).format("YYYY-MM-DD")}——
+                    {moment(data?.sign_up_end_at).format("YYYY-MM-DD")}
                     <Tag>{signUp}</Tag>
                   </Space>
                 </Text>
                 <Text>
                   <Space>
                     {"课程时间:"}
-                    {moment(startAt).format("YYYY-MM-DD")}——
-                    {moment(endAt).format("YYYY-MM-DD")}
+                    {moment(data?.start_at).format("YYYY-MM-DD")}——
+                    {moment(data?.end_at).format("YYYY-MM-DD")}
                     {duration}
                   </Space>
                 </Text>
                 <Text>
-                  {join
+                  {data?.join
                     ? intl.formatMessage({
-                        id: `course.join.mode.${join}.message`,
+                        id: `course.join.mode.${data.join}.message`,
                       })
                     : undefined}
                 </Text>
 
-                <Status
-                  courseId={id}
-                  courseName={title}
-                  joinMode={join}
-                  startAt={startAt}
-                  endAt={endAt}
-                  signUpStartAt={signUpStartAt}
-                  signUpEndAt={signUpEndAt}
-                />
+                <Status data={data} />
               </Space>
             </Space>
 
             <Space>
               <Text>主讲人:</Text>{" "}
               <Text>
-                <UserName {...teacher} />
+                <UserName {...data?.teacher} />
               </Text>
             </Space>
           </Space>

+ 41 - 8
dashboard/src/components/course/CourseInfoEdit.tsx

@@ -8,6 +8,7 @@ import {
   ProFormUploadButton,
   RequestOptionsType,
   ProFormDependency,
+  ProFormDigit,
 } from "@ant-design/pro-components";
 
 import { message, Form } from "antd";
@@ -28,7 +29,10 @@ import { UploadChangeParam, UploadFile } from "antd/es/upload/interface";
 import { IAttachmentResponse } from "../../components/api/Attachments";
 
 import { IAnthologyListResponse } from "../../components/api/Article";
-import { IApiResponseChannelList } from "../../components/api/Channel";
+import {
+  IApiResponseChannelData,
+  IApiResponseChannelList,
+} from "../../components/api/Channel";
 
 interface IFormData {
   title: string;
@@ -44,6 +48,7 @@ interface IFormData {
   status: number;
   join: string;
   exp: string;
+  number: number;
 }
 
 interface IWidget {
@@ -104,6 +109,7 @@ const CourseInfoEditWidget = ({
             sign_up_end_at: signUpEndAt,
             join: values.join,
             request_exp: values.exp,
+            number: values.number,
           };
           console.debug("course info edit put", url, postData);
           const res = await put<ICourseDataRequest, ICourseResponse>(
@@ -175,6 +181,7 @@ const CourseInfoEditWidget = ({
             status: res.data.publicity,
             join: res.data.join,
             exp: res.data.request_exp,
+            number: res.data.number,
           };
         }}
       >
@@ -252,6 +259,7 @@ const CourseInfoEditWidget = ({
               id: "forms.fields.teacher.label",
             })}
           />
+          <ProFormDigit label="招生数量" name="number" min={0} />
         </ProForm.Group>
         <ProForm.Group>
           <ProFormDateRangePicker width="md" name="signUp" label="报名时间" />
@@ -296,20 +304,45 @@ const CourseInfoEditWidget = ({
             debounceTime={300}
             request={async ({ keyWords }) => {
               console.log("keyWord", keyWords);
-              if (typeof keyWords === "undefined") {
+              if (typeof keyWords === "undefined" || keyWords === " ") {
                 return currChannel ? [currChannel] : [];
               }
-              const json = await get<IApiResponseChannelList>(
-                `/v2/channel?view=studio&name=${studioName}`
-              );
-              const textbookList = json.data.rows.map((item) => {
+              let urlMy = `/v2/channel?view=studio-all&name=${studioName}`;
+              if (typeof keyWords !== "undefined" && keyWords !== "") {
+                urlMy += "&search=" + keyWords;
+              }
+              console.info("api request", urlMy);
+              const json = await get<IApiResponseChannelList>(urlMy);
+              console.info("api response", json);
+
+              let urlPublic = `/v2/channel?view=public`;
+              if (typeof keyWords !== "undefined" && keyWords !== "") {
+                urlPublic += "&search=" + keyWords;
+              }
+              console.info("api request", urlPublic);
+              const jsonPublic = await get<IApiResponseChannelList>(urlPublic);
+              console.info("api response", jsonPublic);
+
+              //查重
+              let channels1: IApiResponseChannelData[] = [];
+              const channels = [...json.data.rows, ...jsonPublic.data.rows];
+              channels.forEach((value) => {
+                const has = channels1.findIndex(
+                  (value1) => value1.uid === value.uid
+                );
+                if (has === -1) {
+                  channels1.push(value);
+                }
+              });
+
+              const channelList = channels1.map((item) => {
                 return {
                   value: item.uid,
                   label: `${item.studio.nickName}/${item.name}`,
                 };
               });
-              console.log("json", textbookList);
-              return textbookList;
+              console.debug("channelList", channelList);
+              return channelList;
             }}
           />
         </ProForm.Group>

+ 9 - 20
dashboard/src/components/course/CourseList.tsx

@@ -1,10 +1,9 @@
 //课程列表
 import { Link } from "react-router-dom";
 import { useEffect, useState } from "react";
-
 import { Avatar, List, message, Typography, Image } from "antd";
-import { ICourse } from "../../pages/library/course/course";
-import { ICourseListResponse } from "../api/Course";
+
+import { ICourseDataResponse, ICourseListResponse } from "../api/Course";
 import { API_HOST, get } from "../../request";
 
 const { Paragraph } = Typography;
@@ -13,23 +12,13 @@ interface IWidget {
   type: "open" | "close";
 }
 const CourseListWidget = ({ type }: IWidget) => {
-  const [data, setData] = useState<ICourse[]>();
+  const [data, setData] = useState<ICourseDataResponse[]>();
 
   useEffect(() => {
     get<ICourseListResponse>(`/v2/course?view=${type}`).then((json) => {
       if (json.ok) {
         console.log(json.data);
-        const course: ICourse[] = json.data.rows.map((item) => {
-          return {
-            id: item.id,
-            title: item.title,
-            subtitle: item.subtitle,
-            teacher: item.teacher,
-            intro: item.content,
-            coverUrl: item.cover_url,
-          };
-        });
-        setData(course);
+        setData(json.data.rows);
       } else {
         message.error(json.message);
       }
@@ -55,14 +44,14 @@ const CourseListWidget = ({ type }: IWidget) => {
               width={128}
               style={{ borderRadius: 12 }}
               src={
-                item.coverUrl && item.coverUrl.length > 1
-                  ? item.coverUrl[1]
+                item.cover_url && item.cover_url.length > 1
+                  ? item.cover_url[1]
                   : undefined
               }
               preview={{
                 src:
-                  item.coverUrl && item.coverUrl.length > 0
-                    ? item.coverUrl[0]
+                  item.cover_url && item.cover_url.length > 0
+                    ? item.cover_url[0]
                     : undefined,
               }}
               fallback={`${API_HOST}/app/course/img/default.jpg`}
@@ -75,7 +64,7 @@ const CourseListWidget = ({ type }: IWidget) => {
             description={<div>主讲:{item.teacher?.nickName}</div>}
           />
           <Paragraph ellipsis={{ rows: 2, expandable: false }}>
-            {item.intro}
+            {item.summary}
           </Paragraph>
         </List.Item>
       )}

+ 14 - 13
dashboard/src/components/course/CourseMemberList.tsx

@@ -99,6 +99,14 @@ const CourseMemberListWidget = ({ courseId, onSelect }: IWidget) => {
           description: {
             dataIndex: "desc",
             search: false,
+            render(dom, entity, index, action, schema) {
+              return (
+                <div>
+                  {"channel:"}
+                  {entity.channel?.name ?? "未绑定"}
+                </div>
+              );
+            },
           },
           subTitle: {
             search: false,
@@ -116,16 +124,6 @@ const CourseMemberListWidget = ({ courseId, onSelect }: IWidget) => {
               );
             },
           },
-          content: {
-            render(dom, entity, index, action, schema) {
-              return (
-                <div>
-                  {"channel:"}
-                  {entity.channel?.name ?? "未绑定"}
-                </div>
-              );
-            },
-          },
           actions: {
             search: false,
             render: (text, row, index, action) => {
@@ -245,8 +243,11 @@ const CourseMemberListWidget = ({ courseId, onSelect }: IWidget) => {
             ((params.current ? params.current : 1) - 1) *
             (params.pageSize ? params.pageSize : 20);
           url += `&limit=${params.pageSize}&offset=${offset}`;
-          if (typeof params.keyword !== "undefined") {
-            url += "&search=" + (params.keyword ? params.keyword : "");
+          if (
+            typeof params.keyword !== "undefined" &&
+            params.keyword.trim() !== ""
+          ) {
+            url += "&search=" + params.keyword;
           }
           console.info("api request", url);
           const res = await get<ICourseMemberListResponse>(url);
@@ -256,7 +257,7 @@ const CourseMemberListWidget = ({ courseId, onSelect }: IWidget) => {
               setCanManage(true);
             }
             const items: ICourseMember[] = res.data.rows.map((item, id) => {
-              let member: ICourseMember = {
+              const member: ICourseMember = {
                 sn: id + 1,
                 id: item.id,
                 userId: item.user_id,

+ 8 - 19
dashboard/src/components/course/LecturerList.tsx

@@ -3,8 +3,7 @@ import { useNavigate } from "react-router-dom";
 import { useEffect, useState } from "react";
 import { Card, List, message, Typography, Image } from "antd";
 
-import { ICourse } from "../../pages/library/course/course";
-import { ICourseListResponse } from "../api/Course";
+import { ICourseDataResponse, ICourseListResponse } from "../api/Course";
 import { API_HOST, get } from "../../request";
 import CourseNewLoading from "./CourseNewLoading";
 
@@ -12,7 +11,7 @@ const { Meta } = Card;
 const { Paragraph } = Typography;
 
 const LecturerListWidget = () => {
-  const [data, setData] = useState<ICourse[]>();
+  const [data, setData] = useState<ICourseDataResponse[]>();
   const [loading, setLoading] = useState(false);
 
   const navigate = useNavigate();
@@ -25,17 +24,7 @@ const LecturerListWidget = () => {
       .then((json) => {
         if (json.ok) {
           console.log(json.data);
-          const course: ICourse[] = json.data.rows.map((item) => {
-            return {
-              id: item.id,
-              title: item.title,
-              subtitle: item.subtitle,
-              teacher: item.teacher,
-              intro: item.content,
-              coverUrl: item.cover_url,
-            };
-          });
-          setData(course);
+          setData(json.data.rows);
         } else {
           message.error(json.message);
         }
@@ -57,14 +46,14 @@ const LecturerListWidget = () => {
               <Image
                 alt="example"
                 src={
-                  item.coverUrl && item.coverUrl.length > 1
-                    ? item.coverUrl[1]
+                  item.cover_url && item.cover_url.length > 1
+                    ? item.cover_url[1]
                     : undefined
                 }
                 preview={{
                   src:
-                    item.coverUrl && item.coverUrl.length > 0
-                      ? item.coverUrl[0]
+                    item.cover_url && item.cover_url.length > 0
+                      ? item.cover_url[0]
                       : undefined,
                 }}
                 width="240"
@@ -80,7 +69,7 @@ const LecturerListWidget = () => {
               title={item.title}
               description={
                 <Paragraph ellipsis={{ rows: 2, expandable: false }}>
-                  {item.intro}
+                  {item.summary}
                 </Paragraph>
               }
             />

+ 8 - 0
dashboard/src/components/course/RolePower.ts

@@ -233,6 +233,14 @@ const studentData: IAction[] = [
     duration: ["join"],
     after: [],
   },
+  {
+    mode: ["open"],
+    status: "invited",
+    signUp: ["agree", "disagree"],
+    before: ["agree", "disagree"],
+    duration: [],
+    after: [],
+  },
   {
     mode: ["manual", "invite"],
     status: "none",

+ 33 - 31
dashboard/src/components/course/Status.tsx

@@ -10,9 +10,9 @@ import { Link } from "react-router-dom";
 
 import { get } from "../../request";
 import {
+  ICourseDataResponse,
   ICourseMemberData,
   ICourseMemberResponse,
-  TCourseJoinMode,
   TCourseMemberStatus,
 } from "../api/Course";
 
@@ -21,39 +21,32 @@ import { currentUser } from "../../reducers/current-user";
 import UserAction from "./UserAction";
 import { getStatusColor, getStudentActionsByStatus } from "./RolePower";
 
-const { Paragraph } = Typography;
+const { Paragraph, Text } = Typography;
 
 interface IWidget {
-  courseId?: string;
-  courseName?: string;
-  startAt?: string;
-  endAt?: string;
-  signUpStartAt?: string;
-  signUpEndAt?: string;
-  joinMode?: TCourseJoinMode;
+  data?: ICourseDataResponse;
 }
-const StatusWidget = ({
-  courseId,
-  courseName,
-  startAt,
-  endAt,
-  signUpStartAt,
-  signUpEndAt,
-  joinMode,
-}: IWidget) => {
+const StatusWidget = ({ data }: IWidget) => {
   const intl = useIntl();
   const [currMember, setCurrMember] = useState<ICourseMemberData>();
   const user = useAppSelector(currentUser);
 
-  console.debug("course status", signUpStartAt, signUpEndAt);
+  const numberOfStudent = data?.members?.filter(
+    (value) =>
+      value.role === "student" &&
+      (value.status === "accepted" ||
+        value.status === "applied" ||
+        value.status === "joined")
+  ).length;
+
   useEffect(() => {
     /**
      * 获取该课程我的报名状态
      */
-    if (typeof courseId === "undefined") {
+    if (typeof data?.id === "undefined") {
       return;
     }
-    const url = `/v2/course-member/${courseId}`;
+    const url = `/v2/course-member/${data?.id}`;
     console.info("api request", url);
     get<ICourseMemberResponse>(url).then((json) => {
       console.debug("course member", json);
@@ -61,7 +54,7 @@ const StatusWidget = ({
         setCurrMember(json.data);
       }
     });
-  }, [courseId]);
+  }, [data?.id]);
 
   let labelStatus = "";
 
@@ -73,13 +66,13 @@ const StatusWidget = ({
   }
   const actions = getStudentActionsByStatus(
     currStatus,
-    joinMode,
-    startAt,
-    endAt,
-    signUpStartAt,
-    signUpEndAt
+    data?.join,
+    data?.start_at,
+    data?.end_at,
+    data?.sign_up_start_at,
+    data?.sign_up_end_at
   );
-  console.debug("getStudentActionsByStatus", currStatus, joinMode, actions);
+  console.debug("getStudentActionsByStatus", currStatus, data?.join, actions);
   if (user) {
     labelStatus = intl.formatMessage({
       id: `course.member.status.${currStatus}.label`,
@@ -87,13 +80,22 @@ const StatusWidget = ({
     operation = (
       <Space>
         {actions?.map((item, id) => {
+          if (item === "apply" && data?.number !== 0) {
+            if (
+              numberOfStudent &&
+              data?.number &&
+              numberOfStudent >= data?.number
+            ) {
+              return <Text type="danger">{"名额已满"}</Text>;
+            }
+          }
           return (
             <UserAction
               key={id}
               action={item}
               currUser={currMember}
-              courseId={courseId}
-              courseName={courseName}
+              courseId={data?.id}
+              courseName={data?.title}
               user={{
                 id: user.id,
                 nickName: user.nickName,
@@ -117,7 +119,7 @@ const StatusWidget = ({
     );
   }
 
-  return courseId ? (
+  return data?.id ? (
     <Paragraph>
       <div style={{ color: getStatusColor(currStatus) }}>{labelStatus}</div>
       {operation}

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

@@ -27,8 +27,6 @@ const DiscussionButton = ({
   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;

+ 24 - 14
dashboard/src/components/general/NissayaCard.tsx

@@ -11,6 +11,7 @@ import { Link } from "react-router-dom";
 import TermModal from "../term/TermModal";
 import { ITermDataResponse } from "../api/Term";
 import { useIntl } from "react-intl";
+import MdView from "../template/MdView";
 
 const { Paragraph, Title } = Typography;
 
@@ -22,7 +23,7 @@ export const NissayaCardPop = ({ text, trigger }: INissayaCardModal) => {
   return (
     <Popover
       style={{ width: 600 }}
-      content={<NissayaCardWidget text={text} cache={true} />}
+      content={<NissayaCardWidget text={text} cache={true} hideEditButton />}
       placement="bottom"
     >
       <Typography.Link>{trigger}</Typography.Link>
@@ -75,8 +76,13 @@ interface INissayaCardResponse {
 interface IWidget {
   text?: string;
   cache?: boolean;
+  hideEditButton?: boolean;
 }
-const NissayaCardWidget = ({ text, cache = false }: IWidget) => {
+const NissayaCardWidget = ({
+  text,
+  cache = false,
+  hideEditButton = false,
+}: IWidget) => {
   const intl = useIntl();
   const [cardData, setCardData] = useState<INissayaRelation[]>();
   const [term, setTerm] = useState<ITerm>();
@@ -133,19 +139,23 @@ const NissayaCardWidget = ({ text, cache = false }: IWidget) => {
       <div style={{ display: "flex", justifyContent: "space-between" }}>
         <Title level={4}>
           {term?.word}
-          <TermModal
-            id={term?.id}
-            onUpdate={(value: ITermDataResponse) => {
-              //onModalClose();
-            }}
-            onClose={() => {
-              //onModalClose();
-            }}
-            trigger={<Button type="link" icon={<EditOutlined />} />}
-          />
+          {hideEditButton ? (
+            <></>
+          ) : (
+            <TermModal
+              id={term?.id}
+              onUpdate={(value: ITermDataResponse) => {
+                //onModalClose();
+              }}
+              onClose={() => {
+                //onModalClose();
+              }}
+              trigger={<Button type="link" icon={<EditOutlined />} />}
+            />
+          )}
         </Title>
         <div>
-          <Link to={`/nissaya/ending/${term?.word}`}>
+          <Link to={`/nissaya/ending/${term?.word}`} target="_blank">
             {intl.formatMessage({
               id: "buttons.open.in.new.tab",
             })}
@@ -158,7 +168,7 @@ const NissayaCardWidget = ({ text, cache = false }: IWidget) => {
         </div>
       </div>
       <Paragraph>{term?.meaning}</Paragraph>
-      <Paragraph>{term?.note}</Paragraph>
+      <MdView html={term?.html} />
       {cardData ? <NissayaCardTable data={cardData} /> : undefined}
     </div>
   );

+ 5 - 16
dashboard/src/components/home/CourseNewList.tsx

@@ -3,31 +3,20 @@ import { useNavigate } from "react-router-dom";
 import { useEffect, useState } from "react";
 import { Card, List, message, Typography } from "antd";
 
-import { ICourse } from "../../pages/library/course/course";
-import { ICourseListResponse } from "../api/Course";
+import { ICourseDataResponse, ICourseListResponse } from "../api/Course";
 import { API_HOST, get } from "../../request";
 
 const { Paragraph } = Typography;
 
 const CourseNewListWidget = () => {
-  const [data, setData] = useState<ICourse[]>();
+  const [data, setData] = useState<ICourseDataResponse[]>();
   const navigate = useNavigate();
 
   useEffect(() => {
     get<ICourseListResponse>(`/v2/course?view=new&limit=4`).then((json) => {
       if (json.ok) {
         console.log(json.data);
-        const course: ICourse[] = json.data.rows.map((item) => {
-          return {
-            id: item.id,
-            title: item.title,
-            subtitle: item.subtitle,
-            teacher: item.teacher,
-            intro: item.content,
-            coverUrl: item.cover_url,
-          };
-        });
-        setData(course);
+        setData(json.data.rows);
       } else {
         message.error(json.message);
       }
@@ -51,7 +40,7 @@ const CourseNewListWidget = () => {
               <div style={{ flex: 3 }}>
                 <img
                   alt="example"
-                  src={API_HOST + "/" + item.coverUrl}
+                  src={API_HOST + "/" + item.cover_url}
                   width="150"
                   height="150"
                 />
@@ -59,7 +48,7 @@ const CourseNewListWidget = () => {
               <div style={{ flex: 7 }}>
                 <h3>{item.title}</h3>
                 <Paragraph ellipsis={{ rows: 2, expandable: false }}>
-                  {item.intro}
+                  {item.summary}
                 </Paragraph>
               </div>
             </div>

+ 59 - 8
dashboard/src/components/template/Wbw/WbwDetail.tsx

@@ -1,7 +1,20 @@
 import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
-import { Dropdown, Tabs, Divider, Button, Switch, Rate } from "antd";
-import { SaveOutlined } from "@ant-design/icons";
+import {
+  Dropdown,
+  Tabs,
+  Divider,
+  Button,
+  Switch,
+  Rate,
+  Space,
+  Tooltip,
+} from "antd";
+import {
+  SaveOutlined,
+  VerticalAlignBottomOutlined,
+  VerticalAlignTopOutlined,
+} from "@ant-design/icons";
 
 import { IWbw, IWbwAttachment, IWbwField, TFieldName } from "./WbwWord";
 import WbwDetailBasic from "./WbwDetailBasic";
@@ -15,20 +28,27 @@ import { useAppSelector } from "../../../hooks";
 import { currentUser } from "../../../reducers/current-user";
 import DiscussionButton from "../../discussion/DiscussionButton";
 import { courseUser } from "../../../reducers/course-user";
+import { tempSet } from "../../../reducers/setting";
+import { PopPlacement } from "./WbwPali";
+import store from "../../../store";
 
 interface IWidget {
   data: IWbw;
   visible?: boolean;
+  popIsTop?: boolean;
   onClose?: Function;
   onSave?: Function;
   onAttachmentSelectOpen?: Function;
+  onPopTopChange?: Function;
 }
 const WbwDetailWidget = ({
   data,
   visible = true,
+  popIsTop = false,
   onClose,
   onSave,
   onAttachmentSelectOpen,
+  onPopTopChange,
 }: IWidget) => {
   const intl = useIntl();
   const [currWbwData, setCurrWbwData] = useState<IWbw>(
@@ -108,6 +128,7 @@ const WbwDetailWidget = ({
   }
   return (
     <div
+      className="wbw_detail"
       style={{
         minWidth: 450,
       }}
@@ -116,12 +137,42 @@ const WbwDetailWidget = ({
         size="small"
         type="card"
         tabBarExtraContent={
-          <DiscussionButton
-            initCount={data.hasComment ? 1 : 0}
-            hideCount
-            resId={data.uid}
-            resType="wbw"
-          />
+          <Space>
+            <Tooltip
+              title={popIsTop ? "底端弹窗" : "顶端弹窗"}
+              getTooltipContainer={(node: HTMLElement) =>
+                document.getElementsByClassName("wbw_detail")[0] as HTMLElement
+              }
+            >
+              <Button
+                type="text"
+                icon={
+                  popIsTop ? (
+                    <VerticalAlignBottomOutlined />
+                  ) : (
+                    <VerticalAlignTopOutlined />
+                  )
+                }
+                onClick={() => {
+                  store.dispatch(
+                    tempSet({
+                      key: PopPlacement,
+                      value: !popIsTop,
+                    })
+                  );
+                  if (typeof onPopTopChange !== "undefined") {
+                    //onPopTopChange(popIsTop);
+                  }
+                }}
+              />
+            </Tooltip>
+            <DiscussionButton
+              initCount={data.hasComment ? 1 : 0}
+              hideCount
+              resId={data.uid}
+              resType="wbw"
+            />
+          </Space>
         }
         onChange={(activeKey: string) => {
           setTabKey(activeKey);

+ 3 - 0
dashboard/src/components/template/Wbw/WbwDetailOrder.tsx

@@ -31,6 +31,9 @@ const WbwDetailOrderWidget = ({
     <Tooltip
       open={show}
       placement="right"
+      getTooltipContainer={(node: HTMLElement) =>
+        document.getElementsByClassName("wbw_detail")[0] as HTMLElement
+      }
       title={
         <Button
           type="link"

+ 43 - 2
dashboard/src/components/template/Wbw/WbwPali.tsx

@@ -23,6 +23,10 @@ import { anchor, showWbw } from "../../../reducers/wbw";
 import { ParaLinkCtl } from "../ParaLink";
 import { IStudio } from "../../auth/Studio";
 import WbwPaliDiscussionIcon from "./WbwPaliDiscussionIcon";
+import { TooltipPlacement } from "antd/lib/tooltip";
+import { temp } from "../../../reducers/setting";
+
+export const PopPlacement = "setting.wbw.pop.placement";
 
 //生成视频播放按钮
 interface IVideoIcon {
@@ -65,11 +69,27 @@ const WbwPaliWidget = ({
   onSave,
 }: IWidget) => {
   const [popOpen, setPopOpen] = useState(false);
+  const [popOnTop, setPopOnTop] = useState(false);
+
   const [paliColor, setPaliColor] = useState("unset");
   const divShell = useRef<HTMLDivElement>(null);
   const wbwAnchor = useAppSelector(anchor);
   const addParam = useAppSelector(relationAddParam);
   const wordSn = `${data.book}-${data.para}-${data.sn.join("-")}`;
+  const tempSettings = useAppSelector(temp);
+
+  useEffect(() => {
+    const popSetting = tempSettings?.find(
+      (value) => value.key === PopPlacement
+    );
+    console.debug("PopPlacement change", popSetting);
+    if (popSetting?.value === true) {
+      setPopOnTop(true);
+    } else {
+      setPopOnTop(false);
+    }
+  }, [tempSettings]);
+
   useEffect(() => {
     if (wbwAnchor) {
       if (wbwAnchor.id !== wordSn || wbwAnchor.channel !== channelId) {
@@ -177,6 +197,7 @@ const WbwPaliWidget = ({
     <WbwDetail
       data={data}
       visible={popOpen}
+      popIsTop={popOnTop}
       onClose={() => {
         setPaliColor("unset");
         setPopOpen(false);
@@ -191,6 +212,18 @@ const WbwPaliWidget = ({
       onAttachmentSelectOpen={(open: boolean) => {
         setPopOpen(!open);
       }}
+      onPopTopChange={(value: boolean) => {
+        console.debug(PopPlacement, value);
+        //setPopOnTop(!value);
+        /*
+        store.dispatch(
+          tempSet({
+            key: PopPlacement,
+            value: !value,
+          })
+        );
+        */
+      }}
     />
   );
 
@@ -304,6 +337,13 @@ const WbwPaliWidget = ({
     const divRight = divShell.current?.getBoundingClientRect().right;
     const toDivRight = divRight ? containerWidth - divRight : 0;
 
+    let popPlacement: TooltipPlacement;
+    if (popOnTop) {
+      popPlacement = toDivRight > 200 ? "top" : "topRight";
+    } else {
+      popPlacement = toDivRight > 200 ? "bottom" : "bottomRight";
+    }
+    //console.debug(PopPlacement, popPlacement);
     return (
       <div className="pali_shell" ref={divShell}>
         <span className="pali_shell_spell">
@@ -320,10 +360,10 @@ const WbwPaliWidget = ({
           ) : (
             <></>
           )}
-          {mode === "edit" ? paliWord : ""}
+
           <Popover
             content={wbwDialog}
-            placement={toDivRight > 200 ? "bottom" : "bottomRight"}
+            placement={popPlacement}
             trigger="click"
             open={popOpen}
           >
@@ -347,6 +387,7 @@ const WbwPaliWidget = ({
               )}
             </span>
           </Popover>
+          {mode === "edit" ? paliWord : ""}
         </span>
         <Space>
           <VideoIcon attachments={data.attachments} />

+ 1 - 1
dashboard/src/components/template/Wbw/WbwPaliDiscussionIcon.tsx

@@ -25,7 +25,7 @@ const WbwPaliDiscussionIcon = ({ data, studio }: IWidget) => {
       }
     }
   }
-  console.debug("WbwPaliDiscussionIcon render", studio, data, onlyMe);
+
   return (
     <DiscussionButton
       initCount={data.hasComment ? 1 : 0}

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

@@ -100,6 +100,7 @@
   position: absolute;
   border: 1px solid gray;
   background-color: wheat;
+  margin-top: -1.2em;
 }
 .pali_shell:hover .edit_icon {
   display: inline-block;

+ 1 - 0
dashboard/src/components/term/TermEdit.tsx

@@ -48,6 +48,7 @@ export interface ITerm {
   meaning?: string;
   meaning2?: string[];
   note?: string;
+  html?: string;
   summary?: string;
   summary_is_community?: boolean;
   channelId?: string;

+ 1 - 0
dashboard/src/load.ts

@@ -80,6 +80,7 @@ const init = () => {
       }
     }
   );
+  //获取用户登录信息
   const token = getToken();
   if (token) {
     get<ITokenRefreshResponse>("/v2/auth/current").then((response) => {

+ 6 - 47
dashboard/src/pages/library/course/course.tsx

@@ -5,40 +5,18 @@ import { Divider, message } from "antd";
 
 import CourseIntro from "../../../components/course/CourseIntro";
 import TextBook from "../../../components/course/TextBook";
-import { IUser } from "../../../components/auth/User";
 import { get } from "../../../request";
 import {
+  ICourseDataResponse,
   ICourseResponse,
-  TCourseExpRequest,
-  TCourseJoinMode,
 } from "../../../components/api/Course";
 import CourseHead from "../../../components/course/CourseHead";
 import ArticleSkeleton from "../../../components/article/ArticleSkeleton";
 import ErrorResult from "../../../components/general/ErrorResult";
 
-export interface ICourse {
-  id: string; //课程ID
-  title: string; //标题
-  subtitle?: string; //副标题
-  summary?: string;
-  teacher?: IUser; //UserID
-  privacy?: number; //公开性-公开/内部
-  createdAt?: string; //创建时间
-  updatedAt?: string; //修改时间
-  anthologyId?: string; //文集ID
-  channelId?: string;
-  startAt?: string; //课程开始时间
-  endAt?: string; //课程结束时间
-  signUpStartAt?: string; //报名开始时间
-  signUpEndAt?: string; //报名结束时间
-  intro?: string; //简介
-  coverUrl?: string[]; //封面图片文件名
-  join?: TCourseJoinMode;
-  exp?: TCourseExpRequest;
-}
 const Widget = () => {
   const { id } = useParams(); //url 参数
-  const [courseInfo, setCourseInfo] = useState<ICourse>();
+  const [courseInfo, setCourseInfo] = useState<ICourseDataResponse>();
   const [loading, setLoading] = useState(false);
   const [errorCode, setErrorCode] = useState<number>();
 
@@ -50,26 +28,7 @@ const Widget = () => {
       .then((json) => {
         if (json.ok) {
           console.log("api response", json.data);
-          const course: ICourse = {
-            id: json.data.id,
-            title: json.data.title,
-            subtitle: json.data.subtitle,
-            teacher: json.data.teacher,
-            privacy: json.data.publicity,
-            createdAt: json.data.created_at,
-            updatedAt: json.data.updated_at,
-            anthologyId: json.data.anthology_id,
-            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,
-            exp: json.data.request_exp,
-          };
-          setCourseInfo(course);
+          setCourseInfo(json.data);
         } else {
           message.error(json.message);
         }
@@ -88,12 +47,12 @@ const Widget = () => {
         <ErrorResult code={errorCode} />
       ) : (
         <div>
-          <CourseHead {...courseInfo} />
+          <CourseHead data={courseInfo} />
           <Divider />
-          <CourseIntro {...courseInfo} />
+          <CourseIntro intro={courseInfo?.content} />
           <Divider />
           <TextBook
-            anthologyId={courseInfo?.anthologyId}
+            anthologyId={courseInfo?.anthology_id}
             courseId={courseInfo?.id}
           />
         </div>

+ 2 - 2
dashboard/src/pages/studio/course/edit.tsx

@@ -43,7 +43,7 @@ const Widget = () => {
               label: `成员`,
               children: (
                 <div style={{ display: "flex" }}>
-                  <div style={{ flex: 1 }}>
+                  <div style={{ flex: 3 }}>
                     <CourseMemberList
                       courseId={courseId}
                       onSelect={(value: ICourseMember) => {
@@ -51,7 +51,7 @@ const Widget = () => {
                       }}
                     />
                   </div>
-                  <div style={{ flex: 1 }}>
+                  <div style={{ flex: 2 }}>
                     <Tabs
                       items={[
                         {

+ 20 - 1
dashboard/src/reducers/setting.ts

@@ -9,6 +9,7 @@ export interface ISettingItem {
 
 interface IState {
   settings?: ISettingItem[];
+  temp?: ISettingItem[];
   key?: string;
   value?: string | string[] | number | boolean;
 }
@@ -46,16 +47,34 @@ export const slice = createSlice({
       }
       set(state.settings);
     },
+    tempSet: (state, action: PayloadAction<ISettingItem>) => {
+      //将新的改变放入 settings
+      if (typeof state.temp !== "undefined") {
+        const index = state.temp.findIndex(
+          (element) => element.key === action.payload.key
+        );
+        if (index >= 0) {
+          state.temp[index].value = action.payload.value;
+        } else {
+          state.temp.push(action.payload);
+        }
+      } else {
+        state.temp = [action.payload];
+      }
+    },
   },
 });
 
-export const { refresh, onChange } = slice.actions;
+export const { refresh, onChange, tempSet } = slice.actions;
 
 export const setting = (state: RootState): IState => state.setting;
 
 export const settingInfo = (state: RootState): ISettingItem[] | undefined =>
   state.setting.settings;
 
+export const temp = (state: RootState): ISettingItem[] | undefined =>
+  state.setting.temp;
+
 export const onChangeKey = (state: RootState): string | undefined =>
   state.setting.key;
 export const onChangeValue = (

+ 1 - 1
deploy/roles/mint-v2/templates/v2/env.j2

@@ -53,7 +53,7 @@ MAIL_USERNAME="{{ app_smtp_user }}"
 MAIL_PASSWORD="{{ app_smtp_password }}"
 MAIL_ENCRYPTION=ssl
 MAIL_FROM_ADDRESS="{{ app_smtp_user }}"
-MAIL_FROM_NAME="webmaster"
+MAIL_FROM_NAME="wikipali"
 
 PUSHER_APP_ID=
 PUSHER_APP_KEY=

+ 4 - 0
rpc/tutorials/php/.gitignore

@@ -0,0 +1,4 @@
+/composer.lock
+/vendor/
+/lib/
+/config.yml

+ 9 - 0
rpc/tutorials/php/README.md

@@ -0,0 +1,9 @@
+# USAGE
+
+- install [php-pear](https://aur.archlinux.org/packages/php-pear)
+
+```bash
+# add "extension=yaml" to php.ini
+sudo pecl install yaml
+composer update
+```

+ 7 - 0
rpc/tutorials/php/composer.json

@@ -0,0 +1,7 @@
+{
+  "require": {
+    "php-amqplib/php-amqplib": "^3.2",
+    "apache/thrift": "0.20.0",
+    "monolog/monolog": "3.6.0"
+  }
+}

+ 11 - 0
rpc/tutorials/php/config-orig.yml

@@ -0,0 +1,11 @@
+rabbitmq:
+  host: "127.0.0.1"
+  port: 5672
+  user: "guest"
+  password: "guest"
+  virtual-host: "testing"
+
+lily:
+  tex-to-pdf:
+    queue-name: "tex2pdf"
+    callback-url: "http://localhost:8080/md2pdf/callback"

+ 88 - 0
rpc/tutorials/php/lily.php

@@ -0,0 +1,88 @@
+<?php
+
+require_once __DIR__ . '/vendor/autoload.php';
+require_once __DIR__ . '/lib/lily/Tex.php';
+require_once __DIR__ . '/lib/lily/TexToPdfTask.php';
+
+
+use Monolog\Level;
+use Monolog\Logger;
+use Monolog\Handler\StreamHandler;
+use Thrift\Serializer\TBinarySerializer;
+use PhpAmqpLib\Connection\AMQPStreamConnection;
+use PhpAmqpLib\Message\AMQPMessage;
+
+
+use lily\Tex;
+use lily\TexToPdfTask;
+
+function publish_tex_to_pdf($logger, $channel, $queue, $callback)
+{
+    $task = new TexToPdfTask();
+    $task->bucket = "testing";
+    $task->object = date("Y-m-d") . '.pdf';
+    $task->callback = $callback;
+    $task->tex = new Tex();
+    $task->tex->homepage = <<<TEX
+\\documentclass{article}
+
+\\begin{document}
+
+\\title{The title \\thanks{With footnote}}
+\\auhtor{me}
+\\date{\\today}
+\\maketitle
+
+\\include{vocabulary/session-1.tex}
+\\include{vocabulary/session-2.tex}
+\\include{vocabulary/session-3.tex}
+
+\\end {document}
+TEX;
+    $task->tex->files = array(
+        "section-1.tex" => <<<TEX
+\\section{Session 1}
+TEX,
+        "section-2.tex" => <<<TEX
+\\section{Session 2}        
+
+\\First\\footnote{First note}
+\\Second\\footnote{Second note}
+\\Third\\footnote{Third note}
+TEX,
+        "section-3.tex" => <<<TEX
+\\section{Session 3}
+TEX,
+    );
+
+    $body = TBinarySerializer::serialize($task);
+    $message_id = uniqid();
+    $logger->debug("publish a tex-to-pdf task(" . strlen($body)  . "bytes) " . $message_id);
+    // https://github.com/php-amqplib/php-amqplib/blob/master/doc/AMQPMessage.md
+    $message = new AMQPMessage($body, ['message_id' => $message_id]);
+    $channel->basic_publish($message, '', $queue);
+}
+
+$logger = new Logger('lily');
+$logger->pushHandler(new StreamHandler('php://stdout', Level::Debug));
+$logger->debug("run on debug mode");
+
+if ($argc !== 2) {
+    $logger->error("usage: php $argv[0] config.yml");
+    exit(1);
+}
+$logger->debug("$argc");
+$logger->info("load config from" . $argv[1]);
+$config = yaml_parse_file($argv[1]);
+$logger->debug("connect to rabbitmq://" . $config['rabbitmq']['host'] . "@" . $config['rabbitmq']['host'] . ":" . $config['rabbitmq']['port'] . "/" . $config['rabbitmq']['virtual-host']);
+
+$queue_connection = new AMQPStreamConnection($config['rabbitmq']['host'], $config['rabbitmq']['port'], $config['rabbitmq']['user'], $config['rabbitmq']['password'], $config['rabbitmq']['virtual-host']);
+$queue_channel = $queue_connection->channel();
+
+
+publish_tex_to_pdf($logger, $queue_channel, $config['lily']['tex-to-pdf']['queue-name'], $config['lily']['tex-to-pdf']['callback-url']);
+
+
+$queue_channel->close();
+$queue_connection->close();
+$logger->warning("quit.");