2
0
visuddhinanda 3 жил өмнө
parent
commit
e30cce43cf

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

@@ -1,22 +1,59 @@
 //课程详情图片标题按钮主讲人组合
 import { Link } from "react-router-dom";
-import { Image, Button, Space, Col, Row, Breadcrumb } from "antd";
+import { Image, Space, Col, Row, Breadcrumb } from "antd";
 import { Typography } from "antd";
 import { HomeOutlined } from "@ant-design/icons";
 
 import { IUser } from "../auth/User";
 import { API_HOST } from "../../request";
 import UserName from "../auth/UserName";
+import JoinCourse from "./JoinCourse";
+import { TCourseExpRequest, TCourseJoinMode } from "../api/Course";
+import { useIntl } from "react-intl";
 
-const { Title } = Typography;
+const { Title, Text } = Typography;
 
 interface IWidget {
+  id?: string;
   title?: string;
   subtitle?: string;
   coverUrl?: string;
+  startAt?: string;
+  endAt?: string;
   teacher?: IUser;
+  join?: TCourseJoinMode;
+  exp?: TCourseExpRequest;
 }
-const Widget = ({ title, subtitle, coverUrl, teacher }: IWidget) => {
+const Widget = ({
+  id,
+  title,
+  subtitle,
+  coverUrl,
+  teacher,
+  startAt,
+  endAt,
+  join,
+  exp,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const today = new Date();
+  const courseStart = new Date(startAt ? startAt : 0);
+
+  let labelJoin = "";
+  switch (join) {
+    case "open":
+      labelJoin = "公开课程,开放报名。";
+      break;
+    case "manual":
+      labelJoin = "报名后需要组织者审核。";
+      break;
+    case "invite":
+      labelJoin = "内部邀请课程";
+      break;
+    default:
+      break;
+  }
   return (
     <>
       <Row>
@@ -42,7 +79,21 @@ const Widget = ({ title, subtitle, coverUrl, teacher }: IWidget) => {
               <Space direction="vertical">
                 <Title level={3}>{title}</Title>
                 <Title level={5}>{subtitle}</Title>
-                <Button type="primary">关注</Button>
+
+                <Text>
+                  {startAt}——{endAt}
+                </Text>
+                <Text>
+                  {intl.formatMessage({
+                    id: `course.join.mode.${join}.message`,
+                  })}
+                </Text>
+                <JoinCourse
+                  courseId={id ? id : ""}
+                  expRequest={exp}
+                  joinMode={join}
+                  startAt={startAt}
+                />
               </Space>
             </Space>
             <div>

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

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

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

@@ -0,0 +1,406 @@
+import { useIntl } from "react-intl";
+
+import { Space, Button, Dropdown, Table, Modal } from "antd";
+import { ActionType, ProTable } from "@ant-design/pro-components";
+import {
+  DeleteOutlined,
+  BarChartOutlined,
+  ExclamationCircleFilled,
+} from "@ant-design/icons";
+
+import { delete_, get, put } from "../../request";
+import { ICourseMember } from "./CourseMember";
+import AddMember from "./AddMember";
+import { useRef, useState } from "react";
+import {
+  ICourseMemberData,
+  ICourseMemberDeleteResponse,
+  ICourseMemberListResponse,
+  ICourseMemberResponse,
+  TCourseMemberStatus,
+} from "../api/Course";
+import { ItemType } from "antd/lib/menu/hooks/useItems";
+const { confirm } = Modal;
+
+interface IWidget {
+  studioName?: string;
+  courseId?: string;
+}
+
+const Widget = ({ studioName, courseId }: IWidget) => {
+  const intl = useIntl(); //i18n
+  const [canDelete, setCanDelete] = useState(false);
+  const ref = useRef<ActionType>();
+
+  const ChangeStatus = (
+    id: string,
+    name: string,
+    status: TCourseMemberStatus
+  ) => {
+    confirm({
+      title: (
+        <div>
+          <div>
+            {intl.formatMessage({
+              id: `course.member.status.${status}.message`,
+            })}
+          </div>
+          <div>{name}</div>
+        </div>
+      ),
+      icon: <ExclamationCircleFilled />,
+      onOk() {
+        return put<ICourseMemberData, ICourseMemberResponse>(
+          "/v2/course-member/" + id,
+          {
+            course_id: "",
+            user_id: "",
+            status: status,
+          }
+        )
+          .then((json) => {
+            if (json.ok) {
+              console.log("delete ok");
+              ref.current?.reload();
+            }
+          })
+          .catch(() => console.log("Oops errors!"));
+      },
+    });
+  };
+  return (
+    <>
+      <ProTable<ICourseMember>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.name.label",
+            }),
+            dataIndex: "name",
+            key: "name",
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.role.label",
+            }),
+            dataIndex: "role",
+            key: "role",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: {
+                text: intl.formatMessage({
+                  id: "tables.publicity.all",
+                }),
+                status: "Default",
+              },
+              student: {
+                text: intl.formatMessage({
+                  id: "auth.role.student",
+                }),
+                status: "Default",
+              },
+              assistant: {
+                text: intl.formatMessage({
+                  id: "auth.role.assistant",
+                }),
+                status: "Success",
+              },
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.status.label",
+            }),
+            dataIndex: "status",
+            key: "status",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: {
+                text: intl.formatMessage({
+                  id: "tables.publicity.all",
+                }),
+                status: "Default",
+              },
+              progressing: {
+                text: intl.formatMessage({
+                  id: "course.member.status.progressing.label",
+                }),
+                status: "Processing",
+              },
+              accepted: {
+                text: intl.formatMessage({
+                  id: "course.member.status.accepted.label",
+                }),
+                status: "success",
+              },
+              rejected: {
+                text: intl.formatMessage({
+                  id: "course.member.status.rejected.label",
+                }),
+                status: "warning",
+              },
+              left: {
+                text: intl.formatMessage({
+                  id: "course.member.status.left.label",
+                }),
+                status: "warning",
+              },
+              blocked: {
+                text: intl.formatMessage({
+                  id: "course.member.status.blocked.label",
+                }),
+                status: "warning",
+              },
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "course.exp.start.label",
+            }),
+            dataIndex: "startExp",
+            key: "startExp",
+          },
+          {
+            title: intl.formatMessage({
+              id: "course.exp.current.label",
+            }),
+            dataIndex: "currentExp",
+            key: "currentExp",
+          },
+          {
+            title: intl.formatMessage({
+              id: "course.exp.end.label",
+            }),
+            dataIndex: "endExp",
+            key: "endExp",
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (text, row, index, action) => {
+              let items: ItemType[] = [];
+              switch (row.status) {
+                case "accepted":
+                  items = [
+                    {
+                      key: "exp",
+                      label: "经验值",
+                      icon: <BarChartOutlined />,
+                    },
+                    {
+                      key: "delete",
+                      label: "删除",
+                      icon: <DeleteOutlined />,
+                    },
+                  ];
+                  break;
+                case "progressing":
+                  items = [
+                    {
+                      key: "accept",
+                      label: "接受",
+                      icon: <BarChartOutlined />,
+                    },
+                    {
+                      key: "reject",
+                      label: "拒绝",
+                      icon: <DeleteOutlined />,
+                    },
+                  ];
+                  break;
+                default:
+                  break;
+              }
+
+              return [
+                canDelete ? (
+                  <Dropdown.Button
+                    key={index}
+                    type="link"
+                    menu={{
+                      items,
+                      onClick: (e) => {
+                        console.log("click", e);
+                        switch (e.key) {
+                          case "exp":
+                            break;
+                          case "delete":
+                            confirm({
+                              title: `删除此成员吗?`,
+                              icon: <ExclamationCircleFilled />,
+                              content: "此操作不能恢复",
+                              okType: "danger",
+                              onOk() {
+                                return delete_<ICourseMemberDeleteResponse>(
+                                  "/v2/course-member/" + row.id
+                                )
+                                  .then((json) => {
+                                    if (json.ok) {
+                                      console.log("delete ok");
+                                      ref.current?.reload();
+                                    }
+                                  })
+                                  .catch(() => console.log("Oops errors!"));
+                              },
+                            });
+                            break;
+                          case "accept":
+                            if (row.id && row.name) {
+                              ChangeStatus(row.id, row.name, "accepted");
+                            }
+                            break;
+                          case "reject":
+                            if (row.id && row.name) {
+                              ChangeStatus(row.id, row.name, "rejected");
+                            }
+                            break;
+                          default:
+                            break;
+                        }
+                      },
+                    }}
+                  >
+                    <></>
+                  </Dropdown.Button>
+                ) : (
+                  <></>
+                ),
+              ];
+            },
+          },
+        ]}
+        rowSelection={{
+          // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+          // 注释该行则默认不显示下拉选项
+          selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+        }}
+        tableAlertRender={({
+          selectedRowKeys,
+          selectedRows,
+          onCleanSelected,
+        }) => (
+          <Space size={24}>
+            <span>
+              {intl.formatMessage({ id: "buttons.selected" })}
+              {selectedRowKeys.length}
+              <Button
+                type="link"
+                style={{ marginInlineStart: 8 }}
+                onClick={onCleanSelected}
+              >
+                {intl.formatMessage({
+                  id: "buttons.unselect",
+                })}
+              </Button>
+            </span>
+          </Space>
+        )}
+        tableAlertOptionRender={() => {
+          return (
+            <Space size={16}>
+              <Button type="link">
+                {intl.formatMessage({
+                  id: "buttons.delete.all",
+                })}
+              </Button>
+            </Space>
+          );
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+
+          let url = `/v2/course-member?view=course&id=${courseId}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          if (typeof params.keyword !== "undefined") {
+            url += "&search=" + (params.keyword ? params.keyword : "");
+          }
+          const res = await get<ICourseMemberListResponse>(url);
+          if (res.ok) {
+            console.log(res.data);
+            switch (res.data.role) {
+              case "owner":
+              case "manager":
+              case "assistant":
+                setCanDelete(true);
+                break;
+            }
+            const items: ICourseMember[] = res.data.rows.map((item, id) => {
+              let member: ICourseMember = {
+                sn: id + 1,
+                id: item.id,
+                userId: item.user_id,
+                name: item.user?.nickName,
+                role: item.role,
+                status: item.status,
+                tag: [],
+                image: "",
+              };
+              member.tag.push({
+                title: intl.formatMessage({
+                  id: "forms.fields." + item.role + ".label",
+                }),
+                color: "default",
+              });
+
+              return member;
+            });
+            console.log(items);
+            return {
+              total: res.data.count,
+              succcess: true,
+              data: items,
+            };
+          } else {
+            console.error(res.message);
+            return {
+              total: 0,
+              succcess: false,
+              data: [],
+            };
+          }
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <AddMember
+            courseId={courseId}
+            onCreated={() => {
+              ref.current?.reload();
+            }}
+          />,
+        ]}
+      />
+    </>
+  );
+};
+
+export default Widget;

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

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

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

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

+ 35 - 0
dashboard/src/locales/zh-Hans/course/index.ts

@@ -0,0 +1,35 @@
+const items = {
+  "course.basic.info.label": "基本信息",
+  "course.exp.start.label": "起始经验",
+  "course.exp.end.label": "结束经验",
+  "course.exp.current.label": "当前经验",
+  "course.member.status.normal.label": "已经报名",
+  "course.member.status.progressing.label": "处理中",
+  "course.member.status.accepted.label": "已接受",
+  "course.member.status.accepted.message": "接受报名吗?",
+  "course.member.status.rejected.label": "已拒绝",
+  "course.member.status.rejected.message": "拒绝报名吗?",
+  "course.member.status.left.label": "已退出",
+  "course.member.status.blocked.label": "已屏蔽",
+  "course.member.status.blocked.message": "屏蔽该用户吗?",
+  "course.join.mode.invite.label": "仅限邀请",
+  "course.join.mode.invite.message": "本课程仅限内部邀请。",
+  "course.join.mode.manual.label": "人工审核",
+  "course.join.mode.manual.message":
+    "本课程为报名后人工审核课程。报名后请等待组织者审核。",
+  "course.join.mode.open.label": "开放自学",
+  "course.join.mode.open.message": "本课程为开放课程。加入后可以立即开始学习。",
+  "course.leave.message": "离开后将无法再次报名,您确定要离开吗?",
+  "course.exp.request.none.label": "无需",
+  "course.exp.request.none.message": " ",
+  "course.exp.request.begin-end.label": "起始和结束",
+  "course.exp.request.begin-end.message":
+    "课程组织者需要查看您的课程起始和结束的学习时长。报名意味着您同意。",
+  "course.exp.request.daily.label": "每日",
+  "course.exp.request.daily.message":
+    "课程组织者需要查看您在课程期间的每日学习时长。报名意味着您同意。",
+  "course.table.count.member.title": "成员数",
+  "course.table.count.progressing.title": "待审核",
+};
+
+export default items;