visuddhinanda 1 неделя назад
Родитель
Сommit
1d721b9ef8
35 измененных файлов с 3806 добавлено и 5 удалено
  1. 1 1
      dashboard-v6/documents/development/v6-todo-list.md
  2. 2 0
      dashboard-v6/src/Router.tsx
  3. 23 0
      dashboard-v6/src/api/course.ts
  4. 128 0
      dashboard-v6/src/components/anthology/AnthologyDetail.tsx
  5. 1 1
      dashboard-v6/src/components/anthology/TextBookToc.tsx
  6. 128 0
      dashboard-v6/src/components/course/AddMember.tsx
  7. 74 0
      dashboard-v6/src/components/course/AddStudent.tsx
  8. 32 0
      dashboard-v6/src/components/course/Course.tsx
  9. 75 0
      dashboard-v6/src/components/course/CourseCreate.tsx
  10. 116 0
      dashboard-v6/src/components/course/CourseHead.tsx
  11. 462 0
      dashboard-v6/src/components/course/CourseInfoEdit.tsx
  12. 23 0
      dashboard-v6/src/components/course/CourseIntro.tsx
  13. 177 0
      dashboard-v6/src/components/course/CourseInvite.tsx
  14. 78 0
      dashboard-v6/src/components/course/CourseList.tsx
  15. 425 0
      dashboard-v6/src/components/course/CourseMemberList.tsx
  16. 154 0
      dashboard-v6/src/components/course/CourseMemberTimeLine.tsx
  17. 18 0
      dashboard-v6/src/components/course/CourseNewLoading.tsx
  18. 48 0
      dashboard-v6/src/components/course/ExerciseAnswer.tsx
  19. 493 0
      dashboard-v6/src/components/course/List.tsx
  20. 401 0
      dashboard-v6/src/components/course/RolePower.ts
  21. 127 0
      dashboard-v6/src/components/course/SelectChannel.tsx
  22. 104 0
      dashboard-v6/src/components/course/SignUp.tsx
  23. 129 0
      dashboard-v6/src/components/course/Status.tsx
  24. 39 0
      dashboard-v6/src/components/course/TextBook.tsx
  25. 83 0
      dashboard-v6/src/components/course/UserAction.tsx
  26. 88 0
      dashboard-v6/src/components/course/hooks/useCourse.ts
  27. 83 0
      dashboard-v6/src/components/course/hooks/userActionUtils.tsx
  28. 133 0
      dashboard-v6/src/components/course/readme.md
  29. 88 0
      dashboard-v6/src/pages/workspace/course/edit.tsx
  30. 12 0
      dashboard-v6/src/pages/workspace/course/index.tsx
  31. 12 0
      dashboard-v6/src/pages/workspace/course/show.tsx
  32. 1 1
      dashboard-v6/src/reducers/course-user.ts
  33. 1 1
      dashboard-v6/src/reducers/current-course.ts
  34. 1 1
      dashboard-v6/src/routes/anthologyRoutes.ts
  35. 46 0
      dashboard-v6/src/routes/courseRoutes.ts

+ 1 - 1
dashboard-v6/documents/development/v6-todo-list.md

@@ -24,7 +24,7 @@
 
 #### 内容模块
 
-- [ ] `/course/list`=>`workgroup/course` [2]
+- [x] `/course/list`=>`workgroup/course` [2]
 - [x] `/dict/list`=>`resources/dict` [1]
 - [x] `/term/list`=>`workgroup/term`
 - [x] `/article/list`=>`workgroup/article`

+ 2 - 0
dashboard-v6/src/Router.tsx

@@ -17,6 +17,7 @@ import transferRoutes from "./routes/transferRoutes";
 import tagRoutes from "./routes/tagRoutes";
 import driverRoutes from "./routes/driverRoutes";
 import dictRoutes from "./routes/dictRoutes";
+import courseRoutes from "./routes/courseRoutes";
 
 const RootLayout = lazy(() => import("./layouts/Root"));
 const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
@@ -96,6 +97,7 @@ const router = createBrowserRouter(
             ...tagRoutes,
             ...driverRoutes,
             ...dictRoutes,
+            ...courseRoutes,
           ],
         },
 

+ 23 - 0
dashboard-v6/src/api/Course.ts → dashboard-v6/src/api/course.ts

@@ -1,3 +1,6 @@
+// /src/api/course.ts
+import type { LoaderFunctionArgs } from "react-router";
+import { get } from "../request";
 import type { IStudio, IUser } from "./Auth";
 import type { IChannel } from "./channel";
 
@@ -223,3 +226,23 @@ export interface ICourseExerciseResponse {
     count: number;
   };
 }
+
+export const fetchCourse = (courseId: string): Promise<ICourseResponse> => {
+  return get<ICourseResponse>(`/api/v2/course/${courseId}`);
+};
+
+export async function courseLoader({ params }: LoaderFunctionArgs) {
+  const courseId = params.courseId;
+
+  if (!courseId) {
+    throw new Response("Missing courseId", { status: 400 });
+  }
+
+  const res = await fetchCourse(courseId);
+
+  if (!res.ok) {
+    throw new Response("Channel not found", { status: 404 });
+  }
+
+  return res.data;
+}

+ 128 - 0
dashboard-v6/src/components/anthology/AnthologyDetail.tsx

@@ -0,0 +1,128 @@
+import { useState, useEffect } from "react";
+import { Space, Typography, message } from "antd";
+import { get } from "../../request";
+
+import StudioName from "../auth/Studio";
+import TimeShow from "../general/TimeShow";
+import Marked from "../general/Marked";
+import AnthologyTocTree from "../anthology/AnthologyTocTree";
+import { useIntl } from "react-intl";
+import type {
+  IAnthologyData,
+  IAnthologyDataResponse,
+  IAnthologyResponse,
+} from "../../api/article";
+import type { TTarget } from "../../types";
+
+const { Title, Text, Paragraph } = Typography;
+
+interface Props {
+  aid?: string;
+  channels?: string[];
+  visible?: boolean;
+  onArticleClick?: (anthologyId: string, id: string, target?: TTarget) => void;
+  onTitle?: (title: string) => void;
+  onLoading?: (loading: boolean) => void;
+  onError?: (error: unknown, message?: string) => void;
+}
+
+const AnthologyDetailWidget = ({
+  aid,
+  channels,
+  visible = true,
+  onArticleClick,
+  onLoading,
+  onTitle,
+  onError,
+}: Props) => {
+  const [data, setData] = useState<IAnthologyData>();
+  const intl = useIntl();
+
+  useEffect(() => {
+    if (!aid) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      try {
+        onLoading?.(true);
+
+        const res = await get<IAnthologyResponse>(`/api/v2/anthology/${aid}`);
+
+        if (!active) return;
+
+        if (!res.ok) {
+          message.error(res.message);
+          onError?.(res.data, res.message);
+          return;
+        }
+
+        const item: IAnthologyDataResponse = res.data;
+
+        const parsed: IAnthologyData = {
+          id: item.uid,
+          title: item.title,
+          subTitle: item.subtitle,
+          summary: item.summary,
+          articles: [],
+          studio: item.studio,
+          created_at: item.created_at,
+          updated_at: item.updated_at,
+        };
+
+        setData(parsed);
+        onTitle?.(item.title);
+      } catch (err) {
+        if (active) {
+          console.error(err);
+          onError?.(err);
+        }
+      } finally {
+        if (active && onLoading) {
+          onLoading(false);
+        }
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [aid, onError, onLoading, onTitle]);
+
+  if (!visible || !data) return null;
+
+  return (
+    <div style={{ padding: 12 }}>
+      <Title level={4}>{data.title}</Title>
+
+      <Text type="secondary">{data.subTitle}</Text>
+
+      <Paragraph>
+        <Space>
+          <StudioName data={data.studio} />
+          <TimeShow updatedAt={data.updated_at} />
+        </Space>
+      </Paragraph>
+
+      <Paragraph>
+        <Marked text={data.summary} />
+      </Paragraph>
+
+      <Title level={5}>
+        {intl.formatMessage({ id: "labels.table-of-content" })}
+      </Title>
+
+      <AnthologyTocTree
+        anthologyId={aid}
+        channels={channels}
+        onClick={(anthologyId, id, target) =>
+          onArticleClick?.(anthologyId, id, target)
+        }
+      />
+    </div>
+  );
+};
+
+export default AnthologyDetailWidget;

+ 1 - 1
dashboard-v6/src/components/anthology/TextBookToc.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useState } from "react";
 import AnthologyTocTree from "./AnthologyTocTree";
 import { get } from "../../request";
-import type { ICourseResponse } from "../../api/Course";
+import type { ICourseResponse } from "../../api/course";
 import type { TTarget } from "../../types";
 
 interface IWidget {

+ 128 - 0
dashboard-v6/src/components/course/AddMember.tsx

@@ -0,0 +1,128 @@
+import { useIntl } from "react-intl";
+import { ProForm, ProFormSelect } from "@ant-design/pro-components";
+import { Button, message, Popover } from "antd";
+import { UserAddOutlined } from "@ant-design/icons";
+
+import { get, post } from "../../request";
+import type { IUserListResponse } from "../../api/Auth";
+import { useState } from "react";
+import type {
+  ICourseMemberData,
+  ICourseMemberResponse,
+  TCourseRole,
+} from "../../api/course";
+
+interface IFormData {
+  userId: string;
+  role: TCourseRole;
+}
+
+interface IWidget {
+  courseId?: string;
+  onCreated?: () => void;
+}
+const AddMemeberWidget = ({ courseId, onCreated }: IWidget) => {
+  const intl = useIntl();
+  const [open, setOpen] = useState(false);
+
+  const form = (
+    <ProForm<IFormData>
+      onFinish={async (values: IFormData) => {
+        console.log(values);
+        if (typeof courseId !== "undefined") {
+          const url = "/api/v2/course-member";
+
+          const data: ICourseMemberData = {
+            user_id: values.userId,
+            role: values.role,
+            course_id: courseId,
+            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();
+                }
+              }
+            }
+          );
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormSelect
+          width="sm"
+          name="userId"
+          label={intl.formatMessage({ id: "forms.fields.user.label" })}
+          showSearch
+          debounceTime={300}
+          request={async ({ keyWords }) => {
+            console.log("keyWord", keyWords);
+            const json = await get<IUserListResponse>(
+              `/api/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.message.user.required",
+          })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "forms.message.user.required",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormSelect
+          width="sm"
+          name="role"
+          label={intl.formatMessage({ id: "forms.fields.type.label" })}
+          valueEnum={{
+            student: intl.formatMessage({ id: "forms.fields.student.label" }),
+            assistant: intl.formatMessage({
+              id: "forms.fields.assistant.label",
+            }),
+            manager: intl.formatMessage({
+              id: "auth.role.manager",
+            }),
+          }}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+  const handleClickChange = (open: boolean) => {
+    setOpen(open);
+  };
+  return (
+    <Popover
+      placement="bottomLeft"
+      arrow={{ pointAtCenter: true }}
+      content={form}
+      trigger="click"
+      open={open}
+      onOpenChange={handleClickChange}
+    >
+      <Button icon={<UserAddOutlined />} key="add" type="primary">
+        {intl.formatMessage({ id: "buttons.group.add.member" })}
+      </Button>
+    </Popover>
+  );
+};
+
+export default AddMemeberWidget;

+ 74 - 0
dashboard-v6/src/components/course/AddStudent.tsx

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

+ 32 - 0
dashboard-v6/src/components/course/Course.tsx

@@ -0,0 +1,32 @@
+// /src/pages/course/CourseDetailPage.tsx(或原路径的 Widget.tsx)
+
+import { Divider } from "antd";
+import { useCourse } from "./hooks/useCourse";
+import ArticleSkeleton from "../article/components/ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+import CourseHead from "./CourseHead";
+import CourseIntro from "./CourseIntro";
+import TextBook from "./TextBook";
+
+interface IWidget {
+  id?: string;
+}
+
+const Course = ({ id }: IWidget) => {
+  const { data, loading, errorCode } = useCourse(id);
+
+  if (loading) return <ArticleSkeleton />;
+  if (errorCode) return <ErrorResult code={errorCode} />;
+
+  return (
+    <div>
+      <CourseHead data={data ?? undefined} />
+      <Divider />
+      <CourseIntro intro={data?.content} />
+      <Divider />
+      <TextBook anthologyId={data?.anthology_id} courseId={data?.id} />
+    </div>
+  );
+};
+
+export default Course;

+ 75 - 0
dashboard-v6/src/components/course/CourseCreate.tsx

@@ -0,0 +1,75 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+
+import { post } from "../../request";
+import type { ICourseCreateRequest, ICourseResponse } from "../../api/course";
+import LangSelect from "../general/LangSelect";
+import { useRef } from "react";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+interface IWidgetCourseCreate {
+  studio?: string;
+  onCreate?: () => void;
+}
+const CourseCreateWidget = ({ studio = "", onCreate }: IWidgetCourseCreate) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IFormData>
+      formRef={formRef}
+      onFinish={async (values: IFormData) => {
+        console.log(values);
+        values.studio = studio;
+        const url = `/api/v2/course`;
+        console.info("CourseCreateWidget api request", url, values);
+        const res = await post<ICourseCreateRequest, ICourseResponse>(
+          url,
+          values
+        );
+        console.debug("CourseCreateWidget api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          formRef.current?.resetFields(["title"]);
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          required
+          label={intl.formatMessage({ id: "channel.name" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <LangSelect />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default CourseCreateWidget;

+ 116 - 0
dashboard-v6/src/components/course/CourseHead.tsx

@@ -0,0 +1,116 @@
+//课程详情图片标题按钮主讲人组合
+
+import { Image, Space, Col, Row, Tag } from "antd";
+import { Typography } from "antd";
+
+import type { ICourseDataResponse } from "../../api/course";
+import { useIntl } from "react-intl";
+import Status from "./Status";
+import dayjs from "dayjs";
+import isBetween from "dayjs/plugin/isBetween";
+import User from "../auth/User";
+
+const { Title, Text } = Typography;
+
+dayjs.extend(isBetween);
+
+const courseDuration = (startAt?: string, endAt?: string) => {
+  let labelDuration = "";
+  if (dayjs().isBefore(startAt)) {
+    labelDuration = "未开始";
+  } else if (dayjs().isBefore(endAt)) {
+    labelDuration = "进行中";
+  } else {
+    labelDuration = "已经结束";
+  }
+  return <Tag>{labelDuration}</Tag>;
+};
+
+interface IWidget {
+  data?: ICourseDataResponse;
+}
+const CourseHeadWidget = ({ data }: IWidget) => {
+  const intl = useIntl();
+  const duration = courseDuration(data?.start_at, data?.end_at);
+  let signUp = "";
+  if (dayjs().isBefore(dayjs(data?.sign_up_start_at))) {
+    signUp = "未开始";
+  } else if (
+    dayjs().isBetween(
+      dayjs(data?.sign_up_start_at),
+      dayjs(data?.sign_up_end_at)
+    )
+  ) {
+    signUp = "可报名";
+  } else if (dayjs().isAfter(dayjs(data?.sign_up_end_at))) {
+    signUp = "已结束";
+  }
+  return (
+    <>
+      <Row>
+        <Col flex="auto"></Col>
+        <Col flex="960px">
+          <Space orientation="vertical">
+            <Space>
+              <Image
+                width={200}
+                style={{ borderRadius: 12 }}
+                src={
+                  data?.cover_url && data?.cover_url.length > 1
+                    ? data?.cover_url[1]
+                    : undefined
+                }
+                preview={{
+                  src:
+                    data?.cover_url && data?.cover_url.length > 0
+                      ? data?.cover_url[0]
+                      : undefined,
+                }}
+                fallback={`${import.meta.env.BASE_URL}/app/course/img/default.jpg`}
+              />
+              <Space orientation="vertical">
+                <Title level={3}>{data?.title}</Title>
+                <Title level={5}>{data?.subtitle}</Title>
+                <Text>
+                  <Space>
+                    {"报名时间:"}
+                    {dayjs(data?.sign_up_start_at).format("YYYY-MM-DD")}——
+                    {dayjs(data?.sign_up_end_at).format("YYYY-MM-DD")}
+                    <Tag>{signUp}</Tag>
+                  </Space>
+                </Text>
+                <Text>
+                  <Space>
+                    {"课程时间:"}
+                    {dayjs(data?.start_at).format("YYYY-MM-DD")}——
+                    {dayjs(data?.end_at).format("YYYY-MM-DD")}
+                    {duration}
+                  </Space>
+                </Text>
+                <Text>
+                  {data?.join
+                    ? intl.formatMessage({
+                        id: `course.join.mode.${data.join}.message`,
+                      })
+                    : undefined}
+                </Text>
+
+                <Status data={data} />
+              </Space>
+            </Space>
+
+            <Space>
+              <Text>主讲人:</Text>{" "}
+              <Text>
+                <User {...data?.teacher} showAvatar={false} />
+              </Text>
+            </Space>
+          </Space>
+        </Col>
+        <Col flex="auto"></Col>
+      </Row>
+    </>
+  );
+};
+
+export default CourseHeadWidget;

+ 462 - 0
dashboard-v6/src/components/course/CourseInfoEdit.tsx

@@ -0,0 +1,462 @@
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormText,
+  ProFormDateRangePicker,
+  ProFormSelect,
+  ProFormUploadButton,
+  type RequestOptionsType,
+  ProFormDependency,
+  ProFormDigit,
+} from "@ant-design/pro-components";
+
+import { message, Form } from "antd";
+import { get as getToken } from "../../reducers/current-user";
+
+import { get, put } from "../../request";
+import type {
+  ICourseDataRequest,
+  ICourseDataResponse,
+  ICourseResponse,
+} from "../../api/course";
+import PublicitySelect from "../studio/PublicitySelect";
+
+import type { IUserListResponse } from "../../api/Auth";
+import MDEditor from "@uiw/react-md-editor";
+import type { DefaultOptionType } from "antd/lib/select";
+import type { UploadFile } from "antd/es/upload/interface";
+import type { IAttachmentResponse } from "../../api/Attachments";
+import type { IAnthologyListResponse } from "../../api/article";
+import type {
+  IApiResponseChannelData,
+  IApiResponseChannelList,
+} from "../../api/channel";
+
+interface IFormData {
+  title: string;
+  subtitle: string;
+  summary?: string;
+  content?: string | null;
+  cover?: UploadFile<IAttachmentResponse>[];
+  teacherId?: string;
+  anthologyId?: string;
+  channelId?: string;
+  signUpMessage?: string | null;
+  dateRange?: string[];
+  signUp?: string[];
+  status: number;
+  join: string;
+  exp: string;
+  number: number;
+}
+
+interface IWidget {
+  studioName?: string;
+  courseId?: string;
+  onTitleChange?: (title: string) => void;
+}
+const CourseInfoEditWidget = ({
+  studioName,
+  courseId,
+  onTitleChange,
+}: IWidget) => {
+  const intl = useIntl();
+  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 [, setCourseData] = useState<ICourseDataResponse>();
+
+  return (
+    <div>
+      <ProForm<IFormData>
+        formKey="course_edit"
+        onFinish={async (values: IFormData) => {
+          console.log("course put all data", values);
+          let _cover: string = "";
+          const startAt = values.dateRange ? values.dateRange[0] : "";
+          const endAt = values.dateRange ? values.dateRange[1] : "";
+          const signUpStartAt = values.signUp ? values.signUp[0] : null;
+          const signUpEndAt = values.signUp ? values.signUp[1] : null;
+          if (
+            typeof values.cover === "undefined" ||
+            values.cover.length === 0
+          ) {
+            _cover = "";
+          } else if (typeof values.cover[0].response === "undefined") {
+            _cover = values.cover[0].uid;
+          } else {
+            console.debug("upload ", values.cover[0].response);
+            _cover = values.cover[0].response.data.name;
+          }
+          const url = `/api/v2/course/${courseId}`;
+          const postData: ICourseDataRequest = {
+            title: values.title, //标题
+            subtitle: values.subtitle, //副标题
+            summary: values.summary,
+            content: values.content, //简介
+            sign_up_message: values.signUpMessage,
+            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, //课程结束时间
+            sign_up_start_at: signUpStartAt,
+            sign_up_end_at: signUpEndAt,
+            join: values.join,
+            request_exp: values.exp,
+            number: values.number,
+          };
+          console.debug("course info edit put", url, postData);
+          const res = await put<ICourseDataRequest, ICourseResponse>(
+            url,
+            postData
+          );
+          console.debug("course info edit put", res);
+          if (res.ok) {
+            message.success(intl.formatMessage({ id: "flashes.success" }));
+          } else {
+            message.error(res.message);
+          }
+        }}
+        request={async () => {
+          const res = await get<ICourseResponse>(`/api/v2/course/${courseId}`);
+          console.log("course data", res.data);
+          setCourseData(res.data);
+          if (typeof onTitleChange !== "undefined") {
+            onTitleChange(res.data.title);
+          }
+          console.log(res.data);
+          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 ?? "",
+            signUpMessage: res.data.sign_up_message,
+            cover: res.data.cover
+              ? [
+                  {
+                    uid: res.data.cover,
+                    name: "cover",
+                    thumbUrl:
+                      res.data.cover_url && res.data.cover_url.length > 1
+                        ? res.data.cover_url[1]
+                        : undefined,
+                  },
+                ]
+              : [],
+            teacherId: res.data.teacher?.id,
+            anthologyId: res.data.anthology_id,
+            channelId: res.data.channel_id,
+            dateRange: [res.data.start_at, res.data.end_at],
+            signUp: [res.data.sign_up_start_at, res.data.sign_up_end_at],
+            status: res.data.publicity,
+            join: res.data.join,
+            exp: res.data.request_exp,
+            number: res.data.number,
+          };
+        }}
+      >
+        <ProForm.Group>
+          <ProFormUploadButton
+            name="cover"
+            label="封面"
+            max={1}
+            fieldProps={{
+              name: "file",
+              listType: "picture-card",
+              className: "avatar-uploader",
+              headers: {
+                Authorization: `Bearer ${getToken()}`,
+              },
+              onRemove: (file: UploadFile<unknown>): boolean => {
+                console.log("remove", file);
+                return true;
+              },
+            }}
+            action={`${import.meta.env.BASE_URL}/api/v2/attachment`}
+            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>(
+                `/api/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",
+            })}
+          />
+          <ProFormDigit label="招生数量" name="number" min={0} />
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormDateRangePicker width="md" name="signUp" 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>(
+                `/api/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" || keyWords === " ") {
+                return currChannel ? [currChannel] : [];
+              }
+              let urlMy = `/api/v2/channel?view=studio-all&name=${studioName}`;
+              if (typeof keyWords !== "undefined" && keyWords !== "") {
+                urlMy += "&search=" + keyWords;
+              }
+              console.info("api request", urlMy);
+              const json = await get<IApiResponseChannelList>(urlMy);
+              console.info("api response", json);
+
+              let urlPublic = `/api/v2/channel?view=public`;
+              if (typeof keyWords !== "undefined" && keyWords !== "") {
+                urlPublic += "&search=" + keyWords;
+              }
+              console.info("api request", urlPublic);
+              const jsonPublic = await get<IApiResponseChannelList>(urlPublic);
+              console.info("api response", jsonPublic);
+
+              //查重
+              const channels1: IApiResponseChannelData[] = [];
+              const channels = [...json.data.rows, ...jsonPublic.data.rows];
+              channels.forEach((value) => {
+                const has = channels1.findIndex(
+                  (value1) => value1.uid === value.uid
+                );
+                if (has === -1) {
+                  channels1.push(value);
+                }
+              });
+
+              const channelList = channels1.map((item) => {
+                return {
+                  value: item.uid,
+                  label: `${item.studio.nickName}/${item.name}`,
+                };
+              });
+              console.debug("channelList", channelList);
+              return channelList;
+            }}
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <PublicitySelect width="md" disable={["blocked"]} />
+          <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
+                  hidden
+                  tooltip="要求查看经验值,需要学生同意才会生效。"
+                  options={option}
+                  width="md"
+                  name="exp"
+                  label="查看学生经验值"
+                  allowClear={false}
+                />
+              );
+            }}
+          </ProFormDependency>
+        </ProForm.Group>
+        <ProForm.Group>
+          <Form.Item
+            name="signUpMessage"
+            label={intl.formatMessage({
+              id: "forms.fields.sign-up-message.label",
+            })}
+          >
+            <MDEditor />
+          </Form.Item>
+        </ProForm.Group>
+        <ProForm.Group>
+          <Form.Item
+            name="content"
+            label={intl.formatMessage({ id: "forms.fields.content.label" })}
+          >
+            <MDEditor />
+          </Form.Item>
+        </ProForm.Group>
+      </ProForm>
+    </div>
+  );
+};
+
+export default CourseInfoEditWidget;

+ 23 - 0
dashboard-v6/src/components/course/CourseIntro.tsx

@@ -0,0 +1,23 @@
+//课程详情简介
+import { Col, Row, Typography } from "antd";
+
+import Marked from "../general/Marked";
+const { Paragraph } = Typography;
+interface IWidget {
+  intro?: string;
+}
+const CourseIntroWidget = ({ intro }: IWidget) => {
+  return (
+    <Row>
+      <Col flex="auto"></Col>
+      <Col flex="960px">
+        <Paragraph>
+          <Marked text={intro} />
+        </Paragraph>
+      </Col>
+      <Col flex="auto"></Col>
+    </Row>
+  );
+};
+
+export default CourseIntroWidget;

+ 177 - 0
dashboard-v6/src/components/course/CourseInvite.tsx

@@ -0,0 +1,177 @@
+import { PlusOutlined } from "@ant-design/icons";
+import {
+  type ProFormInstance,
+  ProFormSelect,
+  StepsForm,
+} from "@ant-design/pro-components";
+import { Alert, Button, Modal, Result, message } from "antd";
+import { useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import { get, post } from "../../request";
+import type {
+  ICourseMemberData,
+  ICourseMemberResponse,
+} from "../../api/course";
+import type { IUserListResponse } from "../../api/Auth";
+
+interface IFormData {
+  userId: string;
+  role: string;
+}
+
+interface IWidget {
+  courseId?: string;
+  onCreated?: () => void;
+}
+
+const CourseInviteWidget = ({ courseId, onCreated }: IWidget) => {
+  const intl = useIntl();
+  const [visible, setVisible] = useState(false);
+  const [curr, setCurr] = useState<ICourseMemberData>();
+  const [userId, setUserId] = useState<string>();
+
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <>
+      <Button type="primary" onClick={() => setVisible(true)}>
+        <PlusOutlined />
+        邀请
+      </Button>
+      <Modal
+        title="邀请"
+        width={600}
+        onCancel={() => setVisible(false)}
+        open={visible}
+        footer={false}
+        destroyOnHidden
+      >
+        <StepsForm<IFormData>
+          formRef={formRef}
+          onFinish={async (values) => {
+            console.log(values);
+            setVisible(false);
+            message.success("提交成功");
+          }}
+          formProps={{
+            validateMessages: {
+              required: "此项为必填项",
+            },
+          }}
+        >
+          <StepsForm.StepForm
+            name="base"
+            title="选择用户"
+            onFinish={async (values) => {
+              setUserId(values.userId);
+              const url = `/api/v2/course-member/${courseId}?user_uid=${values.userId}`;
+              console.info("api request", url, values);
+              const json = await get<ICourseMemberResponse>(url);
+              if (json.ok) {
+                setCurr(json.data);
+              } else {
+                setCurr(undefined);
+              }
+              return true;
+            }}
+          >
+            <ProFormSelect
+              width="sm"
+              name="userId"
+              label={intl.formatMessage({ id: "forms.fields.user.label" })}
+              showSearch
+              debounceTime={300}
+              request={async ({ keyWords }) => {
+                console.log("keyWord", keyWords);
+                const json = await get<IUserListResponse>(
+                  `/api/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.message.user.required",
+              })}
+              rules={[
+                {
+                  required: true,
+                  message: intl.formatMessage({
+                    id: "forms.message.user.required",
+                  }),
+                },
+              ]}
+            />
+          </StepsForm.StepForm>
+          <StepsForm.StepForm
+            name="checkbox"
+            title="选择身份"
+            onFinish={async (values) => {
+              if (typeof courseId !== "undefined" && userId) {
+                const url = "/api/v2/course-member";
+                const data: ICourseMemberData = {
+                  user_id: userId,
+                  role: values.role,
+                  course_id: courseId,
+                  status: "invited",
+                };
+                console.info("api request", url, data);
+                const json = await post<
+                  ICourseMemberData,
+                  ICourseMemberResponse
+                >(url, data);
+
+                console.info("add member api response", json);
+                if (json.ok) {
+                  if (typeof onCreated !== "undefined") {
+                    onCreated();
+                  }
+                } else {
+                  console.error(json.message);
+                  return false;
+                }
+              } else {
+                return false;
+              }
+              return true;
+            }}
+          >
+            {curr ? (
+              <Alert
+                message={`用户 ${curr?.user?.nickName} 身份 ${curr?.role} 状态 ${curr?.status} `}
+              />
+            ) : (
+              <></>
+            )}
+            <ProFormSelect
+              width="sm"
+              name="role"
+              label={intl.formatMessage({ id: "forms.fields.type.label" })}
+              valueEnum={{
+                student: intl.formatMessage({
+                  id: "forms.fields.student.label",
+                }),
+                assistant: intl.formatMessage({
+                  id: "forms.fields.assistant.label",
+                }),
+                manager: intl.formatMessage({
+                  id: "auth.role.manager",
+                }),
+              }}
+            />
+          </StepsForm.StepForm>
+          <StepsForm.StepForm name="time" title="完成">
+            <Result status="success" title="已经成功邀请" />
+          </StepsForm.StepForm>
+        </StepsForm>
+      </Modal>
+    </>
+  );
+};
+
+export default CourseInviteWidget;

+ 78 - 0
dashboard-v6/src/components/course/CourseList.tsx

@@ -0,0 +1,78 @@
+//课程列表
+import { Link } from "react-router";
+import { useEffect, useState } from "react";
+import { Avatar, List, message, Typography, Image } from "antd";
+
+import type {
+  ICourseDataResponse,
+  ICourseListResponse,
+} from "../../api/course";
+import { get } from "../../request";
+
+const { Paragraph } = Typography;
+
+interface IWidget {
+  type: "open" | "close";
+}
+const CourseListWidget = ({ type }: IWidget) => {
+  const [data, setData] = useState<ICourseDataResponse[]>();
+
+  useEffect(() => {
+    get<ICourseListResponse>(`/api/v2/course?view=${type}`).then((json) => {
+      if (json.ok) {
+        console.log(json.data);
+        setData(json.data.rows);
+      } else {
+        message.error(json.message);
+      }
+    });
+  }, [type]);
+
+  return (
+    <List
+      itemLayout="vertical"
+      size="default"
+      pagination={{
+        onChange: (page) => {
+          console.log(page);
+        },
+        pageSize: 5,
+      }}
+      dataSource={data}
+      renderItem={(item) => (
+        <List.Item
+          key={item.title}
+          extra={
+            <Image
+              width={128}
+              style={{ borderRadius: 12 }}
+              src={
+                item.cover_url && item.cover_url.length > 1
+                  ? item.cover_url[1]
+                  : undefined
+              }
+              preview={{
+                src:
+                  item.cover_url && item.cover_url.length > 0
+                    ? item.cover_url[0]
+                    : undefined,
+              }}
+              fallback={`${import.meta.env.BASE_URL}/app/course/img/default.jpg`}
+            />
+          }
+        >
+          <List.Item.Meta
+            avatar={<Avatar />}
+            title={<Link to={`/course/show/${item.id}`}>{item.title}</Link>}
+            description={<div>主讲:{item.teacher?.nickName}</div>}
+          />
+          <Paragraph ellipsis={{ rows: 2, expandable: false }}>
+            {item.summary}
+          </Paragraph>
+        </List.Item>
+      )}
+    />
+  );
+};
+
+export default CourseListWidget;

+ 425 - 0
dashboard-v6/src/components/course/CourseMemberList.tsx

@@ -0,0 +1,425 @@
+import { useIntl } from "react-intl";
+import { Dropdown, Tag, Tooltip, Typography, message } from "antd";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { ExportOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+import { useEffect, useRef, useState } from "react";
+import {
+  type ICourseDataResponse,
+  type ICourseMemberListResponse,
+  type ICourseResponse,
+  type TCourseMemberAction,
+  type TCourseMemberStatus,
+  type TCourseRole,
+  actionMap,
+} from "../../api/course";
+
+import User from "../auth/User";
+import { getStatusColor, managerCanDo } from "./RolePower";
+
+import CourseInvite from "./CourseInvite";
+import type { IUser } from "../../api/Auth";
+import type { IChannel } from "../../api/channel";
+import type { ItemType } from "antd/es/menu/interface";
+
+// 1. 引入 hook(删除旧的 ISetStatus 类型导入,改为从 hook 文件导入)
+import { useSetStatus, type ISetStatus } from "./hooks/userActionUtils";
+
+interface IParam {
+  role?: string;
+  status?: string[];
+}
+
+interface IRoleTag {
+  title: string;
+  color: string;
+}
+
+export interface ICourseMember {
+  sn?: number;
+  id?: string;
+  userId: string;
+  user?: IUser;
+  name?: string;
+  tag?: IRoleTag[];
+  image: string;
+  role?: TCourseRole;
+  channel?: IChannel;
+  startExp?: number;
+  endExp?: number;
+  currentExp?: number;
+  expByDay?: number;
+  status?: TCourseMemberStatus;
+}
+
+interface IWidget {
+  courseId?: string;
+  onSelect?: (record: ICourseMember) => void;
+}
+
+const CourseMemberListWidget = ({ courseId, onSelect }: IWidget) => {
+  const intl = useIntl(); //i18n
+  const [canManage, setCanManage] = useState(false);
+  const [course, setCourse] = useState<ICourseDataResponse>();
+  const ref = useRef<ActionType | null>(null);
+  const { Text } = Typography;
+
+  // 2. ✅ 在组件顶层调用 hook
+  const { setStatus } = useSetStatus();
+
+  useEffect(() => {
+    if (courseId) {
+      const url = `/api/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);
+          }
+        })
+        .catch((e) => console.error(e));
+    }
+  }, [courseId]);
+
+  return (
+    <>
+      <ProList<ICourseMember, IParam>
+        actionRef={ref}
+        search={{
+          filterType: "light",
+        }}
+        onItem={(record: ICourseMember) => {
+          return {
+            onClick: () => {
+              // 点击行
+              if (typeof onSelect !== "undefined") {
+                onSelect(record);
+              }
+            },
+          };
+        }}
+        columns={[
+          {
+            listSlot: "title",
+            dataIndex: "name",
+            search: false,
+          },
+          {
+            listSlot: "avatar",
+            search: false,
+            render(_dom, entity) {
+              return <User {...entity.user} showName={false} />;
+            },
+            editable: false,
+          },
+          {
+            listSlot: "description",
+            dataIndex: "desc",
+            search: false,
+            render(_dom, entity) {
+              return (
+                <div>
+                  {entity.role === "student" ? (
+                    <>
+                      {"channel:"}
+                      {entity.channel?.name ?? (
+                        <Text type="danger">
+                          {intl.formatMessage({
+                            id: `course.channel.unbound`,
+                          })}
+                        </Text>
+                      )}
+                    </>
+                  ) : (
+                    <></>
+                  )}
+                </div>
+              );
+            },
+          },
+          {
+            listSlot: "subTitle",
+            search: false,
+            render: (_: React.ReactNode, entity: ICourseMember) => {
+              return (
+                <Tag>
+                  {intl.formatMessage({
+                    id: `auth.role.${entity.role}`,
+                  })}
+                </Tag>
+              );
+            },
+          },
+          {
+            listSlot: "actions",
+            search: false,
+            render: (_text, row, index) => {
+              const statusColor = getStatusColor(row.status);
+              const actions: TCourseMemberAction[] = [
+                "invite",
+                "revoke",
+                "accept",
+                "reject",
+                "block",
+              ];
+
+              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,
+                    course?.sign_up_start_at,
+                    course?.sign_up_end_at
+                  ),
+                };
+              });
+
+              return [
+                <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.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: () => {
+                                message.success(
+                                  intl.formatMessage({ id: "flashes.success" })
+                                );
+                                ref.current?.reload();
+                              },
+                            };
+                            setStatus(actionParam);
+                          }
+                        }
+                      },
+                    }}
+                  >
+                    <></>
+                  </Dropdown.Button>
+                ) : (
+                  <></>
+                ),
+              ];
+            },
+          },
+          {
+            // 自己扩展的字段,主要用于筛选,不在列表中显示
+            listSlot: "status",
+            title: "状态",
+            valueType: "checkbox",
+            valueEnum: {
+              joined: {
+                text: intl.formatMessage({
+                  id: "course.member.status.joined.label",
+                }),
+                status: "Default",
+              },
+              applied: {
+                text: intl.formatMessage({
+                  id: "course.member.status.applied.label",
+                }),
+                status: "Success",
+              },
+              invited: {
+                text: intl.formatMessage({
+                  id: "course.member.status.invited.label",
+                }),
+                status: "Success",
+              },
+              canceled: {
+                text: intl.formatMessage({
+                  id: "course.member.status.canceled.label",
+                }),
+                status: "Success",
+              },
+              revoked: {
+                text: intl.formatMessage({
+                  id: "course.member.status.revoked.label",
+                }),
+                status: "Success",
+              },
+              agreed: {
+                text: intl.formatMessage({
+                  id: "course.member.status.agreed.label",
+                }),
+                status: "Success",
+              },
+              accepted: {
+                text: intl.formatMessage({
+                  id: "course.member.status.accepted.label",
+                }),
+                status: "Success",
+              },
+              disagreed: {
+                text: intl.formatMessage({
+                  id: "course.member.status.disagreed.label",
+                }),
+                status: "Success",
+              },
+              rejected: {
+                text: intl.formatMessage({
+                  id: "course.member.status.rejected.label",
+                }),
+                status: "Success",
+              },
+              left: {
+                text: intl.formatMessage({
+                  id: "course.member.status.left.label",
+                }),
+                status: "Success",
+              },
+              blocked: {
+                text: intl.formatMessage({
+                  id: "course.member.status.blocked.label",
+                }),
+                status: "Success",
+              },
+            },
+          },
+          {
+            listSlot: "role",
+            // 自己扩展的字段,主要用于筛选,不在列表中显示
+            title: "角色",
+            valueType: "select",
+            valueEnum: {
+              student: {
+                text: intl.formatMessage({
+                  id: "auth.role.student",
+                }),
+                status: "Default",
+              },
+              manager: {
+                text: intl.formatMessage({
+                  id: "auth.role.manager",
+                }),
+                status: "Success",
+              },
+              assistant: {
+                text: intl.formatMessage({
+                  id: "auth.role.assistant",
+                }),
+                status: "Success",
+              },
+            },
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+
+          let url = `/api/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" &&
+            params.keyword.trim() !== ""
+          ) {
+            url += "&search=" + params.keyword;
+          }
+          if (params.role) {
+            url += `&role=${params.role}`;
+          }
+          if (params.status) {
+            url += `&status=${params.status}`;
+          }
+          console.info("api request", url);
+          const res = await get<ICourseMemberListResponse>(url);
+          if (res.ok) {
+            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) => {
+              const 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,
+                channel: item.channel,
+                tag: [],
+                image: "",
+              };
+
+              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,
+        }}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <CourseInvite
+            courseId={courseId}
+            onCreated={() => {
+              ref.current?.reload();
+            }}
+          />,
+          <Tooltip title="导出成员列表">
+            <a
+              href={`${import.meta.env.BASE_URL}/api/v2/course-member-export?course_id=${courseId}`}
+              target="_blank"
+              key="export"
+              rel="noreferrer"
+            >
+              <ExportOutlined />
+            </a>
+          </Tooltip>,
+        ]}
+      />
+    </>
+  );
+};
+
+export default CourseMemberListWidget;

+ 154 - 0
dashboard-v6/src/components/course/CourseMemberTimeLine.tsx

@@ -0,0 +1,154 @@
+import { useEffect, useRef } from "react";
+import { useIntl } from "react-intl";
+import { Space, Tag, Typography } from "antd";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+
+import { get } from "../../request";
+import type {
+  ICourseMemberData,
+  ICourseMemberListResponse,
+} from "../../api/course";
+import User from "../auth/User";
+import TimeShow from "../general/TimeShow";
+import { getStatusColor } from "./RolePower";
+
+const { Text } = Typography;
+
+interface IParams {
+  timeline?: string;
+}
+interface IWidget {
+  courseId?: string;
+  userId?: string;
+}
+
+const CourseMemberTimeLineWidget = ({ courseId, userId }: IWidget) => {
+  const intl = useIntl(); //i18n
+
+  const ref = useRef<ActionType | null>(null);
+
+  useEffect(() => {
+    ref.current?.reload();
+  }, [courseId, userId]);
+
+  return (
+    <>
+      <ProList<ICourseMemberData, IParams>
+        actionRef={ref}
+        search={{
+          filterType: "light",
+        }}
+        metas={{
+          avatar: {
+            render(_dom, entity) {
+              return <User {...entity.user} showName={false} />;
+            },
+            editable: false,
+          },
+          title: {
+            dataIndex: "name",
+            search: false,
+            render(_dom, entity) {
+              return entity.course ? (
+                <Text strong>{entity.course.title}</Text>
+              ) : (
+                entity.user?.nickName
+              );
+            },
+          },
+          description: {
+            dataIndex: "desc",
+            search: false,
+            render(_dom, entity) {
+              return (
+                <Space>
+                  <User {...entity.editor} showAvatar={false} />
+                  <TimeShow type="secondary" updatedAt={entity.updated_at} />
+                </Space>
+              );
+            },
+          },
+          subTitle: {
+            search: false,
+            render: (_: React.ReactNode, entity: ICourseMemberData) => {
+              return (
+                <Tag>
+                  {intl.formatMessage({
+                    id: `auth.role.${entity.role}`,
+                  })}
+                </Tag>
+              );
+            },
+          },
+          actions: {
+            search: false,
+            render: (_text, row) => {
+              const statusColor = getStatusColor(row.status);
+              return [
+                <span style={{ color: statusColor }}>
+                  {intl.formatMessage({
+                    id: `course.member.status.${row.status}.label`,
+                  })}
+                </span>,
+              ];
+            },
+          },
+          timeline: {
+            // 自己扩展的字段,主要用于筛选,不在列表中显示
+            title: "筛 选",
+            valueType: "select",
+            valueEnum: {
+              all: { text: intl.formatMessage({ id: "course.timeline.all" }) },
+              current: {
+                text: intl.formatMessage({ id: "course.timeline.current" }),
+              },
+            },
+          },
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.info("filter", params, sorter, filter);
+
+          let url = `/api/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 : "");
+          }
+          if (params.timeline) {
+            url += `&timeline=${params.timeline}&request_course=1`;
+          }
+          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;

+ 18 - 0
dashboard-v6/src/components/course/CourseNewLoading.tsx

@@ -0,0 +1,18 @@
+import { Skeleton } from "antd";
+
+const CourseNewLoading = () => {
+  return (
+    <div style={{ display: "flex", width: "100%" }}>
+      {[1, 1, 1, 1].map((item) => {
+        return (
+          <div style={{ height: 400, flex: 2 }} key={item}>
+            <Skeleton.Image active={true} />
+            <Skeleton title={{ width: 40 }} paragraph={{ rows: 2 }} active />
+          </div>
+        );
+      })}
+    </div>
+  );
+};
+
+export default CourseNewLoading;

+ 48 - 0
dashboard-v6/src/components/course/ExerciseAnswer.tsx

@@ -0,0 +1,48 @@
+import { useEffect, useState } from "react";
+import { Collapse, message } from "antd";
+
+import { get } from "../../request";
+import type { IArticleResponse } from "../../api/article";
+import MdView from "../general/MdView";
+
+const { Panel } = Collapse;
+
+interface IWidget {
+  courseId?: string;
+  articleId?: string;
+  exerciseId?: string;
+  mode?: string;
+  active?: boolean;
+}
+const ExerciseAnswerWidget = ({
+  courseId,
+  articleId,
+  exerciseId,
+  mode,
+  active = false,
+}: IWidget) => {
+  const [answer, setAnswer] = useState<string>();
+
+  useEffect(() => {
+    const url = `/api/v2/article/${articleId}?mode=${mode}&course=${courseId}&exercise=${exerciseId}&view=answer`;
+    get<IArticleResponse>(url).then((json) => {
+      console.log("article", json);
+      if (json.ok) {
+        setAnswer(json.data.html);
+      } else {
+        message.error(json.message);
+      }
+    });
+  }, [courseId, articleId, exerciseId, mode]);
+  return (
+    <div>
+      <Collapse defaultActiveKey={active ? ["answer"] : []}>
+        <Panel header="答案" key="answer">
+          <MdView html={answer} />
+        </Panel>
+      </Collapse>
+    </div>
+  );
+};
+
+export default ExerciseAnswerWidget;

+ 493 - 0
dashboard-v6/src/components/course/List.tsx

@@ -0,0 +1,493 @@
+import { useIntl } from "react-intl";
+import React, { useEffect, useRef, useState } from "react";
+import {
+  Space,
+  Badge,
+  Button,
+  Popover,
+  Dropdown,
+  Image,
+  message,
+  Modal,
+  Tag,
+} from "antd";
+import { ProTable, type ActionType } from "@ant-design/pro-components";
+import {
+  PlusOutlined,
+  DeleteOutlined,
+  ExclamationCircleOutlined,
+} from "@ant-design/icons";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { delete_, get } from "../../request";
+import {
+  actionMap,
+  type ICourseDataResponse,
+  type ICourseListResponse,
+  type ICourseNumberResponse,
+  type TCourseMemberAction,
+  type TCourseRole,
+} from "../../api/course";
+import type { IDeleteResponse } from "../../api/group";
+import { Link } from "react-router";
+import User from "../auth/User";
+import { PublicityValueEnum } from "../studio/table";
+import { getStatusColor, studentCanDo } from "./RolePower";
+import type { ItemType } from "antd/es/menu/interface";
+import { useSetStatus, type ISetStatus } from "./hooks/userActionUtils";
+import { getSorterUrl } from "../../utils";
+import CourseCreate from "./CourseCreate";
+
+const renderBadge = (count: number, active = false) => {
+  return (
+    <Badge
+      count={count}
+      style={{
+        marginBlockStart: -2,
+        marginInlineStart: 4,
+        color: active ? "#1890FF" : "#999",
+        backgroundColor: active ? "#E6F7FF" : "#eee",
+      }}
+    />
+  );
+};
+
+interface IWidget {
+  studioName?: string;
+}
+const List = ({ studioName }: IWidget) => {
+  const intl = useIntl(); //i18n
+
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("create");
+  const [createNumber, setCreateNumber] = useState<number>(0);
+  const [teachNumber, setTeachNumber] = useState<number>(0);
+  const [studyNumber, setStudyNumber] = useState<number>(0);
+  const ref = useRef<ActionType | null>(null);
+  const [openCreate, setOpenCreate] = useState(false);
+  const user = useAppSelector(currentUser);
+  const { setStatus } = useSetStatus();
+  useEffect(() => {
+    /**
+     * 获取各种课程的数量
+     */
+    const url = `/api/v2/course-my-course?studio=${studioName}`;
+    console.log("url", url);
+    get<ICourseNumberResponse>(url).then((json) => {
+      if (json.ok) {
+        setCreateNumber(json.data.create);
+        setTeachNumber(json.data.teach);
+        setStudyNumber(json.data.study);
+      }
+    });
+  }, [studioName]);
+
+  const showDeleteConfirm = (id: string, title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_<IDeleteResponse>(`/api/v2/course/${id}`)
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const canCreate = !(activeKey !== "create" || user?.roles?.includes("basic"));
+
+  const buttonEdit = (course: ICourseDataResponse, key: string | number) => {
+    const canManage: TCourseRole[] = [
+      "owner",
+      "teacher",
+      "manager",
+      "assistant",
+    ];
+    if (course.my_role && canManage.includes(course.my_role)) {
+      return (
+        <Link
+          to={`/workspace/course/${course.id}/setting`}
+          target="_blank"
+          key={key}
+        >
+          {intl.formatMessage({
+            //编辑
+            id: "buttons.setting",
+          })}
+        </Link>
+      );
+    } else {
+      return <></>;
+    }
+  };
+
+  return (
+    <>
+      <ProTable<ICourseDataResponse>
+        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.title.label",
+            }),
+            dataIndex: "title",
+            key: "title",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            width: 300,
+            render: (_, row, index) => {
+              return (
+                <Space key={index}>
+                  <Image
+                    src={
+                      row.cover_url && row.cover_url.length > 1
+                        ? row.cover_url[1]
+                        : ""
+                    }
+                    preview={{
+                      src:
+                        row.cover_url && row.cover_url.length > 0
+                          ? row.cover_url[0]
+                          : "",
+                    }}
+                    width={64}
+                    fallback={`${import.meta.env.BASE_URL}/app/course/img/default.jpg`}
+                  />
+                  <div>
+                    <div>
+                      <Link to={`/workspace/course/${row.id}`}>
+                        {row.title}
+                      </Link>
+                      <Tag>
+                        {intl.formatMessage({
+                          id: `course.join.mode.${row.join}.label`,
+                        })}
+                      </Tag>
+                      <Tag>
+                        {intl.formatMessage({
+                          id: `auth.role.${row.my_role}`,
+                        })}
+                      </Tag>
+                    </div>
+                    <div>{row.subtitle}</div>
+                    <div>
+                      <Space>
+                        {intl.formatMessage({
+                          id: "forms.fields.teacher.label",
+                        })}
+                        <User {...row.teacher} />
+                      </Space>
+                    </div>
+                  </div>
+                </Space>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "course.table.count.member.title",
+            }),
+            dataIndex: "member_count",
+            key: "member_count",
+            width: 80,
+          },
+          {
+            title: intl.formatMessage({
+              id: "course.table.count.progressing.title",
+            }),
+            dataIndex: "count_progressing",
+            key: "count_progressing",
+            width: 80,
+            hideInTable: activeKey === "study" ? true : false,
+          },
+          {
+            //类型
+            title: intl.formatMessage({
+              id: "forms.fields.type.label",
+            }),
+            dataIndex: "type",
+            key: "type",
+            width: 80,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: PublicityValueEnum(),
+          },
+
+          {
+            //创建时间
+            title: intl.formatMessage({
+              id: "forms.fields.created-at.label",
+            }),
+            key: "created-at",
+            width: 100,
+            search: false,
+            dataIndex: "created_at",
+            valueType: "date",
+            sorter: true,
+          },
+          {
+            //操作
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (_, row, index) => {
+              let mainButton = <></>;
+              switch (activeKey) {
+                case "create":
+                  mainButton = buttonEdit(row, index);
+                  break;
+                case "study":
+                  mainButton = (
+                    <span
+                      key={index}
+                      style={{ color: getStatusColor(row.my_status) }}
+                    >
+                      {intl.formatMessage({
+                        id: `course.member.status.${row.my_status}.label`,
+                      })}
+                    </span>
+                  );
+                  break;
+                case "teach":
+                  mainButton = (
+                    <Space>
+                      {buttonEdit(row, index)}
+                      <span
+                        key={index}
+                        style={{ color: getStatusColor(row.my_status) }}
+                      >
+                        {intl.formatMessage({
+                          id: `course.member.status.${row.my_status}.label`,
+                        })}
+                      </span>
+                    </Space>
+                  );
+                  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.my_status,
+                      row.sign_up_start_at,
+                      row.sign_up_end_at
+                    ),
+                  };
+                });
+              }
+
+              return [
+                <Dropdown.Button
+                  key={index}
+                  type="link"
+                  menu={{
+                    items:
+                      activeKey === "create"
+                        ? [
+                            {
+                              key: "remove",
+                              label: intl.formatMessage({
+                                id: "buttons.delete",
+                              }),
+                              icon: <DeleteOutlined />,
+                              danger: true,
+                            },
+                          ]
+                        : userItems,
+                    onClick: (e) => {
+                      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.my_status_id,
+                            message: intl.formatMessage(
+                              {
+                                id: `course.member.status.${currAction}.message`,
+                              },
+                              { course: row.title }
+                            ),
+                            status: newStatus,
+                            onSuccess: () => {
+                              message.success(
+                                intl.formatMessage({ id: "flashes.success" })
+                              );
+                              ref.current?.reload();
+                            },
+                          };
+                          setStatus(actionParam);
+                        }
+                      }
+                    },
+                  }}
+                >
+                  {mainButton}
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        //从服务端获取数据
+        request={async (params = {}, sorter, filter) => {
+          console.debug(params, sorter, filter);
+          console.info(activeKey);
+          let url = `/api/v2/course?view=${activeKey}&studio=${studioName}`;
+          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 : "");
+          }
+          url += getSorterUrl(sorter);
+          console.info("api request", url);
+          const res = await get<ICourseListResponse>(url);
+          console.debug("api response", res);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: res.data.rows,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          canCreate ? (
+            <Popover
+              content={
+                <CourseCreate
+                  studio={studioName}
+                  onCreate={() => {
+                    //新建课程成功后刷新
+                    setActiveKey("create");
+                    setCreateNumber(createNumber + 1);
+                    ref.current?.reload();
+                    setOpenCreate(false);
+                  }}
+                />
+              }
+              title="Create"
+              placement="bottomRight"
+              trigger="click"
+              open={openCreate}
+              onOpenChange={(newOpen: boolean) => {
+                setOpenCreate(newOpen);
+              }}
+            >
+              <Button key="button" icon={<PlusOutlined />} type="primary">
+                {intl.formatMessage({ id: "buttons.create" })}
+              </Button>
+            </Popover>
+          ) : (
+            <></>
+          ),
+        ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: "create",
+                label: (
+                  <span>
+                    我建立的课程
+                    {renderBadge(createNumber, activeKey === "create")}
+                  </span>
+                ),
+              },
+              {
+                key: "study",
+                label: (
+                  <span>
+                    我参加的课程
+                    {renderBadge(studyNumber, activeKey === "study")}
+                  </span>
+                ),
+              },
+              {
+                key: "teach",
+                label: (
+                  <span>
+                    我任教的课程
+                    {renderBadge(teachNumber, activeKey === "teach")}
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              console.log("show course", key);
+              setActiveKey(key);
+              ref.current?.reload();
+            },
+          },
+        }}
+      />
+    </>
+  );
+};
+
+export default List;

+ 401 - 0
dashboard-v6/src/components/course/RolePower.ts

@@ -0,0 +1,401 @@
+import dayjs from "dayjs";
+import isBetween from "dayjs/plugin/isBetween";
+
+import type {
+  TCourseJoinMode,
+  TCourseMemberAction,
+  TCourseMemberStatus,
+} from "../../api/course";
+
+dayjs.extend(isBetween);
+
+export interface IAction {
+  mode: TCourseJoinMode[];
+  status: TCourseMemberStatus;
+  signUp?: TCourseMemberAction[];
+  before: TCourseMemberAction[];
+  duration: TCourseMemberAction[];
+  after: TCourseMemberAction[];
+}
+
+export const getStudentActionsByStatus = (
+  status?: TCourseMemberStatus,
+  mode?: TCourseJoinMode,
+  startAt?: string,
+  endAt?: string,
+  signUpStartAt?: string,
+  signUpEndAt?: string
+): TCourseMemberAction[] | undefined => {
+  const output = getActionsByStatus(
+    studentData,
+    status,
+    mode,
+    startAt,
+    endAt,
+    signUpStartAt,
+    signUpEndAt
+  );
+  console.log("getStudentActionsByStatus", output);
+  return output;
+};
+const getActionsByStatus = (
+  data: IAction[],
+  status?: TCourseMemberStatus,
+  mode?: TCourseJoinMode,
+  startAt?: string,
+  endAt?: string,
+  signUpStartAt?: string,
+  signUpEndAt?: string
+): TCourseMemberAction[] | undefined => {
+  console.debug("getActionsByStatus start");
+  if (!startAt || !endAt || !mode || !status) {
+    return undefined;
+  }
+  const inSignUp = dayjs().isBetween(dayjs(signUpStartAt), dayjs(signUpEndAt));
+  const actions = data.find((value) => {
+    if (value.mode.includes(mode) && value.status === status) {
+      if (inSignUp) {
+        if (value.signUp && value.signUp.length > 0) {
+          console.debug("getActionsByStatus got it", value.signUp);
+          return true;
+        }
+      }
+      if (dayjs().isBefore(dayjs(startAt))) {
+        if (value.before && value.before.length > 0) {
+          return true;
+        }
+      }
+      if (dayjs().isBefore(dayjs(endAt))) {
+        if (value.duration && value.duration.length > 0) {
+          return true;
+        }
+      }
+      if (value.after && value.after.length > 0) {
+        return true;
+      }
+    }
+    return false;
+  });
+
+  if (actions) {
+    if (inSignUp && actions.signUp && actions.signUp.length > 0) {
+      return actions.signUp;
+    } else if (dayjs().isBefore(dayjs(startAt))) {
+      return actions.before;
+    } else if (dayjs().isBefore(dayjs(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,
+  signUpStartAt?: string,
+  signUpEndAt?: string
+): boolean => {
+  if (!startAt || !endAt || !mode || !status) {
+    return false;
+  }
+  const canDo = getActionsByStatus(
+    data,
+    status,
+    mode,
+    startAt,
+    endAt,
+    signUpStartAt,
+    signUpEndAt
+  )?.includes(action);
+
+  if (canDo) {
+    return true;
+  } else {
+    return false;
+  }
+};
+
+export const managerCanDo = (
+  action: TCourseMemberAction,
+  startAt?: string,
+  endAt?: string,
+  mode?: TCourseJoinMode,
+  status?: TCourseMemberStatus,
+  signUpStartAt?: string,
+  signUpEndAt?: string
+): boolean => {
+  if (!startAt || !endAt || !mode || !status) {
+    return false;
+  }
+
+  return test(
+    managerData,
+    action,
+    startAt,
+    endAt,
+    mode,
+    status,
+    signUpStartAt,
+    signUpEndAt
+  );
+};
+
+export const studentCanDo = (
+  action: TCourseMemberAction,
+  startAt?: string,
+  endAt?: string,
+  mode?: TCourseJoinMode,
+  status?: TCourseMemberStatus,
+  signUpStartAt?: string,
+  signUpEndAt?: string
+): boolean => {
+  if (!startAt || !endAt || !mode || !status) {
+    return false;
+  }
+
+  return test(
+    studentData,
+    action,
+    startAt,
+    endAt,
+    mode,
+    status,
+    signUpStartAt,
+    signUpEndAt
+  );
+};
+
+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",
+    signUp: ["join"],
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["open"],
+    status: "applied",
+    before: ["leave"],
+    duration: ["leave"],
+    after: [],
+  },
+  {
+    mode: ["open"],
+    status: "joined",
+    before: ["leave"],
+    duration: ["leave"],
+    after: [],
+  },
+  {
+    mode: ["open"],
+    status: "left",
+    signUp: ["join"],
+    before: [],
+    duration: ["join"],
+    after: [],
+  },
+  {
+    mode: ["open"],
+    status: "invited",
+    signUp: ["agree", "disagree"],
+    before: ["agree", "disagree"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "none",
+    signUp: ["apply"],
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "invited",
+    signUp: ["agree", "disagree"],
+    before: ["agree", "disagree"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "revoked",
+    signUp: ["apply"],
+    before: [],
+    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",
+    signUp: ["apply"],
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "agreed",
+    before: ["leave"],
+    duration: ["leave"],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "disagreed",
+    signUp: ["apply"],
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "left",
+    before: ["apply"],
+    duration: [],
+    after: [],
+  },
+];
+
+const managerData: IAction[] = [
+  {
+    mode: ["manual", "invite"],
+    status: "none",
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "invited",
+    before: ["revoke"],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "revoked",
+    before: [],
+    duration: [],
+    after: [],
+  },
+  {
+    mode: ["manual", "invite"],
+    status: "canceled",
+    signUp: [],
+    before: [],
+    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: ["accept", "reject"],
+    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: [],
+  },
+];

+ 127 - 0
dashboard-v6/src/components/course/SelectChannel.tsx

@@ -0,0 +1,127 @@
+import { ModalForm, ProForm, ProFormSelect } from "@ant-design/pro-components";
+import { Alert, Button, message } from "antd";
+import { GlobalOutlined } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+
+import { get, put } from "../../request";
+
+import { LockIcon } from "../../assets/icon";
+import type {
+  ICourseMemberData,
+  ICourseMemberResponse,
+} from "../../api/course";
+import { useIntl } from "react-intl";
+import type { IApiResponseChannelList } from "../../api/channel";
+
+interface IWidget {
+  courseId?: string | null;
+  exerciseId?: string;
+  channel?: string;
+  open?: boolean;
+  onSelected?: () => void;
+  onOpenChange?: (visible: boolean) => void;
+}
+const SelectChannelWidget = ({
+  courseId,
+  onSelected,
+  onOpenChange,
+}: IWidget) => {
+  const user = useAppSelector(_currentUser);
+  const intl = useIntl();
+
+  return (
+    <Alert
+      message={`请选择作业的存放位置`}
+      type="warning"
+      action={
+        <ModalForm<{
+          channel: string;
+        }>
+          title="请选择作业的存放位置"
+          trigger={
+            <Button type="primary">
+              {intl.formatMessage({
+                id: "buttons.select",
+              })}
+            </Button>
+          }
+          autoFocusFirstInput
+          modalProps={{
+            destroyOnHidden: true,
+            onCancel: () => console.log("run"),
+          }}
+          submitTimeout={20000}
+          onFinish={async (values) => {
+            if (user && courseId) {
+              const url = `/api/v2/course-member_set-channel`;
+              const data = {
+                user_id: user.id,
+                course_id: courseId,
+                channel_id: values.channel,
+              };
+              console.info("course select channel api request", url, data);
+              const json = await put<ICourseMemberData, ICourseMemberResponse>(
+                url,
+                data
+              );
+              console.info("course select channel api response", json);
+              if (json.ok) {
+                message.success("提交成功");
+                if (typeof onSelected !== "undefined") {
+                  onSelected();
+                }
+              } else {
+                message.error(json.message);
+                return false;
+              }
+            } else {
+              console.log("select channel error:", user, courseId);
+              return false;
+            }
+
+            return true;
+          }}
+          onOpenChange={onOpenChange}
+        >
+          <div>
+            您还没有选择版本。您将用一个版本保存自己的作业。这个版本里面的内容将会被老师,助理老师看到。
+          </div>
+          <ProForm.Group>
+            <ProFormSelect
+              rules={[
+                {
+                  required: true,
+                },
+              ]}
+              request={async () => {
+                const channelData = await get<IApiResponseChannelList>(
+                  `/api/v2/channel?view=studio&name=${user?.realName}`
+                );
+                const channel = channelData.data.rows.map((item) => {
+                  const icon =
+                    item.status === 30 ? <GlobalOutlined /> : <LockIcon />;
+                  return {
+                    value: item.uid,
+                    label: (
+                      <>
+                        {icon} {item.name}
+                      </>
+                    ),
+                  };
+                });
+                return channel;
+              }}
+              width="md"
+              name="channel"
+              label="版本风格"
+            />
+          </ProForm.Group>
+        </ModalForm>
+      }
+    />
+  );
+};
+
+export default SelectChannelWidget;

+ 104 - 0
dashboard-v6/src/components/course/SignUp.tsx

@@ -0,0 +1,104 @@
+/**
+ * 报名按钮
+ * 已经报名显示报名状态
+ * 未报名显示报名按钮以及必要的提示
+ */
+import { Button, message, Modal, Typography } from "antd";
+
+import { useIntl } from "react-intl";
+import { ExclamationCircleFilled } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import { post } from "../../request";
+import type {
+  ICourseMemberData,
+  ICourseMemberResponse,
+  TCourseExpRequest,
+  TCourseJoinMode,
+} from "../../api/course";
+import Marked from "../general/Marked";
+
+const { confirm } = Modal;
+const { Text } = Typography;
+
+interface IWidget {
+  courseId: string;
+  startAt?: string;
+  signUpMessage?: string | null;
+  joinMode?: TCourseJoinMode;
+  expRequest?: TCourseExpRequest;
+  onStatusChanged?: (data: ICourseMemberData) => void;
+}
+const SignUpWidget = ({
+  courseId,
+  signUpMessage,
+  joinMode,
+  expRequest,
+  onStatusChanged,
+}: IWidget) => {
+  const user = useAppSelector(_currentUser);
+  const intl = useIntl();
+
+  return (
+    <Button
+      type="primary"
+      onClick={() => {
+        confirm({
+          title: "你想要报名课程吗?",
+          icon: <ExclamationCircleFilled />,
+          content: (
+            <div>
+              <div>
+                {signUpMessage ? (
+                  <Marked text={signUpMessage} />
+                ) : (
+                  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>(
+              "/api/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);
+                  if (typeof onStatusChanged !== "undefined") {
+                    onStatusChanged(json.data);
+                  }
+                  message.success(
+                    intl.formatMessage({ id: "flashes.success" })
+                  );
+                } else {
+                  message.error(json.message);
+                }
+              })
+              .catch((error) => {
+                message.error(error);
+              });
+          },
+        });
+      }}
+    >
+      报名
+    </Button>
+  );
+};
+
+export default SignUpWidget;

+ 129 - 0
dashboard-v6/src/components/course/Status.tsx

@@ -0,0 +1,129 @@
+/**
+ * 报名按钮
+ * 已经报名显示报名状态
+ * 未报名显示报名按钮以及必要的提示
+ */
+import { Space, Typography } from "antd";
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+
+import { get } from "../../request";
+import type {
+  ICourseDataResponse,
+  ICourseMemberData,
+  ICourseMemberResponse,
+  TCourseMemberStatus,
+} from "../../api/course";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import UserAction from "./UserAction";
+import { getStatusColor, getStudentActionsByStatus } from "./RolePower";
+import LoginButton from "../auth/LoginButton";
+
+const { Paragraph, Text } = Typography;
+
+interface IWidget {
+  data?: ICourseDataResponse;
+}
+const StatusWidget = ({ data }: IWidget) => {
+  const intl = useIntl();
+  const [currMember, setCurrMember] = useState<ICourseMemberData>();
+  const user = useAppSelector(currentUser);
+
+  const numberOfStudent = data?.members?.filter(
+    (value) =>
+      value.role === "student" &&
+      (value.status === "accepted" ||
+        value.status === "applied" ||
+        value.status === "joined")
+  ).length;
+
+  useEffect(() => {
+    /**
+     * 获取该课程我的报名状态
+     */
+    if (typeof data?.id === "undefined") {
+      return;
+    }
+    const url = `/api/v2/course-member/${data?.id}`;
+    console.info("api request", url);
+    get<ICourseMemberResponse>(url).then((json) => {
+      console.debug("course member", json);
+      if (json.ok) {
+        setCurrMember(json.data);
+      }
+    });
+  }, [data?.id]);
+
+  let labelStatus = "";
+
+  let operation: React.ReactNode | undefined;
+
+  let currStatus: TCourseMemberStatus = "none";
+  if (currMember?.status) {
+    currStatus = currMember.status;
+  }
+  const actions = getStudentActionsByStatus(
+    currStatus,
+    data?.join,
+    data?.start_at,
+    data?.end_at,
+    data?.sign_up_start_at,
+    data?.sign_up_end_at
+  );
+  console.debug("getStudentActionsByStatus", currStatus, data?.join, actions);
+  if (user) {
+    labelStatus = intl.formatMessage({
+      id: `course.member.status.${currStatus}.label`,
+    });
+    operation = (
+      <Space>
+        {actions?.map((item, id) => {
+          if (item === "apply" && data?.number !== 0) {
+            if (
+              numberOfStudent &&
+              data?.number &&
+              numberOfStudent >= data?.number
+            ) {
+              return <Text type="danger">{"名额已满"}</Text>;
+            }
+          }
+          return (
+            <UserAction
+              key={id}
+              action={item}
+              currUser={currMember}
+              courseId={data?.id}
+              courseName={data?.title}
+              signUpMessage={data?.sign_up_message}
+              user={{
+                id: user.id,
+                nickName: user.nickName,
+                userName: user.realName,
+              }}
+              onStatusChanged={(status: ICourseMemberData | undefined) => {
+                setCurrMember(status);
+              }}
+            />
+          );
+        })}
+      </Space>
+    );
+  } else {
+    //未登录
+    labelStatus = "未登录";
+    operation = <LoginButton target="_blank" />;
+  }
+
+  return data?.id ? (
+    <Paragraph>
+      <div style={{ color: getStatusColor(currStatus) }}>{labelStatus}</div>
+      {operation}
+    </Paragraph>
+  ) : (
+    <></>
+  );
+};
+
+export default StatusWidget;

+ 39 - 0
dashboard-v6/src/components/course/TextBook.tsx

@@ -0,0 +1,39 @@
+import { Col, Row } from "antd";
+import { useNavigate } from "react-router";
+
+import { fullUrl } from "../../utils";
+import AnthologyDetail from "../anthology/AnthologyDetail";
+import type { TTarget } from "../../types";
+
+interface IWidget {
+  anthologyId?: string;
+  courseId?: string;
+}
+const TextBookWidget = ({ anthologyId, courseId }: IWidget) => {
+  const navigate = useNavigate();
+
+  console.log("anthologyId", anthologyId);
+  return (
+    <div style={{ backgroundColor: "#f5f5f5" }}>
+      <Row>
+        <Col flex="auto"></Col>
+        <Col flex="960px">
+          <AnthologyDetail
+            aid={anthologyId}
+            onArticleClick={(_, articleId: string, target?: TTarget) => {
+              const url = `/article/textbook/${articleId}?mode=read&course=${courseId}`;
+              if (target === "_blank") {
+                window.open(fullUrl(url), "_blank");
+              } else {
+                navigate(url);
+              }
+            }}
+          />
+        </Col>
+        <Col flex="auto"></Col>
+      </Row>
+    </div>
+  );
+};
+
+export default TextBookWidget;

+ 83 - 0
dashboard-v6/src/components/course/UserAction.tsx

@@ -0,0 +1,83 @@
+import { Button } from "antd";
+import { useIntl } from "react-intl";
+import {
+  type ICourseMemberData,
+  type TCourseMemberAction,
+  actionMap,
+} from "../../api/course";
+import type { IUser } from "../../api/Auth";
+
+import { type ISetStatus } from "./hooks/userActionUtils";
+import { useSetStatus } from "./hooks/userActionUtils";
+
+interface IWidget {
+  action: TCourseMemberAction;
+  currUser?: ICourseMemberData;
+  courseId?: string;
+  courseName?: string;
+  signUpMessage?: string | null;
+  user?: IUser;
+  onStatusChanged?: (status?: ICourseMemberData) => void;
+}
+
+const UserActionWidget = ({
+  action,
+  currUser,
+  courseId,
+  courseName,
+  signUpMessage,
+  user,
+  onStatusChanged,
+}: IWidget) => {
+  const intl = useIntl();
+  const { setStatus } = useSetStatus();
+
+  const statusChange = (status: ICourseMemberData | undefined) => {
+    onStatusChanged?.(status);
+  };
+
+  const status = actionMap(action);
+  const buttonDisable = !currUser?.id && !(courseId && user);
+
+  let courseMessage = intl.formatMessage(
+    { id: `course.member.status.${action}.message` },
+    { course: courseName }
+  );
+  if ((action === "apply" || action === "join") && signUpMessage) {
+    courseMessage = signUpMessage;
+  }
+
+  const handleClick = () => {
+    const actionParam: ISetStatus = {
+      courseMemberId: currUser?.id,
+      courseId,
+      user,
+      message: courseMessage,
+      status: status!,
+      onSuccess: (data: ICourseMemberData) => {
+        statusChange(data);
+      },
+    };
+    // 直接调用 hook 返回的 setStatus,不再手写 modal.confirm
+    setStatus(actionParam);
+  };
+
+  if (!status) return <></>;
+
+  return (
+    <Button
+      disabled={buttonDisable}
+      type={
+        action === "join" || action === "apply" || action === "agree"
+          ? "primary"
+          : undefined
+      }
+      danger={action === "disagree" || action === "leave"}
+      onClick={handleClick}
+    >
+      {intl.formatMessage({ id: `course.member.status.${action}.button` })}
+    </Button>
+  );
+};
+
+export default UserActionWidget;

+ 88 - 0
dashboard-v6/src/components/course/hooks/useCourse.ts

@@ -0,0 +1,88 @@
+// /src/hooks/useCourse.ts
+
+/**
+ * useCourse
+ *
+ * 获取单个课程详情
+ *
+ * @param courseId 课程 ID
+ *
+ * @returns
+ *   - data         课程数据,未请求或失败时为 null
+ *   - loading      请求进行中
+ *   - errorCode    HTTP 错误码,无错误时为 null
+ *   - errorMessage 后端错误信息,无错误时为 null
+ *   - refresh      手动重新请求
+ *
+ * @example
+ * const { data, loading, errorCode } = useCourse(id);
+ */
+
+import { useState, useEffect, useCallback } from "react";
+
+import { fetchCourse } from "../../../api/course";
+import type { ICourseDataResponse } from "../../../api/course";
+import { HttpError } from "../../../request";
+
+interface IUseCourseReturn {
+  data: ICourseDataResponse | null;
+  loading: boolean;
+  errorCode: number | null;
+  errorMessage: string | null;
+  refresh: () => void;
+}
+
+export const useCourse = (courseId?: string): IUseCourseReturn => {
+  const [data, setData] = useState<ICourseDataResponse | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [tick, setTick] = useState(0);
+
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  useEffect(() => {
+    if (!courseId) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      setLoading(true);
+      setErrorCode(null);
+      setErrorMessage(null);
+
+      try {
+        const res = await fetchCourse(courseId);
+        if (!active) return;
+
+        if (!res.ok) {
+          setErrorCode(400);
+          setErrorMessage(res.message);
+          return;
+        }
+
+        setData(res.data);
+      } catch (e) {
+        console.error("course fetch", e);
+        if (!active) return;
+        if (e instanceof HttpError) {
+          setErrorCode(e.status);
+          setErrorMessage(e.message);
+        } else {
+          setErrorCode(0);
+          setErrorMessage("Network error");
+        }
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [courseId, tick]);
+
+  return { data, loading, errorCode, errorMessage, refresh };
+};

+ 83 - 0
dashboard-v6/src/components/course/hooks/userActionUtils.tsx

@@ -0,0 +1,83 @@
+// userActionUtils.ts
+import { App } from "antd";
+
+import type {
+  ICourseMemberData,
+  ICourseMemberResponse,
+  TCourseMemberStatus,
+} from "../../../api/course";
+import { post, put } from "../../../request";
+import type { IUser } from "../../../api/Auth";
+import Marked from "../../general/Marked";
+
+import { ExclamationCircleFilled } from "@ant-design/icons";
+
+export interface ISetStatus {
+  courseMemberId?: string;
+  courseId?: string;
+  courseName?: string;
+  user?: IUser;
+  message?: string;
+  status: TCourseMemberStatus;
+  onSuccess?: (data: ICourseMemberData) => void;
+  onError?: (message: string) => void;
+}
+
+export const statusQuery = ({
+  courseMemberId,
+  courseId,
+  user,
+  status,
+}: ISetStatus) => {
+  let url = "/api/v2/course-member/";
+  let data: ICourseMemberData;
+  if (courseMemberId) {
+    url += courseMemberId;
+    data = { user_id: "", course_id: "", status };
+    return put<ICourseMemberData, ICourseMemberResponse>(url, data);
+  } else {
+    data = {
+      user_id: user?.id ?? "",
+      role: "student",
+      course_id: courseId ?? "",
+      status,
+    };
+    return post<ICourseMemberData, ICourseMemberResponse>(url, data);
+  }
+};
+
+// ✅ 导出 hook,供各组件使用
+export const useSetStatus = () => {
+  const { modal } = App.useApp();
+
+  const setStatus = ({
+    status,
+    courseMemberId,
+    courseId,
+    user,
+    message,
+    onSuccess,
+    onError,
+  }: ISetStatus) => {
+    modal.confirm({
+      icon: <ExclamationCircleFilled />,
+      content: <Marked text={message} />,
+      onOk() {
+        return statusQuery({ status, courseMemberId, courseId, user })
+          .then((json) => {
+            if (json.ok) {
+              onSuccess?.(json.data);
+            } else {
+              onError?.(json.message);
+            }
+          })
+          .catch((error) => {
+            console.error(error);
+            onError?.(error);
+          });
+      },
+    });
+  };
+
+  return { setStatus };
+};

+ 133 - 0
dashboard-v6/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;
+报名 --> 录取 --> 学员退出
+报名 --> 不录取
+
+邀请 --> 同意 --> 学员退出
+邀请 --> 不同意
+```

+ 88 - 0
dashboard-v6/src/pages/workspace/course/edit.tsx

@@ -0,0 +1,88 @@
+import { useState } from "react";
+import { useParams } from "react-router";
+import { useIntl } from "react-intl";
+import { Card, Tabs } from "antd";
+
+import CourseInfoEdit from "../../../components/course/CourseInfoEdit";
+import CourseMemberList, {
+  type ICourseMember,
+} from "../../../components/course/CourseMemberList";
+import CourseMemberTimeLine from "../../../components/course/CourseMemberTimeLine";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const intl = useIntl();
+  const { courseId } = useParams(); //url 参数
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+  const [title, setTitle] = useState("loading");
+  const [selected, setSelected] = useState<string>();
+  return (
+    <>
+      <title>{title}</title>
+      <Card>
+        <Tabs
+          defaultActiveKey="info"
+          items={[
+            {
+              key: "info",
+              label: intl.formatMessage({ id: "course.basic.info.label" }),
+              children: (
+                <CourseInfoEdit
+                  studioName={studioName}
+                  courseId={courseId}
+                  onTitleChange={(title: string) => {
+                    setTitle(title);
+                    document.title = `${title}`;
+                  }}
+                />
+              ),
+            },
+            {
+              key: "member",
+              label: intl.formatMessage({
+                id: "auth.role.member",
+              }),
+              children: (
+                <div style={{ display: "flex" }}>
+                  <div style={{ flex: 3 }}>
+                    <CourseMemberList
+                      courseId={courseId}
+                      onSelect={(value: ICourseMember) => {
+                        setSelected(value.user?.id);
+                      }}
+                    />
+                  </div>
+                  <div style={{ flex: 2 }}>
+                    <Tabs
+                      items={[
+                        {
+                          key: "timeline",
+                          label: intl.formatMessage({
+                            id: "course.member.timeline",
+                          }),
+                          children:
+                            courseId && selected ? (
+                              <CourseMemberTimeLine
+                                courseId={courseId}
+                                userId={selected}
+                              />
+                            ) : (
+                              <>{"未选择"}</>
+                            ),
+                        },
+                      ]}
+                    />
+                  </div>
+                </div>
+              ),
+            },
+          ]}
+        />
+      </Card>
+    </>
+  );
+};
+
+export default Widget;

+ 12 - 0
dashboard-v6/src/pages/workspace/course/index.tsx

@@ -0,0 +1,12 @@
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+import List from "../../../components/course/List";
+
+const Widget = () => {
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+  return <List studioName={studioName} />;
+};
+
+export default Widget;

+ 12 - 0
dashboard-v6/src/pages/workspace/course/show.tsx

@@ -0,0 +1,12 @@
+//课程详情页面
+import { useParams } from "react-router";
+
+import Course from "../../../components/course/Course";
+
+const Widget = () => {
+  const { courseId } = useParams(); //url 参数
+
+  return <Course id={courseId} />;
+};
+
+export default Widget;

+ 1 - 1
dashboard-v6/src/reducers/course-user.ts

@@ -1,7 +1,7 @@
 import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
-import type { TCourseRole } from "../api/Course";
+import type { TCourseRole } from "../api/course";
 
 export const ROLE_ROOT = "root";
 export const ROLE_ASSISTANT = "assistant";

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

@@ -1,7 +1,7 @@
 import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
-import type { ICourseDataResponse, ICourseMemberData } from "../api/Course";
+import type { ICourseDataResponse, ICourseMemberData } from "../api/course";
 
 export interface ITextbook {
   course?: ICourseDataResponse;

+ 1 - 1
dashboard-v6/src/routes/anthologyRoutes.ts

@@ -1,7 +1,7 @@
 // src/routes/anthologyRoutes.ts
 import { lazy } from "react";
 import type { RouteObject } from "react-router";
-import { anthologyLoader, articleLoader } from "../api/article";
+import { anthologyLoader } from "../api/article";
 
 const WorkspaceAnthologyList = lazy(
   () => import("../pages/workspace/anthology")

+ 46 - 0
dashboard-v6/src/routes/courseRoutes.ts

@@ -0,0 +1,46 @@
+// src/routes/courseRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+
+import { courseLoader } from "../api/course";
+
+const WorkspaceCourseList = lazy(() => import("../pages/workspace/course"));
+const WorkspaceCourseShow = lazy(
+  () => import("../pages/workspace/course/show")
+);
+const WorkspaceCourseSetting = lazy(
+  () => import("../pages/workspace/course/edit")
+);
+
+const courseRoutes: RouteObject[] = [
+  {
+    path: "course",
+    handle: { id: "workspace.course", crumb: "course" },
+    children: [
+      {
+        index: true,
+        Component: WorkspaceCourseList,
+      },
+      {
+        path: ":courseId",
+        loader: courseLoader,
+        handle: {
+          crumb: (match: { data: { title: string } }) => match.data.title,
+        },
+        children: [
+          {
+            index: true,
+            Component: WorkspaceCourseShow,
+          },
+          {
+            path: "setting",
+            Component: WorkspaceCourseSetting,
+            handle: { id: "workspace.course.setting", crumb: "setting" },
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export default courseRoutes;