Explorar el Código

Merge pull request #2037 from visuddhinanda/agile

课程报名逻辑使用数据驱动
visuddhinanda hace 2 años
padre
commit
ef2a69dd8a

+ 54 - 9
dashboard/src/components/api/Course.ts

@@ -1,6 +1,6 @@
 import { IStudio } from "../auth/Studio";
 import { IUser } from "../auth/User";
-import { IUserRequest, TRole } from "./Auth";
+import { TRole } from "./Auth";
 
 export interface ICourseListApiResponse {
   article: string;
@@ -48,9 +48,10 @@ export interface ICourseDataResponse {
   cover: string; //封面图片文件名
   cover_url?: string[]; //封面图片文件名
   member_count: number;
-  join: TCourseJoinMode;
+  join: TCourseJoinMode; //报名方式
   request_exp: TCourseExpRequest;
-  my_status: TCourseMemberStatus;
+  my_status?: TCourseMemberStatus;
+  my_status_id?: string;
   count_progressing?: number;
   created_at: string; //创建时间
   updated_at: string; //修改时间
@@ -91,13 +92,56 @@ export interface ICourseNumberResponse {
 }
 
 export type TCourseMemberStatus =
+  | "none" /*无*/
   | "normal" /*开放课程直接加入*/
-  | "invited" /**管理员已经邀请学生加入 */
-  | "sign_up" /**学生已经报名 管理员尚未审核 */
-  | "accepted" /**已经接受 */
-  | "rejected" /**已经拒绝 */
+  | "joined" /*开放课程已经加入*/
+  | "applied" /**学生已经报名 管理员尚未审核 */
+  | "canceled" /**学生取消报名 */
+  | "agreed" /**学生/助教已经接受邀请 */
+  | "disagreed" /**学生/助教已经拒绝邀请 */
   | "left" /**学生自己退出 */
-  | "blocked"; /**学生被管理员屏蔽 */
+  | "invited" /**管理员已经邀请学生加入 */
+  | "revoked" /**管理员撤销邀请 */
+  | "accepted" /**已经被管理员录取 */
+  | "rejected" /**报名已经被管理员拒绝 */
+  | "blocked"; /**被管理员清退 */
+
+export type TCourseMemberAction =
+  | "join" /*加入自学课程*/
+  | "apply" /**学生报名 */
+  | "cancel" /**学生取消报名 */
+  | "agree" /**学生/助教接受邀请 */
+  | "disagree" /**学生/助教拒绝邀请 */
+  | "leave" /**学生/助教自己退出 */
+  | "invite" /**管理员邀请学生加入 */
+  | "revoke" /**管理员撤销邀请 */
+  | "accept" /**管理员录取 */
+  | "reject" /**管理员拒绝 */
+  | "block"; /**管理员清退 */
+
+interface IActionMap {
+  action: TCourseMemberAction;
+  status: TCourseMemberStatus;
+}
+export const actionMap = (action: TCourseMemberAction) => {
+  const data: IActionMap[] = [
+    { action: "join", status: "joined" },
+    { action: "apply", status: "applied" },
+    { action: "cancel", status: "canceled" },
+    { action: "agree", status: "agreed" },
+    { action: "disagree", status: "disagreed" },
+    { action: "leave", status: "left" },
+    { action: "invite", status: "invited" },
+    { action: "revoke", status: "revoked" },
+    { action: "accept", status: "accepted" },
+    { action: "reject", status: "rejected" },
+    { action: "block", status: "blocked" },
+  ];
+
+  const current = data.find((value) => value.action === action);
+  return current?.status;
+};
+
 export interface ICourseMemberData {
   id?: string;
   user_id: string;
@@ -105,7 +149,8 @@ export interface ICourseMemberData {
   channel_id?: string;
   role?: string;
   operating?: "invite" | "sign_up";
-  user?: IUserRequest;
+  user?: IUser;
+  editor?: IUser;
   status?: TCourseMemberStatus;
   created_at?: string;
   updated_at?: string;

+ 0 - 79
dashboard/src/components/course/AcceptCourse.tsx

@@ -1,79 +0,0 @@
-/**
- * 学生接受课程管理员的邀请 参加课程
- */
-import { Button, message, Modal } from "antd";
-import { useIntl } from "react-intl";
-import { ExclamationCircleFilled } from "@ant-design/icons";
-
-import { put } from "../../request";
-import {
-  ICourseMemberData,
-  ICourseMemberResponse,
-  TCourseJoinMode,
-} from "../api/Course";
-
-const { confirm } = Modal;
-
-interface IWidget {
-  joinMode?: TCourseJoinMode;
-  currUser?: ICourseMemberData;
-  onStatusChanged?: Function;
-}
-const AcceptCourseWidget = ({
-  joinMode,
-  currUser,
-  onStatusChanged,
-}: IWidget) => {
-  const intl = useIntl();
-
-  const statusChange = (status: ICourseMemberData | undefined) => {
-    if (typeof onStatusChanged !== "undefined") {
-      onStatusChanged(status);
-    }
-  };
-  return (
-    <>
-      <Button
-        type="primary"
-        onClick={() => {
-          confirm({
-            title: "参加此课程吗?",
-            icon: <ExclamationCircleFilled />,
-            content: intl.formatMessage({
-              id: `course.join.mode.open.message`,
-            }),
-            onOk() {
-              return put<ICourseMemberData, ICourseMemberResponse>(
-                "/v2/course-member/" + currUser?.id,
-                {
-                  user_id: "",
-                  course_id: "",
-                  status: "accepted",
-                }
-              )
-                .then((json) => {
-                  console.log("leave", json);
-                  if (json.ok) {
-                    console.log("accepted", json.data);
-                    statusChange(json.data);
-                    message.success(
-                      intl.formatMessage({ id: "flashes.success" })
-                    );
-                  } else {
-                    message.error(json.message);
-                  }
-                })
-                .catch((error) => {
-                  message.error(error);
-                });
-            },
-          });
-        }}
-      >
-        参加
-      </Button>
-    </>
-  );
-};
-
-export default AcceptCourseWidget;

+ 0 - 79
dashboard/src/components/course/AcceptNotCourse.tsx

@@ -1,79 +0,0 @@
-/**
- * 学生接受课程管理员的邀请 参加课程
- */
-import { Button, message, Modal } from "antd";
-import { useIntl } from "react-intl";
-import { ExclamationCircleFilled } from "@ant-design/icons";
-
-import { put } from "../../request";
-import {
-  ICourseMemberData,
-  ICourseMemberResponse,
-  TCourseJoinMode,
-} from "../api/Course";
-
-const { confirm } = Modal;
-
-interface IWidget {
-  joinMode?: TCourseJoinMode;
-  currUser?: ICourseMemberData;
-  onStatusChanged?: Function;
-}
-const AcceptNotCourseWidget = ({
-  joinMode,
-  currUser,
-  onStatusChanged,
-}: IWidget) => {
-  const intl = useIntl();
-
-  const statusChange = (status: ICourseMemberData | undefined) => {
-    if (typeof onStatusChanged !== "undefined") {
-      onStatusChanged(status);
-    }
-  };
-  return (
-    <>
-      <Button
-        type="default"
-        onClick={() => {
-          confirm({
-            title: "拒绝参加此课程吗?",
-            icon: <ExclamationCircleFilled />,
-            content: intl.formatMessage({
-              id: "course.rejected.message",
-            }),
-            onOk() {
-              return put<ICourseMemberData, ICourseMemberResponse>(
-                "/v2/course-member/" + currUser?.id,
-                {
-                  user_id: "",
-                  course_id: "",
-                  status: "rejected",
-                }
-              )
-                .then((json) => {
-                  console.log("leave", json);
-                  if (json.ok) {
-                    console.log("rejected", json.data);
-                    statusChange(json.data);
-                    message.success(
-                      intl.formatMessage({ id: "flashes.success" })
-                    );
-                  } else {
-                    message.error(json.message);
-                  }
-                })
-                .catch((error) => {
-                  message.error(error);
-                });
-            },
-          });
-        }}
-      >
-        拒绝
-      </Button>
-    </>
-  );
-};
-
-export default AcceptNotCourseWidget;

+ 19 - 10
dashboard/src/components/course/AddMember.tsx

@@ -26,21 +26,27 @@ const AddMemeberWidget = ({ courseId, onCreated }: IWidget) => {
       onFinish={async (values: IFormData) => {
         console.log(values);
         if (typeof courseId !== "undefined") {
-          post<ICourseMemberData, ICourseMemberResponse>("/v2/course-member", {
+          const url = "/v2/course-member";
+
+          const data: ICourseMemberData = {
             user_id: values.userId,
             role: values.role,
             course_id: courseId,
-            operating: "invite",
-          }).then((json) => {
-            console.log("add member", json);
-            if (json.ok) {
-              message.success(intl.formatMessage({ id: "flashes.success" }));
-              setOpen(false);
-              if (typeof onCreated !== "undefined") {
-                onCreated();
+            status: "invited",
+          };
+          console.info("api request", url, data);
+          post<ICourseMemberData, ICourseMemberResponse>(url, data).then(
+            (json) => {
+              console.log("add member", json);
+              if (json.ok) {
+                message.success(intl.formatMessage({ id: "flashes.success" }));
+                setOpen(false);
+                if (typeof onCreated !== "undefined") {
+                  onCreated();
+                }
               }
             }
-          });
+          );
         }
       }}
     >
@@ -88,6 +94,9 @@ const AddMemeberWidget = ({ courseId, onCreated }: IWidget) => {
             assistant: intl.formatMessage({
               id: "forms.fields.assistant.label",
             }),
+            manager: intl.formatMessage({
+              id: "auth.role.manager",
+            }),
           }}
         />
       </ProForm.Group>

+ 4 - 2
dashboard/src/components/course/CourseCreate.tsx

@@ -31,11 +31,13 @@ const CourseCreateWidget = ({ studio = "", onCreate }: IWidgetCourseCreate) => {
       onFinish={async (values: IFormData) => {
         console.log(values);
         values.studio = studio;
+        const url = `/v2/course`;
+        console.info("CourseCreateWidget api request", url, values);
         const res = await post<ICourseCreateRequest, ICourseResponse>(
-          `/v2/course`,
+          url,
           values
         );
-        console.log(res);
+        console.debug("CourseCreateWidget api response", res);
         if (res.ok) {
           message.success(intl.formatMessage({ id: "flashes.success" }));
           formRef.current?.resetFields(["title"]);

+ 27 - 11
dashboard/src/components/course/CourseHead.tsx

@@ -7,12 +7,25 @@ import { HomeOutlined } from "@ant-design/icons";
 import { IUser } from "../auth/User";
 import { API_HOST } from "../../request";
 import UserName from "../auth/UserName";
-import { TCourseExpRequest, TCourseJoinMode } from "../api/Course";
+import { TCourseJoinMode } from "../api/Course";
 import { useIntl } from "react-intl";
 import Status from "./Status";
+import moment from "moment";
 
 const { Title, Text } = Typography;
 
+const courseDuration = (startAt?: string, endAt?: string) => {
+  let labelDuration = "";
+  if (moment().isBefore(startAt)) {
+    labelDuration = "未开始";
+  } else if (moment().isBefore(endAt)) {
+    labelDuration = "进行中";
+  } else {
+    labelDuration = "已经结束";
+  }
+  return labelDuration;
+};
+
 interface IWidget {
   id?: string;
   title?: string;
@@ -22,7 +35,6 @@ interface IWidget {
   endAt?: string;
   teacher?: IUser;
   join?: TCourseJoinMode;
-  exp?: TCourseExpRequest;
 }
 const CourseHeadWidget = ({
   id,
@@ -33,10 +45,9 @@ const CourseHeadWidget = ({
   startAt,
   endAt,
   join,
-  exp,
 }: IWidget) => {
   const intl = useIntl();
-
+  const duration = courseDuration(startAt, endAt);
   return (
     <>
       <Row>
@@ -70,8 +81,10 @@ const CourseHeadWidget = ({
                 <Title level={5}>{subtitle}</Title>
 
                 <Text>
-                  {startAt}——{endAt}
+                  {moment(startAt).format("YYYY-MM-DD")}——
+                  {moment(endAt).format("YYYY-MM-DD")}
                 </Text>
+                <Text>{duration}</Text>
                 <Text>
                   {join
                     ? intl.formatMessage({
@@ -79,12 +92,15 @@ const CourseHeadWidget = ({
                       })
                     : undefined}
                 </Text>
-                <Status
-                  courseId={id ? id : ""}
-                  expRequest={exp}
-                  joinMode={join}
-                  startAt={startAt}
-                />
+                {id ? (
+                  <Status
+                    courseId={id}
+                    courseName={title}
+                    joinMode={join}
+                    startAt={startAt}
+                    endAt={endAt}
+                  />
+                ) : undefined}
               </Space>
             </Space>
 

+ 7 - 23
dashboard/src/components/course/CourseMember.tsx

@@ -11,6 +11,7 @@ import {
   ICourseMemberListResponse,
   TCourseMemberStatus,
 } from "../api/Course";
+import { IUser } from "../auth/User";
 
 const { Content } = Layout;
 
@@ -23,8 +24,9 @@ export interface ICourseMember {
   sn?: number;
   id?: string;
   userId: string;
+  user?: IUser;
   name?: string;
-  tag: IRoleTag[];
+  tag?: IRoleTag[];
   image: string;
   role?: string;
   startExp?: number;
@@ -82,29 +84,18 @@ const CourseMemberWidget = ({ courseId }: IWidget) => {
           if (res.ok) {
             console.log(res.data);
             setMemberCount(res.data.count);
-            switch (res.data.role) {
-              case "owner":
-                setCanDelete(true);
-                break;
-              case "manager":
-                setCanDelete(true);
-                break;
+            if (res.data.role === "owner" || res.data.role === "manager") {
+              setCanDelete(true);
             }
             const items: ICourseMember[] = res.data.rows.map((item, id) => {
               let member: ICourseMember = {
                 id: item.id ? item.id : "",
                 userId: item.user_id,
+                user: item.user,
                 name: item.user?.nickName,
                 tag: [],
                 image: "",
               };
-              member.tag.push({
-                title: intl.formatMessage({
-                  id: "forms.fields." + item.role + ".label",
-                }),
-                color: "default",
-              });
-
               return member;
             });
             console.log(items);
@@ -136,14 +127,7 @@ const CourseMemberWidget = ({ courseId }: IWidget) => {
           },
           subTitle: {
             render: (text, row, index, action) => {
-              const showtag = row.tag.map((item, id) => {
-                return (
-                  <Tag color={item.color} key={id}>
-                    {item.title}
-                  </Tag>
-                );
-              });
-              return <Space size={0}>{showtag}</Space>;
+              return <Tag>{row.role}</Tag>;
             },
           },
           actions: {

+ 158 - 332
dashboard/src/components/course/CourseMemberList.tsx

@@ -1,326 +1,173 @@
 import { useIntl } from "react-intl";
+import { Dropdown, Modal, Tag, message } from "antd";
+import { ActionType, ProList } from "@ant-design/pro-components";
+import { ExclamationCircleFilled } from "@ant-design/icons";
 
-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 { get, put } from "../../request";
 import { ICourseMember } from "./CourseMember";
 import AddMember from "./AddMember";
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 import {
+  ICourseDataResponse,
   ICourseMemberData,
-  ICourseMemberDeleteResponse,
   ICourseMemberListResponse,
   ICourseMemberResponse,
+  ICourseResponse,
+  TCourseMemberAction,
   TCourseMemberStatus,
+  actionMap,
 } from "../api/Course";
 import { ItemType } from "antd/lib/menu/hooks/useItems";
+import User from "../auth/User";
+import { getStatusColor, managerCanDo } from "./RolePower";
+import { ISetStatus, setStatus } from "./UserAction";
 const { confirm } = Modal;
 
 interface IWidget {
-  studioName?: string;
   courseId?: string;
+  onSelect?: Function;
 }
 
-const CourseMemberListWidget = ({ studioName, courseId }: IWidget) => {
+const CourseMemberListWidget = ({ courseId, onSelect }: IWidget) => {
   const intl = useIntl(); //i18n
-  const [canDelete, setCanDelete] = useState(false);
+  const [canManage, setCanManage] = useState(false);
+  const [course, setCourse] = useState<ICourseDataResponse>();
   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,
+  useEffect(() => {
+    if (courseId) {
+      const url = `/v2/course/${courseId}`;
+      console.debug("course url", url);
+      get<ICourseResponse>(url)
+        .then((json) => {
+          console.debug("course data", json.data);
+          if (json.ok) {
+            setCourse(json.data);
           }
-        )
-          .then((json) => {
-            if (json.ok) {
-              console.log("delete ok");
-              ref.current?.reload();
-            }
-          })
-          .catch(() => console.log("Oops errors!"));
-      },
-    });
-  };
+        })
+        .catch((e) => console.error(e));
+    }
+  }, [courseId]);
+
   return (
     <>
-      <ProTable<ICourseMember>
+      <ProList<ICourseMember>
         actionRef={ref}
-        columns={[
-          {
-            title: intl.formatMessage({
-              id: "dict.fields.sn.label",
-            }),
-            dataIndex: "sn",
-            key: "sn",
-            width: 50,
+        search={{
+          filterType: "light",
+        }}
+        onItem={(record: ICourseMember, index: number) => {
+          return {
+            onClick: (event) => {
+              // 点击行
+              if (typeof onSelect !== "undefined") {
+                onSelect(record);
+              }
+            },
+          };
+        }}
+        metas={{
+          title: {
+            dataIndex: "name",
             search: false,
           },
-          {
-            title: intl.formatMessage({
-              id: "forms.fields.name.label",
-            }),
-            dataIndex: "name",
-            key: "name",
+          avatar: {
+            render(dom, entity, index, action, schema) {
+              return <User {...entity.user} showName={false} />;
+            },
+            editable: false,
           },
-          {
-            title: intl.formatMessage({
-              id: "forms.fields.role.label",
-            }),
-            dataIndex: "role",
-            key: "role",
-            width: 100,
+          description: {
+            dataIndex: "desc",
             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,
+          subTitle: {
             search: false,
-            filters: true,
-            onFilter: true,
-            valueEnum: {
-              /**"success","processing","error","default","warning" */
-              all: {
-                text: intl.formatMessage({
-                  id: "tables.publicity.all",
-                }),
-                status: "default",
-              },
-              normal: {
-                text: intl.formatMessage({
-                  id: "course.member.status.normal.label",
-                }),
-                status: "success",
-              },
-              sign_up: {
-                text: intl.formatMessage({
-                  id: "course.member.status.sign_up.label",
-                }),
-                status: "Processing",
-              },
-              invited: {
-                text: intl.formatMessage({
-                  id: "course.member.status.invited.label",
-                }),
-                status: "default",
-              },
-              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: "error",
-              },
-              blocked: {
-                text: intl.formatMessage({
-                  id: "course.member.status.blocked.label",
-                }),
-                status: "error",
-              },
+            render: (
+              dom: React.ReactNode,
+              entity: ICourseMember,
+              index: number
+            ) => {
+              return (
+                <Tag>
+                  {intl.formatMessage({
+                    id: `auth.role.${entity.role}`,
+                  })}
+                </Tag>
+              );
             },
           },
-          {
-            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",
+          actions: {
+            search: false,
             render: (text, row, index, action) => {
-              let items: ItemType[] = [];
-              switch (row.status) {
-                case "accepted":
-                  items = [
-                    {
-                      key: "exp",
-                      label: "查看经验值",
-                      icon: <BarChartOutlined />,
-                    },
-                    {
-                      key: "block",
-                      label: "屏蔽",
-                      icon: <DeleteOutlined />,
-                      danger: true,
-                    },
-                  ];
-                  break;
-                case "sign_up":
-                  items = [
-                    {
-                      key: "accept",
-                      label: "接受",
-                      icon: <BarChartOutlined />,
-                    },
-                    {
-                      key: "reject",
-                      label: "拒绝",
-                      icon: <DeleteOutlined />,
-                      danger: true,
-                    },
-                  ];
-                  break;
-                case "invited":
-                  items = [
-                    {
-                      key: "delete",
-                      label: "删除",
-                      icon: <DeleteOutlined />,
-                      danger: true,
-                    },
-                  ];
-                  break;
-                case "normal":
-                  items = [
-                    {
-                      key: "exp",
-                      label: "查看经验值",
-                      icon: <BarChartOutlined />,
-                    },
-                    {
-                      key: "block",
-                      label: "屏蔽",
-                      icon: <DeleteOutlined />,
-                      danger: true,
-                    },
-                  ];
-                  break;
-                default:
-                  items = [
-                    {
-                      key: "none",
-                      label: "无操作",
-                      disabled: true,
-                    },
-                  ];
-                  break;
-              }
+              const statusColor = getStatusColor(row.status);
+              const actions: TCourseMemberAction[] = [
+                "invite",
+                "revoke",
+                "accept",
+                "reject",
+                "block",
+              ];
+              /*
+
+              const undo = {
+                key: "undo",
+                label: "撤销上次操作",
+                disabled: !canUndo,
+              };
+              */
+              const items: ItemType[] = actions.map((item) => {
+                return {
+                  key: item,
+                  label: intl.formatMessage({
+                    id: `course.member.status.${item}.button`,
+                  }),
+                  disabled: !managerCanDo(
+                    item,
+                    course?.start_at,
+                    course?.end_at,
+                    course?.join,
+                    row.status
+                  ),
+                };
+              });
 
               return [
-                canDelete ? (
+                <span style={{ color: statusColor }}>
+                  {intl.formatMessage({
+                    id: `course.member.status.${row.status}.label`,
+                  })}
+                </span>,
+                canManage ? (
                   <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!"));
+                        console.debug("click", e);
+                        const currAction = e.key as TCourseMemberAction;
+                        if (actions.includes(currAction)) {
+                          const newStatus = actionMap(currAction);
+                          if (newStatus) {
+                            const actionParam: ISetStatus = {
+                              courseMemberId: row.id,
+                              message: intl.formatMessage(
+                                {
+                                  id: `course.member.status.${currAction}.message`,
+                                },
+                                { user: row.user?.nickName }
+                              ),
+                              status: newStatus,
+                              onSuccess: (data: ICourseMemberData) => {
+                                message.success(
+                                  intl.formatMessage({ id: "flashes.success" })
+                                );
+                                ref.current?.reload();
                               },
-                            });
-                            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;
+                            };
+                            setStatus(actionParam);
+                          }
                         }
                       },
                     }}
@@ -333,43 +180,31 @@ const CourseMemberListWidget = ({ studioName, courseId }: IWidget) => {
               ];
             },
           },
-        ]}
-        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>
-          );
+          role: {
+            // 自己扩展的字段,主要用于筛选,不在列表中显示
+            title: "角色",
+            valueType: "select",
+            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",
+              },
+            },
+          },
         }}
         request={async (params = {}, sorter, filter) => {
           console.log(params, sorter, filter);
@@ -382,33 +217,25 @@ const CourseMemberListWidget = ({ studioName, courseId }: IWidget) => {
           if (typeof params.keyword !== "undefined") {
             url += "&search=" + (params.keyword ? params.keyword : "");
           }
+          console.info("api request", url);
           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;
+            console.debug("api response", res.data);
+            if (res.data.role === "owner" || res.data.role === "manager") {
+              setCanManage(true);
             }
             const items: ICourseMember[] = res.data.rows.map((item, id) => {
               let member: ICourseMember = {
                 sn: id + 1,
                 id: item.id,
                 userId: item.user_id,
+                user: item.user,
                 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;
             });
@@ -433,7 +260,6 @@ const CourseMemberListWidget = ({ studioName, courseId }: IWidget) => {
           showQuickJumper: true,
           showSizeChanger: true,
         }}
-        search={false}
         options={{
           search: true,
         }}

+ 132 - 0
dashboard/src/components/course/CourseMemberTimeLine.tsx

@@ -0,0 +1,132 @@
+import { useEffect, useRef } from "react";
+import { useIntl } from "react-intl";
+import { Space, Tag } from "antd";
+import { ActionType, ProList } from "@ant-design/pro-components";
+
+import { get } from "../../request";
+import { ICourseMemberData, ICourseMemberListResponse } from "../api/Course";
+import User from "../auth/User";
+import TimeShow from "../general/TimeShow";
+import { getStatusColor } from "./RolePower";
+
+interface IWidget {
+  courseId?: string;
+  userId?: string;
+}
+
+const CourseMemberTimeLineWidget = ({ courseId, userId }: IWidget) => {
+  const intl = useIntl(); //i18n
+
+  const ref = useRef<ActionType>();
+
+  useEffect(() => {
+    ref.current?.reload();
+  }, [courseId, userId]);
+
+  return (
+    <>
+      <ProList<ICourseMemberData>
+        actionRef={ref}
+        search={{
+          filterType: "light",
+        }}
+        metas={{
+          title: {
+            dataIndex: "name",
+            search: false,
+            render(dom, entity, index, action, schema) {
+              return entity.user?.nickName;
+            },
+          },
+          avatar: {
+            render(dom, entity, index, action, schema) {
+              return <User {...entity.user} showName={false} />;
+            },
+            editable: false,
+          },
+          description: {
+            dataIndex: "desc",
+            search: false,
+            render(dom, entity, index, action, schema) {
+              return (
+                <Space>
+                  <User {...entity.editor} showAvatar={false} />
+                  <TimeShow type="secondary" updatedAt={entity.updated_at} />
+                </Space>
+              );
+            },
+          },
+          subTitle: {
+            search: false,
+            render: (
+              dom: React.ReactNode,
+              entity: ICourseMemberData,
+              index: number
+            ) => {
+              return (
+                <Tag>
+                  {intl.formatMessage({
+                    id: `auth.role.${entity.role}`,
+                  })}
+                </Tag>
+              );
+            },
+          },
+          actions: {
+            search: false,
+            render: (text, row, index, action) => {
+              const statusColor = getStatusColor(row.status);
+              return [
+                <span style={{ color: statusColor }}>
+                  {intl.formatMessage({
+                    id: `course.member.status.${row.status}.label`,
+                  })}
+                </span>,
+              ];
+            },
+          },
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+
+          let url = `/v2/course-member?view=timeline&course=${courseId}&userId=${userId}`;
+          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 : "");
+          }
+          console.info("api request", url);
+          const res = await get<ICourseMemberListResponse>(url);
+          if (res.ok) {
+            console.debug("api response", res.data);
+            return {
+              total: res.data.count,
+              succcess: true,
+              data: res.data.rows,
+            };
+          } else {
+            console.error(res.message);
+            return {
+              total: 0,
+              succcess: false,
+              data: [],
+            };
+          }
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        options={{
+          search: false,
+        }}
+      />
+    </>
+  );
+};
+
+export default CourseMemberTimeLineWidget;

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

@@ -1,177 +0,0 @@
-/**
- * 报名按钮
- * 已经报名显示报名状态
- * 未报名显示报名按钮以及必要的提示
- */
-import { Button, message, Modal, Space, Typography } from "antd";
-import { useEffect, useState } from "react";
-import { 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";
-import AcceptCourse from "./AcceptCourse";
-import AcceptNotCourse from "./AcceptNotCourse";
-
-const { confirm } = Modal;
-const { Text } = Typography;
-
-interface IWidget {
-  courseId: string;
-  startAt?: string;
-  joinMode?: TCourseJoinMode;
-  expRequest?: TCourseExpRequest;
-}
-const JoinCourseWidget = ({
-  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 === "sign_up") {
-      button = (
-        <LeaveCourse
-          joinMode={joinMode}
-          currUser={currMember}
-          onStatusChanged={() => {
-            loadStatus();
-          }}
-        />
-      );
-    } else if (currMember.status === "invited") {
-      button = (
-        <Space>
-          <AcceptCourse
-            joinMode={joinMode}
-            currUser={currMember}
-            onStatusChanged={() => {
-              loadStatus();
-            }}
-          />
-          <AcceptNotCourse
-            joinMode={joinMode}
-            currUser={currMember}
-            onStatusChanged={() => {
-              loadStatus();
-            }}
-          />
-        </Space>
-      );
-    }
-  } 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 : "",
-                    operating: "sign_up",
-                  }
-                )
-                  .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>
-      <Text>{labelStatus}</Text>
-      {button}
-    </div>
-  );
-};
-
-export default JoinCourseWidget;

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

@@ -1,119 +0,0 @@
-import { Button, message, Modal, Typography } from "antd";
-import { useIntl } from "react-intl";
-import { ExclamationCircleFilled } from "@ant-design/icons";
-
-import { delete_, put } from "../../request";
-import {
-  ICourseMemberData,
-  ICourseMemberDeleteResponse,
-  ICourseMemberResponse,
-  TCourseJoinMode,
-} from "../api/Course";
-
-const { confirm } = Modal;
-const { Text } = Typography;
-
-interface IWidget {
-  joinMode?: TCourseJoinMode;
-  currUser?: ICourseMemberData;
-  onStatusChanged?: Function;
-}
-const LeaveCourseWidget = ({
-  joinMode,
-  currUser,
-  onStatusChanged,
-}: IWidget) => {
-  const intl = useIntl();
-  console.log("user info", currUser);
-  /**
-   * 离开课程业务逻辑
-   * open 直接删除记录
-   * manual,invite
-   *  sign_up 直接删除记录
-   *  其他        设置为 left
-   */
-  let isDelete = false;
-  if (joinMode === "open") {
-    if (currUser?.status === "normal") {
-      isDelete = true;
-    }
-  } else if (currUser?.status === "sign_up") {
-    isDelete = true;
-  }
-  const statusChange = (status: ICourseMemberData | undefined) => {
-    if (typeof onStatusChanged !== "undefined") {
-      onStatusChanged(status);
-    }
-  };
-  return (
-    <>
-      <Button
-        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(undefined);
-                        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(json.data);
-                        message.success(
-                          intl.formatMessage({ id: "flashes.success" })
-                        );
-                      } else {
-                        message.error(json.message);
-                      }
-                    })
-                    .catch((error) => {
-                      message.error(error);
-                    });
-            },
-          });
-        }}
-      >
-        退出
-      </Button>
-    </>
-  );
-};
-
-export default LeaveCourseWidget;

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

@@ -0,0 +1,324 @@
+import moment from "moment";
+import {
+  TCourseJoinMode,
+  TCourseMemberAction,
+  TCourseMemberStatus,
+} from "../api/Course";
+
+export interface IAction {
+  mode: TCourseJoinMode[];
+  status: TCourseMemberStatus;
+  before: TCourseMemberAction[];
+  duration: TCourseMemberAction[];
+  after: TCourseMemberAction[];
+}
+
+export const getStudentActionsByStatus = (
+  status?: TCourseMemberStatus,
+  mode?: TCourseJoinMode,
+  startAt?: string,
+  endAt?: string
+): TCourseMemberAction[] | undefined => {
+  const output = getActionsByStatus(studentData, status, mode, startAt, endAt);
+  return output;
+};
+const getActionsByStatus = (
+  data: IAction[],
+  status?: TCourseMemberStatus,
+  mode?: TCourseJoinMode,
+  startAt?: string,
+  endAt?: string
+): TCourseMemberAction[] | undefined => {
+  if (!startAt || !endAt || !mode || !status) {
+    return undefined;
+  }
+  const actions = data.find((value) => {
+    if (value.mode.includes(mode) && value.status === status) {
+      if (moment().isBefore(moment(startAt))) {
+        if (value.before && value.before.length > 0) {
+          return true;
+        }
+      } else if (moment().isBefore(moment(endAt))) {
+        if (value.duration && value.duration.length > 0) {
+          return true;
+        }
+      } else {
+        if (value.after && value.after.length > 0) {
+          return true;
+        }
+      }
+    }
+    return undefined;
+  });
+
+  if (actions) {
+    if (moment().isBefore(moment(startAt))) {
+      return actions.before;
+    } else if (moment().isBefore(moment(endAt))) {
+      return actions.duration;
+    } else {
+      return actions.after;
+    }
+  } else {
+    return undefined;
+  }
+};
+
+export const test = (
+  data: IAction[],
+  action: TCourseMemberAction,
+  startAt?: string,
+  endAt?: string,
+  mode?: TCourseJoinMode,
+  status?: TCourseMemberStatus
+): boolean => {
+  if (!startAt || !endAt || !mode || !status) {
+    return false;
+  }
+  const canDo = getActionsByStatus(
+    data,
+    status,
+    mode,
+    startAt,
+    endAt
+  )?.includes(action);
+
+  if (canDo) {
+    return true;
+  } else {
+    return false;
+  }
+};
+
+export const managerCanDo = (
+  action: TCourseMemberAction,
+  startAt?: string,
+  endAt?: string,
+  mode?: TCourseJoinMode,
+  status?: TCourseMemberStatus
+): boolean => {
+  if (!startAt || !endAt || !mode || !status) {
+    return false;
+  }
+
+  return test(managerData, action, startAt, endAt, mode, status);
+};
+
+export const studentCanDo = (
+  action: TCourseMemberAction,
+  startAt?: string,
+  endAt?: string,
+  mode?: TCourseJoinMode,
+  status?: TCourseMemberStatus
+): boolean => {
+  if (!startAt || !endAt || !mode || !status) {
+    return false;
+  }
+
+  return test(studentData, action, startAt, endAt, mode, status);
+};
+
+interface IStatusColor {
+  status: TCourseMemberStatus;
+  color: string;
+}
+export const getStatusColor = (status?: TCourseMemberStatus): string => {
+  let color = "unset";
+  const setting: IStatusColor[] = [
+    { status: "applied", color: "blue" },
+    { status: "invited", color: "blue" },
+    { status: "accepted", color: "green" },
+    { status: "agreed", color: "green" },
+    { status: "rejected", color: "orange" },
+    { status: "disagreed", color: "red" },
+    { status: "left", color: "red" },
+    { status: "blocked", color: "orange" },
+  ];
+  const CourseStatusColor = setting.find((value) => value.status === status);
+
+  if (CourseStatusColor) {
+    color = CourseStatusColor.color;
+  }
+  return color;
+};
+
+const studentData: IAction[] = [
+  {
+    mode: ["open"],
+    status: "none",
+    before: [],
+    duration: ["join"],
+    after: [],
+  },
+  {
+    mode: ["open"],
+    status: "applied",
+    before: ["leave"],
+    duration: ["leave"],
+    after: [],
+  },
+  {
+    mode: ["open"],
+    status: "joined",
+    before: ["leave"],
+    duration: ["leave"],
+    after: [],
+  },
+  {
+    mode: ["open"],
+    status: "left",
+    before: [],
+    duration: ["join"],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "none",
+    before: ["apply"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "invited",
+    before: ["agree", "disagree"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "revoked",
+    before: ["apply"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "accepted",
+    before: ["leave"],
+    duration: ["leave"],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "rejected",
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "blocked",
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "applied",
+    before: ["cancel"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "canceled",
+    before: ["apply"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "agreed",
+    before: ["leave"],
+    duration: ["leave"],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "disagreed",
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "left",
+    before: [],
+    duration: [],
+    after: [],
+  },
+];
+
+const managerData: IAction[] = [
+  {
+    mode: ["manual", "invite"],
+    status: "none",
+    before: ["invite"],
+    duration: ["invite"],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "invited",
+    before: ["revoke"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "revoked",
+    before: ["invite"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "accepted",
+    before: [],
+    duration: ["block"],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "rejected",
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "blocked",
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "applied",
+    before: ["accept", "reject"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "agreed",
+    before: [],
+    duration: ["block"],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "disagreed",
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "left",
+    before: [],
+    duration: [],
+    after: [],
+  },
+];

+ 66 - 107
dashboard/src/components/course/Status.tsx

@@ -6,153 +6,112 @@
 import { Space, Typography } from "antd";
 import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
+import { Link } from "react-router-dom";
 
 import { get } from "../../request";
 import {
   ICourseMemberData,
-  ICourseMemberListResponse,
-  TCourseExpRequest,
+  ICourseMemberResponse,
   TCourseJoinMode,
+  TCourseMemberStatus,
 } from "../api/Course";
-import AcceptCourse from "./AcceptCourse";
-import AcceptNotCourse from "./AcceptNotCourse";
-import LeaveCourse from "./LeaveCourse";
-import SignUp from "./SignUp";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import UserAction from "./UserAction";
+import { getStatusColor, getStudentActionsByStatus } from "./RolePower";
 
 const { Paragraph } = Typography;
 
 interface IWidget {
   courseId: string;
+  courseName?: string;
   startAt?: string;
+  endAt?: string;
   joinMode?: TCourseJoinMode;
-  expRequest?: TCourseExpRequest;
 }
-const StatusWidget = ({ courseId, joinMode, startAt, expRequest }: IWidget) => {
+const StatusWidget = ({
+  courseId,
+  courseName,
+  joinMode,
+  startAt,
+  endAt,
+}: IWidget) => {
   const intl = useIntl();
   const [currMember, setCurrMember] = useState<ICourseMemberData>();
-
-  const today = new Date();
-  const courseStart = new Date(startAt ? startAt : "3000-01-01");
+  const user = useAppSelector(currentUser);
 
   useEffect(() => {
     /**
      * 获取该课程我的报名状态
      */
-    const url = `/v2/course-member?view=user&course=${courseId}`;
-    console.log(url);
-    get<ICourseMemberListResponse>(url).then((json) => {
-      console.log("course member", json);
+    const url = `/v2/course-member/${courseId}`;
+    console.info("api request", url);
+    get<ICourseMemberResponse>(url).then((json) => {
+      console.debug("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);
-          }
-        }
+        setCurrMember(json.data);
       }
     });
   }, [courseId]);
 
   let labelStatus = "";
+
   let operation: React.ReactNode | undefined;
-  if (currMember?.role === "student" || currMember?.role === "assistant") {
+
+  let currStatus: TCourseMemberStatus = "none";
+  if (currMember?.status) {
+    currStatus = currMember.status;
+  }
+  const actions = getStudentActionsByStatus(
+    currStatus,
+    joinMode,
+    startAt,
+    endAt
+  );
+  console.debug("getStudentActionsByStatus", currStatus, actions);
+  if (user) {
     labelStatus = intl.formatMessage({
-      id: `course.member.status.${currMember.status}.label`,
+      id: `course.member.status.${currStatus}.label`,
     });
-    switch (currMember.status) {
-      case "normal":
-        operation = (
-          <Space>
-            <LeaveCourse
-              joinMode={joinMode}
+    operation = (
+      <Space>
+        {actions?.map((item, id) => {
+          return (
+            <UserAction
+              key={id}
+              action={item}
               currUser={currMember}
-              onStatusChanged={(status: ICourseMemberData | undefined) => {
-                setCurrMember(status);
-              }}
-            />
-          </Space>
-        );
-        break;
-      case "sign_up":
-        operation = (
-          <Space>
-            <LeaveCourse
-              joinMode={joinMode}
-              currUser={currMember}
-              onStatusChanged={(status: ICourseMemberData | undefined) => {
-                setCurrMember(status);
-              }}
-            />
-          </Space>
-        );
-        break;
-      case "invited":
-        operation = (
-          <Space>
-            <AcceptCourse
-              joinMode={joinMode}
-              currUser={currMember}
-              onStatusChanged={(status: ICourseMemberData | undefined) => {
-                setCurrMember(status);
-              }}
-            />
-            <AcceptNotCourse
-              joinMode={joinMode}
-              currUser={currMember}
-              onStatusChanged={(status: ICourseMemberData | undefined) => {
-                setCurrMember(status);
+              courseId={courseId}
+              courseName={courseName}
+              user={{
+                id: user.id,
+                nickName: user.nickName,
+                userName: user.realName,
               }}
-            />
-          </Space>
-        );
-        break;
-      case "accepted":
-        operation = (
-          <Space>
-            <LeaveCourse
-              joinMode={joinMode}
-              currUser={currMember}
               onStatusChanged={(status: ICourseMemberData | undefined) => {
                 setCurrMember(status);
               }}
             />
-          </Space>
-        );
-        break;
-      case "rejected":
-        break;
-      case "blocked":
-        break;
-      case "left":
-        break;
-    }
+          );
+        })}
+      </Space>
+    );
   } else {
-    if (courseStart < today) {
-      labelStatus = "已经过期";
-    } else {
-      if (joinMode === "manual" || joinMode === "open") {
-        labelStatus = "可报名";
-        operation = (
-          <Space>
-            <SignUp
-              courseId={courseId}
-              joinMode={joinMode}
-              expRequest={expRequest}
-              onStatusChanged={(status: ICourseMemberData | undefined) => {
-                setCurrMember(status);
-              }}
-            />
-          </Space>
-        );
-      }
-    }
+    //未登录
+    labelStatus = "未登录";
+    operation = (
+      <Link to="/anonymous/users/sign-in" target="_blank">
+        {"登录"}
+      </Link>
+    );
   }
+
   return (
-    <div>
-      <Paragraph>{labelStatus}</Paragraph>
+    <Paragraph>
+      <div style={{ color: getStatusColor(currStatus) }}>{labelStatus}</div>
       {operation}
-    </div>
+    </Paragraph>
   );
 };
 

+ 175 - 0
dashboard/src/components/course/UserAction.tsx

@@ -0,0 +1,175 @@
+/**
+ * 学生接受课程管理员的邀请 参加课程
+ */
+import { Button, Modal } from "antd";
+import { useIntl } from "react-intl";
+import { ExclamationCircleFilled } from "@ant-design/icons";
+
+import {
+  ICourseMemberData,
+  ICourseMemberResponse,
+  TCourseMemberAction,
+  TCourseMemberStatus,
+  actionMap,
+} from "../api/Course";
+
+import { IUser } from "../auth/User";
+import { post, put } from "../../request";
+
+export interface ISetStatus {
+  courseMemberId?: string;
+  courseId?: string;
+  courseName?: string;
+  user?: IUser;
+  message?: string;
+  status: TCourseMemberStatus;
+  onSuccess?: Function;
+  onError?: Function;
+}
+
+const statusQuery = ({
+  courseMemberId,
+  courseId,
+  user,
+  status,
+}: ISetStatus) => {
+  let url = "/v2/course-member/";
+  let data: ICourseMemberData;
+  if (courseMemberId) {
+    //修改现有数据
+    url += courseMemberId;
+    data = {
+      user_id: "",
+      course_id: "",
+      status: status,
+    };
+    console.info("api request", url, data);
+    return put<ICourseMemberData, ICourseMemberResponse>(url, data);
+  } else {
+    //新增数据
+    data = {
+      user_id: user?.id ? user?.id : "",
+      role: "student",
+      course_id: courseId ? courseId : "",
+      status: status,
+    };
+    console.info("api request", url, data);
+    return post<ICourseMemberData, ICourseMemberResponse>(url, data);
+  }
+};
+export const setStatus = ({
+  status,
+  courseMemberId,
+  courseId,
+  user,
+  message,
+  onSuccess,
+  onError,
+}: ISetStatus) => {
+  Modal.confirm({
+    icon: <ExclamationCircleFilled />,
+    content: message,
+    onOk() {
+      const query: ISetStatus = {
+        status: status,
+        courseMemberId: courseMemberId,
+        courseId: courseId,
+        user: user,
+      };
+      return statusQuery(query)
+        .then((json) => {
+          console.debug("AcceptCourse api response", json);
+          if (json.ok) {
+            console.debug("accepted", json.data);
+            if (typeof onSuccess !== "undefined") {
+              onSuccess(json.data);
+            }
+          } else {
+            if (typeof onError !== "undefined") {
+              onError(json.message);
+            }
+          }
+        })
+        .catch((error) => {
+          console.error(error);
+          if (typeof onError !== "undefined") {
+            onError(error);
+          }
+        });
+    },
+  });
+};
+
+interface IWidget {
+  action: TCourseMemberAction;
+  currUser?: ICourseMemberData;
+  courseId?: string;
+  courseName?: string;
+  user?: IUser;
+  onStatusChanged?: Function;
+}
+const UserActionWidget = ({
+  action,
+  currUser,
+  courseId,
+  courseName,
+  user,
+  onStatusChanged,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const statusChange = (status: ICourseMemberData | undefined) => {
+    if (typeof onStatusChanged !== "undefined") {
+      onStatusChanged(status);
+    }
+  };
+  const status = actionMap(action);
+  let buttonDisable: boolean;
+  if (!currUser?.id && !(courseId && user)) {
+    buttonDisable = true;
+  } else {
+    buttonDisable = false;
+  }
+  return (
+    <>
+      {status ? (
+        <Button
+          disabled={buttonDisable}
+          type={
+            action === "join" || action === "apply" || action === "agree"
+              ? "primary"
+              : undefined
+          }
+          danger={action === "disagree" || action === "leave" ? true : false}
+          onClick={() => {
+            console.debug("currUser", currUser);
+            const actionParam: ISetStatus = {
+              courseMemberId: currUser?.id,
+              courseId: courseId,
+              user: user,
+              message: intl.formatMessage(
+                {
+                  id: `course.member.status.${action}.message`,
+                },
+                { course: courseName }
+              ),
+              status: status,
+              onSuccess: (data: ICourseMemberData) => {
+                statusChange(data);
+              },
+            };
+            setStatus(actionParam);
+          }}
+        >
+          {intl.formatMessage({
+            id: `course.member.status.${action}.button`,
+          })}
+        </Button>
+      ) : (
+        <></>
+      )}
+    </>
+  );
+};
+
+export default UserActionWidget;

+ 133 - 0
dashboard/src/components/course/readme.md

@@ -0,0 +1,133 @@
+# 公开性
+
+| 报名方式 | 公开性          |
+| -------- | --------------- |
+| 自学     | 公开            |
+| 需审核   | 公开/不公开列出 |
+| 仅邀请   | 不公开          |
+
+# 可能的操作和状态
+
+| 学员               | 状态           | 管理员           | 状态          |
+| ------------------ | -------------- | ---------------- | ------------- |
+| 加入自学课程(join) | joined         | ---              | ---           |
+| 报名(apply)        | applied        | 邀请(invite)     | invited       |
+| 取消报名(cancel)   | **_canceled_** | 撤销邀请(revoke) | **_revoked_** |
+| 参加(agree)        | agreed         | 录取(accept)     | accepted      |
+| 拒绝(disagree)     | disagreed      | 不录取(reject)   | rejected      |
+| 退出(leave)        | left           | 清退(block)      | blocked       |
+
+> canceled , revoked 与无记录等效
+> 提问不公开
+
+# 角色权限
+
+| 角色   | 修改课程信息 | 增删管理员 | 增删助教 | 学员录取 | 学员清退 | 查看作业 | 回答问题 |
+| ------ | ------------ | ---------- | -------- | -------- | -------- | -------- | -------- |
+| 拥有者 | √            | √          | √        | √        | √        | √        | √        |
+| 管理员 | ----         | √          | √        | √        | √        | √        | √        |
+| 助教   | ----         | ----       | ----     | ---      | √        | √        | √        |
+
+# 管理员操作
+
+| 报名方式 | 学生状态   | 开始前      | 课程中   | 结束后 |
+| -------- | ---------- | ----------- | -------- | ------ |
+| 自学     | 未报名     | ---         | ---      | ---    |
+| 自学     | 已报名     | ---         | ---      | ---    |
+| 自学     | 未注册     | ---         | ---      | ---    |
+| 需审核   | 未注册     | ---         | ---      | ---    |
+| 需审核   | 未报名     | 邀请        | 邀请助教 | ---    |
+| 需审核   | 已邀请     | undo        | ---      | ---    |
+| 需审核   | 已撤销邀请 | undo        | ---      | ---    |
+| 需审核   | 已录取     | undo        | 清退     | ---    |
+| 需审核   | 不录取     | undo        | undo     | ---    |
+| 需审核   | 已清退     | ---         | undo     | ---    |
+| ---      | ---        | ---         | ---      | ---    |
+| 需审核   | 已报名     | 录取/不录取 | ---      | ---    |
+| 需审核   | 取消报名   | ---         | ---      | ---    |
+| 需审核   | 用户已接受 | ---         | 清退     | ---    |
+| 需审核   | 用户已拒绝 | ---         | ---      | ---    |
+| 需审核   | 已退出     | ---         | ---      | ---    |
+| 仅邀请   | 同上       | ---         | ---      | ---    |
+
+> 仅邀请与需审核相同,但是无已经报名状态
+> 所有需审核的操作均可撤销
+> 横线上为管理员设置的状态,一下为用户设置的状态
+
+# 助教-管理员 操作
+
+| 报名方式 | 学生状态   | 开始前    | 课程中    | 结束后 |
+| -------- | ---------- | --------- | --------- | ------ |
+| 自学     | 未报名     | ---       | 参加      | ---    |
+| 自学     | 已报名     | ---       | 退出      | ---    |
+| 自学     | 已退出     | ---       | 参加      | ---    |
+| 自学     | 未注册     | ---       | ---       | ---    |
+| 需审核   | 未注册     | ---       | ---       | ---    |
+| 需审核   | 已邀请     | 参加/拒绝 | 参加/拒绝 | ---    |
+| 需审核   | 已撤销邀请 | ---       | ---       | ---    |
+| 需审核   | 已录取     | 退出      | 退出      | ---    |
+| 需审核   | 不录取     | ---       | ---       | ---    |
+| 需审核   | 已清退     | ---       | 留言      | ---    |
+| ---      | ---        | ---       | ---       | ---    |
+| 需审核   | 已报名     | 取消      | ---       | ---    |
+| 需审核   | 取消报名   | ---       | ---       | ---    |
+| 需审核   | 用户已接受 | 退出      | 退出      | ---    |
+| 需审核   | 用户已拒绝 | ---       | ---       | ---    |
+| 需审核   | 已退出     | ---       | ---       | ---    |
+| 仅邀请   | 同上       | ---       | ---       | ---    |
+
+# 学生操作
+
+| 报名方式 | 学生状态   | 开始前    | 课程中 | 结束后 |
+| -------- | ---------- | --------- | ------ | ------ |
+| 自学     | 未报名     | ---       | 参加   | ---    |
+| 自学     | 已报名     | ---       | 退出   | ---    |
+| 自学     | 已退出     | ---       | 参加   | ---    |
+| 自学     | 未注册     | ---       | ---    | ---    |
+| 需审核   | 未注册     | ---       | ---    | ---    |
+| 需审核   | 未报名     | 报名      | ---    | ---    |
+| 需审核   | 已邀请     | 参加/拒绝 | ---    | ---    |
+| 需审核   | 已撤销邀请 | 报名      | ---    | ---    |
+| 需审核   | 已录取     | 退出      | 退出   | ---    |
+| 需审核   | 不录取     | ---       | ---    | ---    |
+| 需审核   | 已清退     | ---       | 留言   | ---    |
+| ---      | ---        | ---       | ---    | ---    |
+| 需审核   | 已报名     | 取消      | ---    | ---    |
+| 需审核   | 取消报名   | ---       | ---    | ---    |
+| 需审核   | 用户已接受 | 退出      | 退出   | ---    |
+| 需审核   | 用户已拒绝 | ---       | ---    | ---    |
+| 需审核   | 已退出     | ---       | ---    | ---    |
+| 仅邀请   | 同上       | ---       | ---    | ---    |
+
+> 仅邀请与需审核相同,但是无已经报名状态
+> 横线上为管理员设置的状态,一下为用户设置的状态
+
+# 课程状态显示-学生
+
+| 报名方式 | 学生状态 | 开始前   | 课程中     | 结束后   |
+| -------- | -------- | -------- | ---------- | -------- |
+| 自学     | 未报名   | 尚未开始 | 可报名     | 已经结束 |
+| 自学     | 已报名   | 尚未开始 | 已报名     | 已经结束 |
+| 需审核   | 未报名   | 可以报名 | 录取已结束 | 已经结束 |
+| 需审核   | 已经报名 | 审核中   | 未通过     | 已经结束 |
+| 需审核   | 已被邀请 | 已被邀请 | 邀请过期   | 已经结束 |
+| 需审核   | 已被录取 | 已被录取 | 进行中     | 已经结束 |
+| 需审核   | 已被拒绝 | 未被录取 | 未被录取   | 已经结束 |
+| 仅邀请   | 同上     |
+
+> 仅邀请与需审核相同,但是无已经报名状态
+
+# 测试帐号权限
+
+课程报名及向相关权限
+
+# 流程
+
+```mermaid
+graph LR;
+报名 --> 录取 --> 学员退出
+报名 --> 不录取
+
+邀请 --> 同意 --> 学员退出
+邀请 --> 不同意
+```

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

@@ -3,21 +3,49 @@ const items = {
   "course.exp.start.label": "起始经验",
   "course.exp.end.label": "结束经验",
   "course.exp.current.label": "当前经验",
+  "course.member.status.none.label": "",
   "course.member.status.normal.label": "报名成功",
-  "course.member.status.invited.label": "已经邀请",
-  "course.member.status.sign_up.label": "已经报名",
-  "course.member.status.accepted.label": "已接受",
-  "course.member.status.accepted.message": "接受报名吗?",
-  "course.member.status.rejected.label": "已拒绝",
-  "course.member.status.rejected.message": "拒绝报名吗?",
+  "course.member.status.joined.label": "已经参加了",
+  "course.member.status.join.button": "参加",
+  "course.member.status.join.message":
+    "参加{course}吗?本课程为自学课程。加入后可以立即开始学习。",
+  "course.member.status.applied.label": "已经报名",
+  "course.member.status.apply.button": "报名",
+  "course.member.status.apply.message":
+    "要报名{course}吗?本课程为需审核课程。报名后请等待组织者审核录取。",
+  "course.member.status.canceled.label": "已经取消",
+  "course.member.status.cancel.button": "取消",
+  "course.member.status.cancel.message": "要取消{course}的报名吗?",
+  "course.member.status.agreed.label": "已经加入",
+  "course.member.status.agree.button": "参加",
+  "course.member.status.agree.message": "要参加{course}吗?",
+  "course.member.status.disagreed.label": "已经拒绝",
+  "course.member.status.disagree.button": "拒绝",
+  "course.member.status.disagree.message": "要拒绝{course}吗?",
   "course.member.status.left.label": "已退出",
+  "course.member.status.leave.button": "退出",
+  "course.member.status.leave.message": "要退出{course}吗?",
+  "course.member.status.invited.label": "已经邀请",
+  "course.member.status.invite.button": "邀请",
+  "course.member.status.invite.message": "要邀请{name}成为{role}吗?",
+  "course.member.status.revoked.label": "已经撤销邀请",
+  "course.member.status.revoke.button": "撤销邀请",
+  "course.member.status.revoke.message": "要撤销{name}成为{role}吗?",
+  "course.member.status.accepted.label": "已录取",
+  "course.member.status.accept.button": "录取",
+  "course.member.status.accept.message": "录取{name}吗?",
+  "course.member.status.rejected.label": "不录取",
+  "course.member.status.reject.button": "不录取",
+  "course.member.status.reject.message": "不录取{name}吗?",
   "course.member.status.blocked.label": "已屏蔽",
-  "course.member.status.blocked.message": "屏蔽该用户吗?",
+  "course.member.status.block.button": "不录取",
+  "course.member.status.block.message": "屏蔽{name}吗?",
   "course.join.mode.invite.label": "仅限邀请",
   "course.join.mode.invite.message": "本课程仅限内部邀请。",
   "course.join.mode.manual.label": "人工审核",
   "course.join.mode.manual.message":
     "本课程为报名后人工审核课程。报名后请等待组织者审核。",
+  "course.join.mode.manual.user-accept.message": "您确定要接受课程邀请吗?",
   "course.join.mode.open.label": "开放自学",
   "course.join.mode.open.message": "本课程为开放课程。加入后可以立即开始学习。",
   "course.leave.message": "离开后将无法再次报名,您确定要离开吗?",

+ 36 - 8
dashboard/src/locales/zh-Hans/course/index.ts

@@ -3,21 +3,49 @@ const items = {
   "course.exp.start.label": "起始经验",
   "course.exp.end.label": "结束经验",
   "course.exp.current.label": "当前经验",
+  "course.member.status.none.label": " ",
   "course.member.status.normal.label": "报名成功",
-  "course.member.status.invited.label": "已经邀请",
-  "course.member.status.sign_up.label": "已经报名",
-  "course.member.status.accepted.label": "已接受",
-  "course.member.status.accepted.message": "接受报名吗?",
-  "course.member.status.rejected.label": "已拒绝",
-  "course.member.status.rejected.message": "拒绝报名吗?",
+  "course.member.status.joined.label": "已经参加了",
+  "course.member.status.join.button": "参加",
+  "course.member.status.join.message":
+    "参加{course}吗?本课程为自学课程。加入后可以立即开始学习。",
+  "course.member.status.applied.label": "已经报名",
+  "course.member.status.apply.button": "报名",
+  "course.member.status.apply.message":
+    "要报名{course}吗?本课程为需审核课程。报名后请等待组织者审核录取。",
+  "course.member.status.canceled.label": "已经取消",
+  "course.member.status.cancel.button": "取消",
+  "course.member.status.cancel.message": "要取消{course}的报名吗?",
+  "course.member.status.agreed.label": "已经加入",
+  "course.member.status.agree.button": "参加",
+  "course.member.status.agree.message": "要参加{course}吗?",
+  "course.member.status.disagreed.label": "已经拒绝",
+  "course.member.status.disagree.button": "拒绝",
+  "course.member.status.disagree.message": "要拒绝{course}吗?",
   "course.member.status.left.label": "已退出",
-  "course.member.status.blocked.label": "已屏蔽",
-  "course.member.status.blocked.message": "屏蔽该用户吗?",
+  "course.member.status.leave.button": "退出",
+  "course.member.status.leave.message": "要退出{course}吗?",
+  "course.member.status.invited.label": "已经邀请",
+  "course.member.status.invite.button": "邀请",
+  "course.member.status.invite.message": "要邀请{user}成为{role}吗?",
+  "course.member.status.revoked.label": "已经撤销邀请",
+  "course.member.status.revoke.button": "撤销邀请",
+  "course.member.status.revoke.message": "要撤销{user}成为{role}吗?",
+  "course.member.status.accepted.label": "已录取",
+  "course.member.status.accept.button": "录取",
+  "course.member.status.accept.message": "录取{user}吗?",
+  "course.member.status.rejected.label": "未录取",
+  "course.member.status.reject.button": "不录取",
+  "course.member.status.reject.message": "不录取{user}吗?",
+  "course.member.status.blocked.label": "已清退",
+  "course.member.status.block.button": "清退",
+  "course.member.status.block.message": "清退{user}吗?",
   "course.join.mode.invite.label": "仅限邀请",
   "course.join.mode.invite.message": "本课程仅限内部邀请。",
   "course.join.mode.manual.label": "人工审核",
   "course.join.mode.manual.message":
     "本课程为报名后人工审核课程。报名后请等待组织者审核。",
+  "course.join.mode.manual.user-accept.message": "您确定要接受课程邀请吗?",
   "course.join.mode.open.label": "开放自学",
   "course.join.mode.open.message": "本课程为开放课程。加入后可以立即开始学习。",
   "course.leave.message": "离开后将无法再次报名,您确定要离开吗?",

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

@@ -6,12 +6,14 @@ import { Card, Tabs } from "antd";
 import GoBack from "../../../components/studio/GoBack";
 import CourseInfoEdit from "../../../components/course/CourseInfoEdit";
 import CourseMemberList from "../../../components/course/CourseMemberList";
+import CourseMemberTimeLine from "../../../components/course/CourseMemberTimeLine";
+import { ICourseMember } from "../../../components/course/CourseMember";
 
 const Widget = () => {
   const intl = useIntl();
   const { studioname, courseId } = useParams(); //url 参数
   const [title, setTitle] = useState("loading");
-
+  const [selected, setSelected] = useState<string>();
   return (
     <>
       <Card
@@ -40,7 +42,35 @@ const Widget = () => {
               key: "member",
               label: `成员`,
               children: (
-                <CourseMemberList studioName={studioname} courseId={courseId} />
+                <div style={{ display: "flex" }}>
+                  <div style={{ flex: 1 }}>
+                    <CourseMemberList
+                      courseId={courseId}
+                      onSelect={(value: ICourseMember) => {
+                        setSelected(value.user?.id);
+                      }}
+                    />
+                  </div>
+                  <div style={{ flex: 1 }}>
+                    <Tabs
+                      items={[
+                        {
+                          key: "timeline",
+                          label: "timeline",
+                          children:
+                            courseId && selected ? (
+                              <CourseMemberTimeLine
+                                courseId={courseId}
+                                userId={selected}
+                              />
+                            ) : (
+                              <>{"未选择"}</>
+                            ),
+                        },
+                      ]}
+                    />
+                  </div>
+                </div>
               ),
             },
           ]}

+ 113 - 62
dashboard/src/pages/studio/course/list.tsx

@@ -7,10 +7,10 @@ import {
   Button,
   Popover,
   Dropdown,
-  Table,
   Image,
   message,
   Modal,
+  Tag,
 } from "antd";
 import { ProTable, ActionType } from "@ant-design/pro-components";
 import {
@@ -23,12 +23,22 @@ import CourseCreate from "../../../components/course/CourseCreate";
 import { API_HOST, delete_, get } from "../../../request";
 import {
   ICourseListResponse,
+  ICourseMemberData,
   ICourseNumberResponse,
+  TCourseJoinMode,
+  TCourseMemberAction,
   TCourseMemberStatus,
+  actionMap,
 } from "../../../components/api/Course";
 import { PublicityValueEnum } from "../../../components/studio/table";
 import { IDeleteResponse } from "../../../components/api/Article";
 import { getSorterUrl } from "../../../utils";
+import { ItemType } from "antd/lib/menu/hooks/useItems";
+import {
+  getStatusColor,
+  studentCanDo,
+} from "../../../components/course/RolePower";
+import { ISetStatus, setStatus } from "../../../components/course/UserAction";
 
 interface DataItem {
   sn: number;
@@ -39,15 +49,17 @@ interface DataItem {
   course_count?: number; //课程数
   member_count: number; //成员数量
   type: number; //类型-公开/内部
+  join: TCourseJoinMode; //报名方式
   created_at: string; //创建时间
   updated_at?: string; //修改时间
   article_id?: string; //文集ID
-  course_start_at?: string; //课程开始时间
-  course_end_at?: string; //课程结束时间
+  start_at?: string; //课程开始时间
+  end_at?: string; //课程结束时间
   intro_markdown?: string; //简介
   coverId: string;
   coverUrl?: string[]; //封面图片文件名
   myStatus?: TCourseMemberStatus;
+  myStatusId?: string;
   countProgressing?: number;
 }
 
@@ -172,6 +184,11 @@ const Widget = () => {
                       <Link to={`/course/show/${row.id}`} target="_blank">
                         {row.title}
                       </Link>
+                      <Tag>
+                        {intl.formatMessage({
+                          id: `course.join.mode.${row.join}.label`,
+                        })}
+                      </Tag>
                     </div>
                     <div>{row.subtitle}</div>
                   </div>
@@ -255,7 +272,10 @@ const Widget = () => {
                   break;
                 case "study":
                   mainButton = (
-                    <span key={index}>
+                    <span
+                      key={index}
+                      style={{ color: getStatusColor(row.myStatus) }}
+                    >
                       {intl.formatMessage({
                         id: `course.member.status.${row.myStatus}.label`,
                       })}
@@ -263,32 +283,91 @@ const Widget = () => {
                   );
                   break;
                 case "teach":
+                  mainButton = (
+                    <span
+                      key={index}
+                      style={{ color: getStatusColor(row.myStatus) }}
+                    >
+                      {intl.formatMessage({
+                        id: `course.member.status.${row.myStatus}.label`,
+                      })}
+                    </span>
+                  );
                   break;
                 default:
                   break;
               }
+              let userItems: ItemType[] = [];
+              const actions: TCourseMemberAction[] = [
+                "join",
+                "apply",
+                "cancel",
+                "agree",
+                "disagree",
+                "leave",
+              ];
+              if (activeKey !== "create") {
+                userItems = actions.map((item) => {
+                  return {
+                    key: item,
+                    label: intl.formatMessage({
+                      id: `course.member.status.${item}.button`,
+                    }),
+                    disabled: !studentCanDo(
+                      item,
+                      row.start_at,
+                      row.end_at,
+                      row.join,
+                      row.myStatus
+                    ),
+                  };
+                });
+              }
+
               return [
                 <Dropdown.Button
                   key={index}
                   type="link"
                   menu={{
-                    items: [
-                      {
-                        key: "remove",
-                        label: intl.formatMessage({
-                          id: "buttons.delete",
-                        }),
-                        icon: <DeleteOutlined />,
-                        danger: true,
-                      },
-                    ],
+                    items:
+                      activeKey === "create"
+                        ? [
+                            {
+                              key: "remove",
+                              label: intl.formatMessage({
+                                id: "buttons.delete",
+                              }),
+                              icon: <DeleteOutlined />,
+                              danger: true,
+                            },
+                          ]
+                        : userItems,
                     onClick: (e) => {
-                      switch (e.key) {
-                        case "remove":
-                          showDeleteConfirm(row.id, row.title);
-                          break;
-                        default:
-                          break;
+                      if (e.key === "remove") {
+                        showDeleteConfirm(row.id, row.title);
+                      }
+                      const currAction = e.key as TCourseMemberAction;
+                      if (actions.includes(currAction)) {
+                        const newStatus = actionMap(currAction);
+                        if (newStatus) {
+                          const actionParam: ISetStatus = {
+                            courseMemberId: row.myStatusId,
+                            message: intl.formatMessage(
+                              {
+                                id: `course.member.status.${currAction}.message`,
+                              },
+                              { course: row.title }
+                            ),
+                            status: newStatus,
+                            onSuccess: (data: ICourseMemberData) => {
+                              message.success(
+                                intl.formatMessage({ id: "flashes.success" })
+                              );
+                              ref.current?.reload();
+                            },
+                          };
+                          setStatus(actionParam);
+                        }
                       }
                     },
                   }}
@@ -299,47 +378,10 @@ const Widget = () => {
             },
           },
         ]}
-        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);
-          console.log(activeKey);
+          console.debug(params, sorter, filter);
+          console.info(activeKey);
           let url = `/v2/course?view=${activeKey}&studio=${studioname}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
@@ -349,7 +391,7 @@ const Widget = () => {
             url += "&search=" + (params.keyword ? params.keyword : "");
           }
           url += getSorterUrl(sorter);
-          console.log("url", url);
+          console.info("api request", url);
 
           const res = await get<ICourseListResponse>(url);
           console.debug("course data", res);
@@ -363,13 +405,17 @@ const Widget = () => {
               coverId: item.cover,
               coverUrl: item.cover_url,
               type: item.publicity,
+              join: item.join,
               member_count: item.member_count,
               myStatus: item.my_status,
+              myStatusId: item.my_status_id,
               countProgressing: item.count_progressing,
               created_at: item.created_at,
+              start_at: item.start_at,
+              end_at: item.end_at,
             };
           });
-          console.log(items);
+          console.debug("data covert", items);
           return {
             total: res.data.count,
             succcess: true,
@@ -408,7 +454,12 @@ const Widget = () => {
               setOpenCreate(newOpen);
             }}
           >
-            <Button key="button" icon={<PlusOutlined />} type="primary">
+            <Button
+              disabled={activeKey !== "create"}
+              key="button"
+              icon={<PlusOutlined />}
+              type="primary"
+            >
               {intl.formatMessage({ id: "buttons.create" })}
             </Button>
           </Popover>,