2
0
Эх сурвалжийг харах

Merge branch 'agile' of https://github.com/visuddhinanda/mint into agile

visuddhinanda 3 жил өмнө
parent
commit
65738d8625
29 өөрчлөгдсөн 2022 нэмэгдсэн , 198 устгасан
  1. 1 0
      cpp/out/build/x64-Debug/VSInheritEnvironments.txt
  2. 2 1
      dashboard-fluent/src/locales/zh-Hans/forms.ts
  3. 2 2
      dashboard/.env.orig
  4. 7 2
      dashboard/src/Router.tsx
  5. 73 0
      dashboard/src/components/api/Course.ts
  6. 76 0
      dashboard/src/components/library/course/AddLesson.tsx
  7. 86 0
      dashboard/src/components/library/course/AddStudent.tsx
  8. 76 0
      dashboard/src/components/library/course/AddTeacher.tsx
  9. 61 0
      dashboard/src/components/library/course/CourseCreate.tsx
  10. 51 0
      dashboard/src/components/library/course/CourseIntro.tsx
  11. 62 0
      dashboard/src/components/library/course/CourseList.tsx
  12. 45 0
      dashboard/src/components/library/course/CourseShow.tsx
  13. 116 0
      dashboard/src/components/library/course/LecturerList.tsx
  14. 82 0
      dashboard/src/components/library/course/LessonSelect.tsx
  15. 55 0
      dashboard/src/components/library/course/LessonTreeShow.tsx
  16. 98 0
      dashboard/src/components/library/course/StudentsSelect.tsx
  17. 82 0
      dashboard/src/components/library/course/TeacherSelect.tsx
  18. 66 0
      dashboard/src/components/library/course/UploadTexture.tsx
  19. 137 136
      dashboard/src/components/studio/LeftSider.tsx
  20. 1 0
      dashboard/src/locales/zh-Hans/buttons.ts
  21. 8 1
      dashboard/src/locales/zh-Hans/forms.ts
  22. 1 0
      dashboard/src/locales/zh-Hans/index.ts
  23. 76 11
      dashboard/src/pages/library/course/course.tsx
  24. 102 17
      dashboard/src/pages/library/course/lesson.tsx
  25. 77 16
      dashboard/src/pages/library/course/list.tsx
  26. 230 7
      dashboard/src/pages/studio/course/edit.tsx
  27. 336 2
      dashboard/src/pages/studio/course/list.tsx
  28. 10 3
      spring/README.md
  29. 3 0
      spring/Vagrantfile

+ 1 - 0
cpp/out/build/x64-Debug/VSInheritEnvironments.txt

@@ -0,0 +1 @@
+msvc_x64_x64

+ 2 - 1
dashboard-fluent/src/locales/zh-Hans/forms.ts

@@ -4,12 +4,13 @@ const items = {
 	"forms.fields.id.label": "ID",
 	"forms.fields.message.label": "消息",
 	"forms.fields.created-at.label": "创建时间",
-	"forms.fields.update-at.label": "创建时间",
+	"forms.fields.update-at.label": "更新时间",
 	"forms.fields.lang.label": "语言",
 	"forms.create.message.no.lang": "请选择一种语言",
 	"forms.fields.title.label": "标题",
 	"forms.create.message.no.title": "请输入标题",
 	"forms.fields.subtitle.label": "副标题",
+	"forms.fields.teacher.label": "主讲人",
 	"forms.fields.summary.label": "简介",
 	"forms.fields.content.label": "内容",
 	"forms.fields.tag.label": "标签",

+ 2 - 2
dashboard/.env.orig

@@ -2,10 +2,10 @@ GENERATE_SOURCEMAP=false
 BROWSER=none
 PUBLIC_URL=/my
 HOST=0.0.0.0
-PORT=20080
+PORT=20139
 
 #REACT_APP_GRPC_HOST=http://127.0.0.1:10012
 REACT_APP_DEFAULT_LOCALE=zh-Hans
 REACT_APP_LANGUAGES=en-US,zh-Hans,zh-Hant
-REACT_APP_API_HOST=https://jeremy.spring.wikipali.org
+REACT_APP_API_HOST=https://crixus.spring.wikipali.org
 REACT_APP_ENABLE_LOCAL_TOKEN=true

+ 7 - 2
dashboard/src/Router.tsx

@@ -27,6 +27,7 @@ import LibraryCourse from "./pages/library/course";
 import LibraryCourseList from "./pages/library/course/list";
 import LibraryCourseShow from "./pages/library/course/course";
 import LibraryLessonShow from "./pages/library/course/lesson";
+//import LibraryCourseManage from "./pages/library/course/courseManage";
 import LibraryTerm from "./pages/library/term/show";
 import LibraryDict from "./pages/library/dict";
 import LibraryDictShow from "./pages/library/dict/show";
@@ -75,6 +76,10 @@ import StudioDictList from "./pages/studio/dict/list";
 import StudioTerm from "./pages/studio/term";
 import StudioTermList from "./pages/studio/term/list";
 
+import StudioCourse from "./pages/studio/course";
+import StudioCourseList from "./pages/studio/course/list";
+import StudioCourseEdit from "./pages/studio/course/edit";
+
 import StudioArticle from "./pages/studio/article";
 import StudioArticleList from "./pages/studio/article/list";
 import StudioArticleEdit from "./pages/studio/article/edit";
@@ -134,8 +139,8 @@ const Widget = () => {
       </Route>
       <Route path="course" element={<LibraryCourse />}>
         <Route path="list" element={<LibraryCourseList />}></Route>
-        <Route path="show/:id" element={<LibraryCourseShow />}></Route>
-        <Route path="lesson/:id" element={<LibraryLessonShow />}></Route>
+        <Route path="show" element={<LibraryCourseShow />}></Route>
+        <Route path="lesson" element={<LibraryLessonShow />}></Route>
       </Route>
 
       <Route path="term/:word" element={<LibraryTerm />} />

+ 73 - 0
dashboard/src/components/api/Course.ts

@@ -0,0 +1,73 @@
+import { ITocPathNode } from "../corpus/TocPath";
+import type { IStudioApiResponse } from "./Auth";
+
+export interface ICourseListApiResponse {
+  article: string;
+  title: string;
+  level: string;
+  children: number;
+}
+
+export interface ICourseDataRequest {
+  uid: string;//课程ID
+  title: string;//标题
+  subtitle: string;//副标题
+  teacher: number;//UserID
+  course_count: number;//课程数
+  //content: string;
+  //content_type: string;
+  //path?: ITocPathNode[];
+  type: number;//类型-公开/内部
+  //lang: string;
+  created_at: string;//创建时间
+  updated_at: string;//修改时间
+  article_id: number;//文集ID
+  course_start_at: string;//课程开始时间
+  course_end_at: string;//课程结束时间
+  intro_markdown: string;//简介
+  cover_img_name: string;//封面图片文件名
+}
+export interface ICourseDataResponse {
+  uid: string;//课程ID
+  title: string;//标题
+  subtitle: string;//副标题
+  teacher: number;//UserID
+  course_count: number;//课程数
+  //content: string;
+  //content_type: string;
+  //path?: ITocPathNode[];
+  type: number;//类型-公开/内部
+  //lang: string;
+  created_at: string;//创建时间
+  updated_at: string;//修改时间
+  article_id: number;//文集ID
+  course_start_at: string;//课程开始时间
+  course_end_at: string;//课程结束时间
+  intro_markdown: string;//简介
+  cover_img_name: string;//封面图片文件名
+}
+export interface ICourseResponse {
+  ok: boolean;
+  message: string;
+  data: ICourseDataResponse;
+}
+export interface ICourseListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ICourseDataResponse[];
+    count: number;
+  };
+}
+
+export interface ICourseCreateRequest {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+export interface IAnthologyCreateRequest {
+  title: string;
+  lang: string;
+  studio: string;
+}

+ 76 - 0
dashboard/src/components/library/course/AddLesson.tsx

@@ -0,0 +1,76 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Button, message, Popover } from "antd";
+import { UserAddOutlined } from "@ant-design/icons";
+import { get } from "../../../request";
+
+import { IUserListResponse } from "../../api/Auth";
+
+interface IFormData {
+  userId: string;
+}
+
+interface IWidget {
+  groupId?: string;
+}
+const Widget = ({ groupId }: IWidget) => {
+  const intl = useIntl();
+
+  const form = (
+    <ProForm<IFormData>
+      onFinish={async (values: IFormData) => {
+        // TODO
+        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>(`/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",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+  return (
+    <Popover
+      placement="bottom"
+      arrowPointAtCenter
+      content={form}
+      trigger="click"
+    >
+      <Button icon={<UserAddOutlined />} key="add" type="primary">
+        {intl.formatMessage({ id: "buttons.lesson.add.lesson" })}
+      </Button>
+    </Popover>
+  );
+};
+
+export default Widget;

+ 86 - 0
dashboard/src/components/library/course/AddStudent.tsx

@@ -0,0 +1,86 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Button, message, Popover, MenuProps } from "antd";
+import { UserAddOutlined } from "@ant-design/icons";
+import { get } from "../../../request";
+
+import { IUserListResponse } from "../../api/Auth";
+
+interface IFormData {
+  userId: string;
+}
+
+interface IWidget {
+  groupId?: string;
+}
+
+const Widget = ({ groupId }: IWidget) => {
+  const intl = useIntl();
+
+  const form = (
+    <ProForm<IFormData>
+      onFinish={async (values: IFormData) => {
+        // TODO
+        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>(`/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={{
+            1: intl.formatMessage({ id: "forms.fields.students.label" }),
+            2: intl.formatMessage({ id: "forms.fields.assistant.label" }),
+          }}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+  return (
+    <Popover
+      placement="bottom"
+      arrowPointAtCenter
+      content={form}
+      trigger="click"
+    >
+      <Button icon={<UserAddOutlined />} key="add" type="primary">
+        {intl.formatMessage({ id: "buttons.group.add.member" })}
+      </Button>
+    </Popover>
+  );
+};
+
+export default Widget;

+ 76 - 0
dashboard/src/components/library/course/AddTeacher.tsx

@@ -0,0 +1,76 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Button, message, Popover } from "antd";
+import { UserAddOutlined } from "@ant-design/icons";
+import { get } from "../../../request";
+
+import { IUserListResponse } from "../../api/Auth";
+
+interface IFormData {
+  userId: string;
+}
+
+interface IWidget {
+  groupId?: string;
+}
+const Widget = ({ groupId }: IWidget) => {
+  const intl = useIntl();
+
+  const form = (
+    <ProForm<IFormData>
+      onFinish={async (values: IFormData) => {
+        // TODO
+        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>(`/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",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+  return (
+    <Popover
+      placement="bottom"
+      arrowPointAtCenter
+      content={form}
+      trigger="click"
+    >
+      <Button icon={<UserAddOutlined />} key="add" type="primary">
+        {intl.formatMessage({ id: "buttons.group.add.member" })}
+      </Button>
+    </Popover>
+  );
+};
+
+export default Widget;

+ 61 - 0
dashboard/src/components/library/course/CourseCreate.tsx

@@ -0,0 +1,61 @@
+import { useIntl } from "react-intl";
+import { ProForm, ProFormText } from "@ant-design/pro-components";
+import { message } from "antd";
+
+import { post } from "../../../request";
+import { ICourseCreateRequest, ICourseResponse } from "../../api/Course";
+import LangSelect from "../../general/LangSelect";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+type IWidgetCourseCreate = {
+  studio?: string;
+};
+const Widget = (prop: IWidgetCourseCreate) => {
+  const intl = useIntl();
+
+  return (
+    <ProForm<IFormData>
+      onFinish={async (values: IFormData) => {
+        console.log(values);
+        values.studio = prop.studio ? prop.studio : "";
+        const res = await post<ICourseCreateRequest, ICourseResponse>(
+          `/v2/article`,
+          values
+        );
+        console.log(res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+        } 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 Widget;

+ 51 - 0
dashboard/src/components/library/course/CourseIntro.tsx

@@ -0,0 +1,51 @@
+//课程详情简介
+import { Link } from "react-router-dom";
+import React from 'react';
+import { ProForm, ProFormText } from "@ant-design/pro-components";
+import {Layout,  Descriptions, Space , Col, Row } from 'antd';
+
+import ReactPlayer from 'react-player'
+
+const Widget = () => {
+  
+
+  return (
+    <ProForm.Group>
+        <Layout>
+        <Descriptions title="课程简介">
+    <Descriptions.Item label=" ">每一尊佛体证后,都会有一次殊胜的大聚会,那是十方天人的相聚,由此宣说《大集会经》。
+佛陀观察到:天人们内心有种种问题,他们却不知该如何表达……
+于是便有了化身佛在问,本尊佛在答……
+ 
+根据众生的根性,佛陀共开示六部经,分别针对贪、瞋、痴、信、觉、寻六种性格习性的天人。
+此部《纷争分歧经》便是专门为瞋行者量身而作,瞋行者往往多思、多慧,佛陀便以智慧循循善诱,抽丝剥茧,层层深入,探究纷争、分歧等八种烦恼根源何在……
+ 
+听,佛陀在说——
+让我们以佛陀当年的语言——古老的巴利语——去聆听佛陀的教诲……</Descriptions.Item>
+  </Descriptions>
+  <Descriptions title="电子平台课堂笔记">
+    <Descriptions.Item label="快速预览(课前预习)">
+      <Link to="/course/lesson/12345">原文</Link>  <Link to="/course/lesson/23456">原文+义注</Link> </Descriptions.Item>
+  </Descriptions>
+  <ReactPlayer
+            className='react-player fixed-bottom'
+            url= 'https://assets-hk.wikipali.org/video/admissions1080p.mp4'
+            width='50%'
+            height='50%'
+            controls = {true}
+
+          />
+    </Layout>
+      </ProForm.Group>
+    );
+};
+
+export default Widget;
+
+
+/*
+<Button type="primary">关注</Button>
+<Button type="primary" disabled>
+  已关注
+</Button>
+*/

+ 62 - 0
dashboard/src/components/library/course/CourseList.tsx

@@ -0,0 +1,62 @@
+//课程列表
+import React from 'react';
+import { LikeOutlined, MessageOutlined, StarOutlined } from '@ant-design/icons';
+import { Avatar, List, Space } from 'antd';
+
+const data = Array.from({ length: 23 }).map((_, i) => ({
+  href: '../course/show',
+  title: `课程 ${i}`,
+  avatar: 'https://joeschmoe.io/api/v1/random',
+  description:
+    '主讲人: 小僧善巧',
+  content:
+    '一年之计在于春,新春佳节修善行; 一周学习与精进,法为伊始吉祥年',
+}));
+
+const IconText = ({ icon, text }: { icon: React.FC; text: string }) => (
+  <Space>
+    {React.createElement(icon)}
+    {text}
+  </Space>
+);
+
+const App: React.FC = () => (
+  <List
+    itemLayout="vertical"
+    size="large"
+    pagination={{
+      onChange: (page) => {
+        console.log(page);
+      },
+      pageSize: 5,
+    }}
+    dataSource={data}
+    footer={
+      <div>
+        <b>ant design</b> footer part
+      </div>
+    }
+    renderItem={(item) => (
+      <List.Item
+        key={item.title}
+
+        extra={
+          <img
+            width={272}
+            alt="logo"
+            src="https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg95.699pic.com%2Fxsj%2F0g%2Fk2%2F7d.jpg%21%2Ffw%2F700%2Fwatermark%2Furl%2FL3hzai93YXRlcl9kZXRhaWwyLnBuZw%2Falign%2Fsoutheast&refer=http%3A%2F%2Fimg95.699pic.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1673839902&t=d8f4306ddd6935313c66efb936cbe268"
+          />
+        }
+      >
+        <List.Item.Meta
+          avatar={<Avatar src={item.avatar} />}
+          title={<a href={item.href}>{item.title}</a>}
+          description={item.description}
+        />
+        {item.content}
+      </List.Item>
+    )}
+  />
+);
+
+export default App;

+ 45 - 0
dashboard/src/components/library/course/CourseShow.tsx

@@ -0,0 +1,45 @@
+//课程详情图片标题按钮主讲人组合
+import { Link } from "react-router-dom";
+import React from 'react';
+import { ProForm, ProFormText } from "@ant-design/pro-components";
+import {Layout,  Image, Button, Space , Col, Row } from 'antd';
+
+const Widget = () => {
+  
+
+  return (
+    <ProForm.Group>
+        <Layout>
+          <Row>
+    <Image
+    width={200}
+    src="https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg95.699pic.com%2Fxsj%2F0g%2Fk2%2F7d.jpg%21%2Ffw%2F700%2Fwatermark%2Furl%2FL3hzai93YXRlcl9kZXRhaWwyLnBuZw%2Falign%2Fsoutheast&refer=http%3A%2F%2Fimg95.699pic.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1673839902&t=d8f4306ddd6935313c66efb936cbe268"
+  />
+    <h1 style={{ "fontWeight": 'bold', "fontSize": 30}}>wikipali课程</h1>
+
+          <Col flex="auto"></Col>
+          <Col flex="1260px">   </Col>
+          </Row>
+          <Col>
+          <Button type="primary">关注</Button>
+          </Col>
+
+
+
+
+    <p style={{ "fontWeight": 'bold', "fontSize": 15}}>主讲人: <Link to="/course/lesson/12345">小僧善巧</Link> </p>
+    
+    </Layout>
+      </ProForm.Group>
+    );
+};
+
+export default Widget;
+
+
+/*
+<Button type="primary">关注</Button>
+<Button type="primary" disabled>
+  已关注
+</Button>
+*/

+ 116 - 0
dashboard/src/components/library/course/LecturerList.tsx

@@ -0,0 +1,116 @@
+//主讲人列表
+import { useIntl } from "react-intl";
+import { ProForm, ProFormText } from "@ant-design/pro-components";
+//import { message } from "antd";
+
+import { post } from "../../../request";
+import { useState } from "react";
+//import React from 'react';
+import { Card, List , Col, Row , Space} from 'antd';
+const { Meta } = Card;
+//const {  Card, Col, Row  } = antd;
+
+const data = [
+  {
+    title: 'U Kuṇḍadhāna Sayadaw',
+    introduction: 'U Kuṇḍadhāna Sayadaw简介 U Kun西亚多今年51岁,30个瓦萨, - 1969年,出生于缅甸...',
+    portrait:'https://img2.baidu.com/it/u=2930319359,2500787374&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=334'
+
+  },
+  {
+    title: '某尊者',
+    introduction: '某尊者简介...',
+    portrait:'https://img2.baidu.com/it/u=2930319359,2500787374&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=334'
+  },
+  {
+    title: '小僧善巧',
+    introduction: '小僧善巧尊者简介...',
+    portrait:'https://avatars.githubusercontent.com/u/58804044?v=4'
+  },
+  {
+    title: 'Kalyāṇamitta',
+    introduction: 'Kalyāṇamitta尊者简介...',
+    portrait:'https://img2.baidu.com/it/u=2930319359,2500787374&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=334'
+  },
+];
+/*栅格卡片实现方案 https://ant.design/components/card-cn/
+
+const App = () => (
+  <div className="site-card-wrapper">
+    <Row gutter={16}>
+      <Col span={4}>
+      <Card
+    hoverable
+    style={{ width: 240,  height: 300}}
+    cover={<img alt="example" src={data[0].portrait}  width="240" height="180"/>}
+  >
+    <Meta title={data[0].title} description={data[0].introduction} />
+  </Card>
+      </Col>
+      <Col span={4}>
+      <Card
+    hoverable
+    style={{ width: 240,  height: 300}}
+    cover={<img alt="example" src={data[1].portrait}  width="240" height="180"/>}
+  >
+    <Meta title={data[1].title} description={data[1].introduction} />
+  </Card>
+      </Col>
+      <Col span={4}>
+      <Card
+    hoverable
+    style={{ width: 240,  height: 300}}
+    cover={<img alt="example" src={data[2].portrait}  width="240" height="180"/>}
+  >
+    <Meta title={data[2].title} description={data[2].introduction} />
+  </Card>
+      </Col>
+      <Col span={4}>
+      <Card
+    hoverable
+    style={{ width: 240,  height: 300}}
+    cover={<img alt="example" src={data[3].portrait}  width="240" height="180"/>}
+  >
+    <Meta title={data[3].title} description={data[3].introduction} />
+  </Card>
+      </Col>
+    </Row>
+  </div>
+);
+
+export default App;
+*/
+
+
+/*List实现方案
+
+import { useIntl } from "react-intl";
+import { ProForm, ProFormText } from "@ant-design/pro-components";
+//import { message } from "antd";
+
+import { post } from "../../../request";
+import { useState } from "react";
+//import React from 'react';
+import { Card, List } from 'antd';
+const { Meta } = Card;
+*/
+const App: React.FC = () => (
+
+  <List
+    grid={{ gutter: 16, column: 4 }}
+    dataSource={data}
+    renderItem={(item) => (
+      <List.Item>
+        <Card
+          hoverable
+          style={{ width: 240,  height: 300}}
+          cover={<img alt="example" src={item.portrait}  width="240" height="180"/>}
+          >
+          <Meta title={item.title} description={item.introduction} />
+        </Card>
+      </List.Item>
+    )}
+  />
+      
+);
+export default App;

+ 82 - 0
dashboard/src/components/library/course/LessonSelect.tsx

@@ -0,0 +1,82 @@
+//选择讲师组件
+
+import { useIntl } from "react-intl";
+import { useState } from "react";
+import { ProList } from "@ant-design/pro-components";
+import { UserAddOutlined } from "@ant-design/icons";
+import { Space, Tag, Button, Layout } from "antd";
+import AddLesson from "./AddLesson";
+
+const { Content } = Layout;
+
+const defaultData = [
+  {
+    id: "1",
+    name: "lesson0",
+    //tag: [{ title: "管理员", color: "success" }],
+    //image:
+    //  "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+  },
+];
+type DataItem = typeof defaultData[number];
+interface IWidgetGroupFile {
+  groupId?: string;
+}
+const Widget = ({ groupId }: IWidgetGroupFile) => {
+  const intl = useIntl(); //i18n
+  const [dataSource, setDataSource] = useState<DataItem[]>(defaultData);
+
+  return (
+    <Content>
+      <Space>{groupId}</Space>
+      <ProList<DataItem>
+        rowKey="id"
+        headerTitle={intl.formatMessage({ id: "forms.fields.lesson.label" })}
+        toolBarRender={() => {
+          return [<AddLesson groupId={groupId} />];
+        }}
+        dataSource={dataSource}
+        showActions="hover"
+        onDataSourceChange={setDataSource}
+        metas={{
+          title: {
+            dataIndex: "name",
+          },
+          avatar: {
+            dataIndex: "image",
+            editable: false,
+          },
+          // subTitle: {
+          //   render: (text, row, index, action) => {
+          //     const showtag = row.tag.map((item, id) => {
+          //       return (
+          //         <Tag color={item.color} key={id}>
+          //           {item.title}
+          //         </Tag>
+          //       );
+          //     });
+          //     return <Space size={0}>{showtag}</Space>;
+          //   },
+          // },
+          actions: {
+            render: (text, row, index, action) => [
+              <Button
+                style={{ padding: 0, margin: 0 }}
+                type="link"
+                danger
+                onClick={() => {
+                  action?.startEditable(row.id);
+                }}
+                key="link"
+              >
+                {intl.formatMessage({ id: "buttons.remove" })}
+              </Button>,
+            ],
+          },
+        }}
+      />
+    </Content>
+  );
+};
+
+export default Widget;

+ 55 - 0
dashboard/src/components/library/course/LessonTreeShow.tsx

@@ -0,0 +1,55 @@
+//上传封面组件
+import React, { useState } from "react";
+import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
+import { message, Upload, Tree } from "antd";
+import type { UploadChangeParam } from "antd/es/upload";
+import type { RcFile, UploadFile, UploadProps } from "antd/es/upload/interface";
+
+import type { DataNode } from "antd/es/tree";
+/*
+const dig = (path = "0", level = 3) => {
+  const list = [];
+  for (let i = 0; i < 10; i += 1) {
+    const key = `a-${i}`;
+    const treeNode: DataNode = {
+      title: key,
+      key,
+    };
+
+    if (level > 0) {
+      treeNode.children = dig(key, level - 1);
+    }
+
+    list.push(treeNode);
+  }
+  return list;
+};
+
+const treeData = dig();
+*/
+const treeData: DataNode[] = [
+  {
+    title: "课程1",
+    key: "0-0",
+    children: [
+      { title: "课程1-0", key: "0-0-0", isLeaf: true },
+      { title: "课程1-1", key: "0-0-1", isLeaf: true },
+      { title: "课程1-2", key: "0-0-2", isLeaf: true },
+      { title: "课程1-3", key: "0-0-3", isLeaf: true },
+    ],
+  },
+  {
+    title: "课程2",
+    key: "0-1",
+    children: [
+      { title: "课程2-0", key: "0-1-0", isLeaf: true },
+      { title: "课程2-1", key: "0-1-1", isLeaf: true },
+      { title: "课程2-2", key: "0-1-2", isLeaf: true },
+    ],
+  },
+];
+const Widget = () => {
+  return <Tree treeData={treeData} height={233} defaultExpandAll />;
+};
+
+export default Widget;

+ 98 - 0
dashboard/src/components/library/course/StudentsSelect.tsx

@@ -0,0 +1,98 @@
+//选择讲师组件
+
+import { useIntl } from "react-intl";
+import { useState } from "react";
+import { ProList } from "@ant-design/pro-components";
+import { UserAddOutlined } from "@ant-design/icons";
+import { Space, Tag, Button, Layout } from "antd";
+import AddStudent from "./AddStudent";
+
+const { Content } = Layout;
+
+const defaultData = [
+  {
+    id: "1",
+    name: "小僧善巧",
+    tag: [{ title: "助教", color: "success" }],
+    image:
+      "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+  },
+  {
+    id: "2",
+    name: "学员1",
+    tag: [{ title: "学员", color: "blue" }],
+    image:
+      "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+  },
+  {
+    id: "3",
+    name: "学员2",
+    tag: [{ title: "学员", color: "blue" }],
+    image:
+      "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+  },
+];
+type DataItem = typeof defaultData[number];
+interface IWidgetGroupFile {
+  groupId?: string;
+}
+const Widget = ({ groupId }: IWidgetGroupFile) => {
+  const intl = useIntl(); //i18n
+  const [dataSource, setDataSource] = useState<DataItem[]>(defaultData);
+
+  return (
+    <Content>
+      <Space>{groupId}</Space>
+      <ProList<DataItem>
+        rowKey="id"
+        headerTitle={intl.formatMessage({
+          id: "forms.fields.studentsassistant.label",
+        })}
+        toolBarRender={() => {
+          return [<AddStudent groupId={groupId} />];
+        }}
+        dataSource={dataSource}
+        showActions="hover"
+        onDataSourceChange={setDataSource}
+        metas={{
+          title: {
+            dataIndex: "name",
+          },
+          avatar: {
+            dataIndex: "image",
+            editable: false,
+          },
+          subTitle: {
+            render: (text, row, index, action) => {
+              const showtag = row.tag.map((item, id) => {
+                return (
+                  <Tag color={item.color} key={id}>
+                    {item.title}
+                  </Tag>
+                );
+              });
+              return <Space size={0}>{showtag}</Space>;
+            },
+          },
+          actions: {
+            render: (text, row, index, action) => [
+              <Button
+                style={{ padding: 0, margin: 0 }}
+                type="link"
+                danger
+                onClick={() => {
+                  action?.startEditable(row.id);
+                }}
+                key="link"
+              >
+                {intl.formatMessage({ id: "buttons.remove" })}
+              </Button>,
+            ],
+          },
+        }}
+      />
+    </Content>
+  );
+};
+
+export default Widget;

+ 82 - 0
dashboard/src/components/library/course/TeacherSelect.tsx

@@ -0,0 +1,82 @@
+//选择讲师组件
+
+import { useIntl } from "react-intl";
+import { useState } from "react";
+import { ProList } from "@ant-design/pro-components";
+import { UserAddOutlined } from "@ant-design/icons";
+import { Space, Tag, Button, Layout } from "antd";
+import AddTeacher from "./AddTeacher";
+
+const { Content } = Layout;
+
+const defaultData = [
+  {
+    id: "1",
+    name: "小僧善巧",
+    tag: [{ title: "管理员", color: "success" }],
+    image:
+      "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+  },
+];
+type DataItem = typeof defaultData[number];
+interface IWidgetGroupFile {
+  groupId?: string;
+}
+const Widget = ({ groupId }: IWidgetGroupFile) => {
+  const intl = useIntl(); //i18n
+  const [dataSource, setDataSource] = useState<DataItem[]>(defaultData);
+
+  return (
+    <Content>
+      <Space>{groupId}</Space>
+      <ProList<DataItem>
+        rowKey="id"
+        headerTitle={intl.formatMessage({ id: "forms.fields.teacher.label" })}
+        toolBarRender={() => {
+          return [<AddTeacher groupId={groupId} />];
+        }}
+        dataSource={dataSource}
+        showActions="hover"
+        onDataSourceChange={setDataSource}
+        metas={{
+          title: {
+            dataIndex: "name",
+          },
+          avatar: {
+            dataIndex: "image",
+            editable: false,
+          },
+          subTitle: {
+            render: (text, row, index, action) => {
+              const showtag = row.tag.map((item, id) => {
+                return (
+                  <Tag color={item.color} key={id}>
+                    {item.title}
+                  </Tag>
+                );
+              });
+              return <Space size={0}>{showtag}</Space>;
+            },
+          },
+          actions: {
+            render: (text, row, index, action) => [
+              <Button
+                style={{ padding: 0, margin: 0 }}
+                type="link"
+                danger
+                onClick={() => {
+                  action?.startEditable(row.id);
+                }}
+                key="link"
+              >
+                {intl.formatMessage({ id: "buttons.remove" })}
+              </Button>,
+            ],
+          },
+        }}
+      />
+    </Content>
+  );
+};
+
+export default Widget;

+ 66 - 0
dashboard/src/components/library/course/UploadTexture.tsx

@@ -0,0 +1,66 @@
+//上传封面组件
+import React, { useState } from 'react';
+import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
+import { message, Upload } from 'antd';
+import type { UploadChangeParam } from 'antd/es/upload';
+import type { RcFile, UploadFile, UploadProps } from 'antd/es/upload/interface';
+
+const getBase64 = (img: RcFile, callback: (url: string) => void) => {
+  const reader = new FileReader();
+  reader.addEventListener('load', () => callback(reader.result as string));
+  reader.readAsDataURL(img);
+};
+
+const beforeUpload = (file: RcFile) => {
+  const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
+  if (!isJpgOrPng) {
+    message.error('You can only upload JPG/PNG file!');
+  }
+  const isLt2M = file.size / 1024 / 1024 < 2;
+  if (!isLt2M) {
+    message.error('Image must smaller than 2MB!');
+  }
+  return isJpgOrPng && isLt2M;
+};
+
+const Widget = () => {
+  const [loading, setLoading] = useState(false);
+  const [imageUrl, setImageUrl] = useState<string>();
+
+  const handleChange: UploadProps['onChange'] = (info: UploadChangeParam<UploadFile>) => {
+    if (info.file.status === 'uploading') {
+      setLoading(true);
+      return;
+    }
+    if (info.file.status === 'done') {
+      // Get this url from response in real world.
+      getBase64(info.file.originFileObj as RcFile, (url) => {
+        setLoading(false);
+        setImageUrl(url);
+      });
+    }
+  };
+
+  const uploadButton = (
+    <div>
+      {loading ? <LoadingOutlined /> : <PlusOutlined />}
+      <div style={{ marginTop: 8 }}>Upload</div>
+    </div>
+  );
+
+  return (
+    <Upload
+      name="avatar"
+      listType="picture-card"
+      className="avatar-uploader"
+      showUploadList={false}
+      action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
+      beforeUpload={beforeUpload}
+      onChange={handleChange}
+    >
+      {imageUrl ? <img src={imageUrl} alt="avatar" style={{ width: '100%' }} /> : uploadButton}
+    </Upload>
+  );
+};
+
+export default Widget;

+ 137 - 136
dashboard/src/components/studio/LeftSider.tsx

@@ -20,143 +20,144 @@ type IWidgetHeadBar = {
   selectedKeys?: string;
 };
 const Widget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
-  //Library head bar
-  const intl = useIntl(); //i18n
-  const { studioname } = useParams();
-  const linkPalicanon = "/studio/" + studioname + "/palicanon";
-  const linkRecent = "/studio/" + studioname + "/recent";
-  const linkChannel = "/studio/" + studioname + "/channel/list";
-  const linkGroup = "/studio/" + studioname + "/group/list";
-  const linkUserdict = "/studio/" + studioname + "/dict/list";
-  const linkTerm = "/studio/" + studioname + "/term/list";
-  const linkArticle = "/studio/" + studioname + "/article/list";
-  const linkAnthology = "/studio/" + studioname + "/anthology/list";
-  const linkAnalysis = "/studio/" + studioname + "/analysis/list";
-  const linkCourse = "/studio/" + studioname + "/course/list";
+	//Library head bar
+	const intl = useIntl(); //i18n
+	const { studioname } = useParams();
+	const linkPalicanon = "/studio/" + studioname + "/palicanon";
+	const linkRecent = "/studio/" + studioname + "/recent";
+	const linkChannel = "/studio/" + studioname + "/channel/list";
+	const linkGroup = "/studio/" + studioname + "/group/list";
+	const linkUserdict = "/studio/" + studioname + "/dict/list";
+	const linkTerm = "/studio/" + studioname + "/term/list";
+	const linkCourse = "/studio/" + studioname + "/course/list";
+	const linkArticle = "/studio/" + studioname + "/article/list";
+	const linkAnthology = "/studio/" + studioname + "/anthology/list";
+	const linkAnalysis = "/studio/" + studioname + "/analysis/list";
+
+	const items: MenuProps["items"] = [
+		{
+			label: "常用",
+			key: "basic",
+			icon: <HomeOutlined />,
+			children: [
+				{
+					label: (
+						<Link to={linkPalicanon}>
+							{intl.formatMessage({
+								id: "columns.studio.palicanon.title",
+							})}
+						</Link>
+					),
+					key: "palicanon",
+				},
+				{
+					label: (
+						<Link to={linkRecent}>
+							{intl.formatMessage({
+								id: "columns.studio.recent.title",
+							})}
+						</Link>
+					),
+					key: "recent",
+				},
+				{
+					label: (
+						<Link to={linkChannel}>
+							{intl.formatMessage({
+								id: "columns.studio.channel.title",
+							})}
+						</Link>
+					),
+					key: "channel",
+				},
+				{
+					label: (
+						<Link to={linkAnalysis}>
+							{intl.formatMessage({
+								id: "columns.studio.analysis.title",
+							})}
+						</Link>
+					),
+					key: "analysis",
+				},
+			],
+		},
+		{
+			label: "高级",
+			key: "advance",
+			icon: <AppstoreOutlined />,
+			children: [
+				{
+					label: (
+						<Link to={linkUserdict}>
+							{intl.formatMessage({
+								id: "columns.studio.userdict.title",
+							})}
+						</Link>
+					),
+					key: "userdict",
+				},
+				{
+					label: (
+						<Link to={linkTerm}>
+							{intl.formatMessage({
+								id: "columns.studio.term.title",
+							})}
+						</Link>
+					),
+					key: "term",
+				},
+				{
+					label: (
+						<Link to={linkCourse}>
+							{intl.formatMessage({
+								id: "columns.studio.course.title",
+							})}
+						</Link>
+					),
+					key: "course",
+				},
+				{
+					label: (
+						<Link to={linkArticle}>
+							{intl.formatMessage({
+								id: "columns.studio.article.title",
+							})}
+						</Link>
+					),
+					key: "article",
+				},
+				{
+					label: (
+						<Link to={linkAnthology}>
+							{intl.formatMessage({
+								id: "columns.studio.anthology.title",
+							})}
+						</Link>
+					),
+					key: "anthology",
+				},
+			],
+		},
+		{
+			label: "协作",
+			key: "collaboration",
+			icon: <TeamOutlined />,
+			children: [
+				{
+					label: (
+						<Link to={linkGroup}>
+							{intl.formatMessage({
+								id: "columns.studio.group.title",
+							})}
+						</Link>
+					),
+					key: "group",
+				},
+			],
+		},
+	];
 
-  const items: MenuProps["items"] = [
-    {
-      label: "常用",
-      key: "basic",
-      icon: <HomeOutlined />,
-      children: [
-        {
-          label: (
-            <Link to={linkPalicanon}>
-              {intl.formatMessage({
-                id: "columns.studio.palicanon.title",
-              })}
-            </Link>
-          ),
-          key: "palicanon",
-        },
-        {
-          label: (
-            <Link to={linkRecent}>
-              {intl.formatMessage({
-                id: "columns.studio.recent.title",
-              })}
-            </Link>
-          ),
-          key: "recent",
-        },
-        {
-          label: (
-            <Link to={linkChannel}>
-              {intl.formatMessage({
-                id: "columns.studio.channel.title",
-              })}
-            </Link>
-          ),
-          key: "channel",
-        },
-        {
-          label: (
-            <Link to={linkAnalysis}>
-              {intl.formatMessage({
-                id: "columns.studio.analysis.title",
-              })}
-            </Link>
-          ),
-          key: "analysis",
-        },
-      ],
-    },
-    {
-      label: "高级",
-      key: "advance",
-      icon: <AppstoreOutlined />,
-      children: [
-        {
-          label: (
-            <Link to={linkCourse}>
-              {intl.formatMessage({
-                id: "columns.library.course.title",
-              })}
-            </Link>
-          ),
-          key: "course",
-        },
-        {
-          label: (
-            <Link to={linkUserdict}>
-              {intl.formatMessage({
-                id: "columns.studio.userdict.title",
-              })}
-            </Link>
-          ),
-          key: "userdict",
-        },
-        {
-          label: (
-            <Link to={linkTerm}>
-              {intl.formatMessage({
-                id: "columns.studio.term.title",
-              })}
-            </Link>
-          ),
-          key: "term",
-        },
-        {
-          label: (
-            <Link to={linkArticle}>
-              {intl.formatMessage({
-                id: "columns.studio.article.title",
-              })}
-            </Link>
-          ),
-          key: "article",
-        },
-        {
-          label: (
-            <Link to={linkAnthology}>
-              {intl.formatMessage({
-                id: "columns.studio.anthology.title",
-              })}
-            </Link>
-          ),
-          key: "anthology",
-        },
-      ],
-    },
-    {
-      label: "协作",
-      key: "collaboration",
-      icon: <TeamOutlined />,
-      children: [
-        {
-          label: (
-            <Link to={linkGroup}>
-              {intl.formatMessage({
-                id: "columns.studio.group.title",
-              })}
-            </Link>
-          ),
-          key: "group",
-        },
-      ],
-    },
-  ];
 
   return (
     <Affix offsetTop={0}>

+ 1 - 0
dashboard/src/locales/zh-Hans/buttons.ts

@@ -22,6 +22,7 @@ const items = {
   "buttons.click.upload": "点击上传",
   "buttons.group.exit": "退群",
   "buttons.group.add.member": "加人",
+  "buttons.lesson.add.lesson": "加入",
   "buttons.ok": "确定",
 };
 

+ 8 - 1
dashboard/src/locales/zh-Hans/forms.ts

@@ -5,7 +5,7 @@ const items = {
   "forms.fields.id.label": "ID",
   "forms.fields.message.label": "消息",
   "forms.fields.created-at.label": "创建时间",
-  "forms.fields.update-at.label": "创建时间",
+  "forms.fields.update-at.label": "更新时间",
   "forms.fields.lang.label": "语言",
   "forms.message.lang.required": "请选择一种语言",
   "forms.fields.title.label": "标题",
@@ -21,6 +21,11 @@ const items = {
   "forms.fields.publicity.disable.label": "禁用",
   "forms.fields.publicity.private.label": "私有",
   "forms.fields.publicity.public.label": "公开",
+  "forms.fields.teacher.label": "主讲人",
+  "forms.fields.studentsassistant.label": "学生与助教",
+  "forms.fields.students.label": "学生",
+  "forms.fields.assistant.label": "助教",
+  "forms.fields.lesson.label": "讲",
   "forms.fields.note.label": "注解",
   "forms.fields.confidence.label": "信心指数",
   "forms.fields.confidence.0.label": "不靠谱",
@@ -28,6 +33,8 @@ const items = {
   "forms.fields.confidence.50.label": "一般",
   "forms.fields.confidence.75.label": "还可以",
   "forms.fields.confidence.100.label": "有把握",
+  "forms.fields.upload.texture": "上传封面",
+  "forms.fields.markdown.label": "markdown效果预览",
   "forms.fields.role.label": "角色",
   "forms.fields.name.label": "名称",
   "forms.fields.meaning.label": "意思",

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

@@ -35,6 +35,7 @@ const items = {
   "columns.studio.group.title": "群组",
   "columns.studio.userdict.title": "用户字典",
   "columns.studio.term.title": "术语",
+  "columns.studio.course.title": "课程",
   "columns.studio.article.title": "文章",
   "columns.studio.anthology.title": "文集",
   "columns.studio.analysis.title": "分析",

+ 76 - 11
dashboard/src/pages/library/course/course.tsx

@@ -1,18 +1,83 @@
+//课程详情页面
 import { Link } from "react-router-dom";
 import { useParams } from "react-router-dom";
+import { Layout, Col, Row, Divider } from "antd";
+import CourseShow from "../../../components/library/course/CourseShow";
+import CourseIntro from "../../../components/library/course/CourseIntro";
+import TocTree from "../../../components/article/TocTree";
+import { ListNodeData } from "../../../components/article/EditableTree";
+import ReactMarkdown from "react-markdown";
+import rehypeRaw from "rehype-raw";
+import { marked } from "marked";
+const { Content, Header } = Layout;
+
+let arrTocTree: ListNodeData[] = [];
+let i = 0;
+do {
+  ++i;
+  arrTocTree.push({
+    key: i.toString(),
+    title: `课程 ${i}`,
+    level: 1,
+  });
+} while (i < 10); // 在循环的尾部检查条件
+
+let markdown =
+  "# 这是标题\n" +
+  "[ **M** ] arkdown + E [ **ditor** ] = **Mditor**  \n" +
+  "> Mditor 是一个简洁、易于集成、方便扩展、期望舒服的编写 markdown 的编辑器,仅此而已... \n\n" +
+  "**这是加粗的文字**\n\n" +
+  "*这是倾斜的文字*`\n\n" +
+  "***这是斜体加粗的文字***\n\n" +
+  "~~这是加删除线的文字~~ \n\n" +
+  "\n\n" +
+  "|表格头1|表格头2|表格头3| \n\n" +
+  "|------|------|------| \n\n" +
+  "| 文本 | 文本 | 文本 |\n\n" +
+  "\n\n" +
+  "```const a=2; ```";
 
 const Widget = () => {
-	// TODO
-	const { courseid } = useParams(); //url 参数
-	return (
-		<div>
-			<div>课程{courseid} 详情</div>
-			<div>
-				<Link to="/course/lesson/12345">lesson 1</Link>
-				<Link to="/course/lesson/23456">lesson 2</Link>
-			</div>
-		</div>
-	);
+  // TODO
+  const { courseid } = useParams(); //url 参数
+
+  return (
+    <Layout>
+      <Content>
+        <Row>
+          <Col flex="auto"></Col>
+          <Col flex="1760px">
+            <div>
+              <div>
+                <CourseShow />
+                <Divider />
+                <div
+                  dangerouslySetInnerHTML={{
+                    __html: marked.parse(markdown),
+                  }}
+                ></div>
+                <CourseIntro />
+                <Divider />
+                <TocTree treeData={arrTocTree} />
+              </div>
+            </div>
+          </Col>
+        </Row>
+      </Content>
+    </Layout>
+  );
 };
 
 export default Widget;
+
+/*
+  return (
+    <div>
+      <div>课程{courseid} 详情</div>
+      <div>
+        <Link to="/course/lesson/12345">lesson 1</Link>
+        <Link to="/course/lesson/23456">lesson 2</Link>
+      </div>
+    </div>
+  );
+*/

+ 102 - 17
dashboard/src/pages/library/course/lesson.tsx

@@ -1,17 +1,102 @@
-import { useParams } from "react-router-dom";
-
-const Widget = () => {
-	// TODO
-	const { lessonid } = useParams(); //url 参数
-
-	return (
-		<div>
-			<div>课 {lessonid} 详情</div>
-			<div>
-				主显示区
-			</div>
-		</div>
-	);
-};
-
-export default Widget;
+//讲页面
+import { Link } from "react-router-dom";
+import { useParams } from "react-router-dom";
+import { Layout, Col, Row, Divider } from "antd";
+import CourseShow from "../../../components/library/course/CourseShow";
+import CourseIntro from "../../../components/library/course/CourseIntro";
+import TocTree from "../../../components/article/TocTree";
+import { ListNodeData } from "../../../components/article/EditableTree";
+import ReactMarkdown from "react-markdown";
+import rehypeRaw from "rehype-raw";
+import { marked } from "marked";
+const { Content, Header } = Layout;
+
+let arrTocTree: ListNodeData[] = [];
+let i = 0;
+do {
+  ++i;
+  arrTocTree.push({
+    key: i.toString(),
+    title: `课程 ${i}`,
+    level: 1,
+  });
+} while (i < 10); // 在循环的尾部检查条件
+
+let markdown =
+  "# 这是标题\n" +
+  "[ **M** ] arkdown + E [ **ditor** ] = **Mditor**  \n" +
+  "> Mditor 是一个简洁、易于集成、方便扩展、期望舒服的编写 markdown 的编辑器,仅此而已... \n\n" +
+  "**这是加粗的文字**\n\n" +
+  "*这是倾斜的文字*`\n\n" +
+  "***这是斜体加粗的文字***\n\n" +
+  "~~这是加删除线的文字~~ \n\n" +
+  "\n\n" +
+  "|表格头1|表格头2|表格头3| \n\n" +
+  "|------|------|------| \n\n" +
+  "| 文本 | 文本 | 文本 |\n\n" +
+  "\n\n" +
+  "```const a=2; ```";
+
+const Widget = () => {
+  // TODO
+  const { courseid } = useParams(); //url 参数
+
+  return (
+    <Layout>
+      <Content>
+        <Row>
+          <Col flex="auto"></Col>
+          <Col flex="1760px">
+            <div>
+              <div>
+                <h1 style={{ fontWeight: "bold", fontSize: 30 }}>课程7</h1>
+                <Divider />
+                <div
+                  dangerouslySetInnerHTML={{
+                    __html: marked.parse(markdown),
+                  }}
+                ></div>
+                <Divider />
+
+                <TocTree treeData={arrTocTree} />
+              </div>
+            </div>
+          </Col>
+        </Row>
+      </Content>
+    </Layout>
+  );
+};
+
+export default Widget;
+/*
+      <p style={{ "fontWeight": 'bold', "fontSize": 15}}>时间安排: 2022/2/2 </p>
+      <p style={{ "fontWeight": 'bold', "fontSize": 15}}>持续时间: 2小时 </p>
+      <p style={{ "fontWeight": 'bold', "fontSize": 15}}>主讲人: <Link to="/course/lesson/12345">小僧善巧</Link> </p>
+    
+      <Divider /> 
+        <h2 style={{ "fontWeight": 'bold', "fontSize": 20}}>直播预告</h2>
+      <p style={{ "fontWeight": 'bold', "fontSize": 15}}>marked is not a function </p>
+        <h2 style={{ "fontWeight": 'bold', "fontSize": 20}}>录播回放</h2>
+      <p style={{ "fontWeight": 'bold', "fontSize": 15}}>marked is not a function </p>
+      <h2 style={{ "fontWeight": 'bold', "fontSize": 20}}>内容</h2>
+      <p style={{ "fontWeight": 'bold', "fontSize": 15}}>marked is not a function </p>
+*/
+
+/*
+import { useParams } from "react-router-dom";
+
+const Widget = () => {
+  // TODO
+  const { lessonid } = useParams(); //url 参数
+
+  return (
+    <div>
+      <div>课 {lessonid} 详情</div>
+      <div>
+        主显示区
+      </div>
+    </div>
+  );
+};
+*/

+ 77 - 16
dashboard/src/pages/library/course/list.tsx

@@ -1,16 +1,77 @@
-import { Link } from "react-router-dom";
-
-const Widget = () => {
-	// TODO
-	return (
-		<div>
-			<div>课程首页</div>
-			<div>
-				<Link to="/course/show/12345">课程1</Link>
-				<Link to="/course/show/23456">课程2</Link>
-			</div>
-		</div>
-	);
-};
-
-export default Widget;
+//课程主页
+import { Link } from "react-router-dom";
+import { Space, Input } from "antd";
+import { Layout, Affix, Col, Row, Divider } from "antd";
+
+import LecturerList from "../../../components/library/course/LecturerList";
+import CourseList from "../../../components/library/course/CourseList";
+const { Content, Header } = Layout;
+const { Search } = Input;
+const Widget = () => {
+  // TODO i18n
+  return (
+    <Layout>
+      <Header style={{ height: 200 }}>
+        <h1 style={{"color": "white", "fontWeight": 'bold', "fontSize": 40}}>课程</h1>
+        <p style={{"color": "white", "fontSize": 17}}>看看世界各地的巴利专家都是如何解析圣典的</p>
+      </Header>
+
+
+      <Content>
+        <Row>
+          <Col flex="auto"></Col>
+          <Col flex="1260px">
+          <Row>
+              
+          <h1>主讲人</h1>
+              </Row>
+            <Row>
+              
+            <div>
+              <LecturerList />
+            </div>
+            </Row>
+          </Col>
+          <Col flex="auto"></Col>
+        </Row>
+        <Space></Space>
+        <Row>
+          <Col flex="auto"></Col>
+          <Col flex="1260px">
+          <Row>
+          <Divider />   
+          <h1>正在进行</h1>
+              </Row>
+            <Row>
+              
+            <div>
+              <CourseList />
+            </div>
+            </Row>
+          </Col>
+          <Col flex="auto"></Col>
+        </Row>
+        <Space></Space>
+        <Row>
+          <Col flex="auto"></Col>
+          <Col flex="1260px">
+          <Row>
+          <Divider />
+          <h1>已经结束</h1>
+              </Row>
+            <Row>
+              
+            <div>
+              <CourseList />
+            </div>
+            </Row>
+          </Col>
+          <Col flex="auto"></Col>
+        </Row>
+      </Content>
+    </Layout>
+
+  );
+};
+
+export default Widget;

+ 230 - 7
dashboard/src/pages/studio/course/edit.tsx

@@ -1,18 +1,241 @@
-import { Card } from "antd";
 import { useState } from "react";
-import { useIntl } from "react-intl";
 import { useParams } from "react-router-dom";
+import { useIntl, FormattedMessage } from "react-intl";
+import {
+  ProForm,
+  ProFormText,
+  ProFormTextArea,
+  ProFormDateRangePicker,
+} from "@ant-design/pro-components";
+import { Card, message, Col, Row, Divider, Tabs } from "antd";
+import { get, put } from "../../../request";
+import { marked } from "marked";
+import {
+  ICourseDataRequest,
+  ICourseResponse,
+} from "../../../components/api/Course";
+import PublicitySelect from "../../../components/studio/PublicitySelect";
 import GoBack from "../../../components/studio/GoBack";
+import UploadTexture from "../../../components/library/course/UploadTexture";
+import TeacherSelect from "../../../components/library/course/TeacherSelect";
+import StudentsSelect from "../../../components/library/course/StudentsSelect";
+import LessonSelect from "../../../components/library/course/LessonSelect";
+import LessonTreeShow from "../../../components/library/course/LessonTreeShow";
+
+interface IFormData {
+  uid: string;
+  title: string;
+
+  t_type: string;
+  status: number;
+  lang: string;
+}
+const onChange = (key: string) => {
+  console.log(key);
+};
+let groupid = "1";
 
 const Widget = () => {
   const intl = useIntl();
-  const { studioname, courseId } = useParams(); //url 参数
-  const [title, setTitle] = useState("Loading");
+  const { studioname, courseid } = useParams(); //url 参数
+  const [title, setTitle] = useState("loading");
 
   return (
-    <Card
-      title={<GoBack to={`/studio/${studioname}/course/list`} title={title} />}
-    ></Card>
+    <Tabs
+      onChange={onChange}
+      type="card"
+      items={[
+        {
+          label: `基本信息`,
+          key: "1",
+          children: (
+            <Card
+              title={
+                <GoBack
+                  to={`/studio/${studioname}/course/list`}
+                  title={title}
+                />
+              }
+            >
+              <ProForm<IFormData>
+                onFinish={async (values: IFormData) => {
+                  // TODO
+                  let request = {
+                    uid: courseid?.toString,
+                    title: "课程" + courseid,
+                    subtitle: "课程副标题" + courseid,
+                    teacher: 1,
+                    course_count: 2,
+                    type: 30,
+                    created_at: "",
+                    updated_at: "",
+                    article_id: 1, //"1e642dac-dcb2-468a-8cc7-0228e5ca6ac4",
+                    course_start_at: "", //课程开始时间
+                    course_end_at: "", //课程结束时间
+                    intro_markdown: "", //简介
+                    cover_img_name: "", //封面图片文件名
+                  };
+                }}
+                /*		    const request = {
+    uid: courseid ? courseid : "",
+    title: values.title,
+    subtitle: values.subtitle,
+    teacher: values.teacher,//UserID
+    course_count: values.course_count,//课程数
+    type: values.type,//类型-公开/内部
+    created_at: values.created_at,//创建时间
+    updated_at: values.updated_at,//修改时间
+    article_id: values.article_id,//文集ID
+    course_start_at: values.course_start_at,//课程开始时间
+    course_end_at: values.course_end_at,//课程结束时间
+    intro_markdown: values.intro_markdown,//简介
+    cover_img_name: values.cover_img_name,//封面图片文件名
+  };
+  console.log(request);
+  const res = await put<ICourseDataRequest, ICourseResponse>(
+    `/v2/course/${courseid}`,
+    request
+  );
+  console.log(res);
+  if (res.ok) {
+    message.success(intl.formatMessage({ id: "flashes.success" }));
+  } else {
+    message.error(res.message);
+  }
+}}
+request={async () => {
+  const res = await get<ICourseResponse>(`/v2/course/${courseid}`);
+  setTitle(res.data.title);
+  return {
+    uid: res.data.uid,
+    title: res.data.title,
+    subtitle: res.data.subtitle,
+    summary: res.data.summary,
+    content: res.data.content,
+    content_type: res.data.content_type,
+    lang: res.data.lang,
+    status: res.data.status,
+  };
+}}*/
+              >
+                <ProForm.Group>
+                  <ProFormText
+                    width="md"
+                    name="title"
+                    required
+                    label={intl.formatMessage({
+                      id: "forms.fields.title.label",
+                    })}
+                    rules={[
+                      {
+                        required: true,
+                        message: intl.formatMessage({
+                          id: "forms.message.title.required",
+                        }),
+                      },
+                    ]}
+                  />
+                </ProForm.Group>
+                <ProForm.Group>
+                  <ProFormText
+                    width="md"
+                    name="subtitle"
+                    label={intl.formatMessage({
+                      id: "forms.fields.subtitle.label",
+                    })}
+                  />
+                </ProForm.Group>
+                <ProForm.Group>
+                  <p style={{ fontWeight: "bold", fontSize: 15 }}>
+                    <FormattedMessage id="forms.fields.upload.texture" />{" "}
+                  </p>
+                  <UploadTexture />
+                </ProForm.Group>
+                <ProForm.Group>
+                  <ProFormDateRangePicker
+                    width="md"
+                    name={["contract", "createTime"]}
+                    label="课程区间"
+                  />
+                </ProForm.Group>
+                <ProForm.Group>
+                  <PublicitySelect />
+                </ProForm.Group>
+                <Divider />
+
+                <Row>
+                  <Col flex="400px">
+                    <TeacherSelect groupId={groupid} />
+                  </Col>
+                </Row>
+                <Divider />
+                <Row>
+                  <Col flex="400px">
+                    <LessonSelect groupId={groupid} />
+                  </Col>
+                </Row>
+                <Divider />
+                <ProForm.Group>
+                  <ProFormTextArea
+                    name="summary"
+                    width="md"
+                    label={intl.formatMessage({
+                      id: "forms.fields.summary.label",
+                    })}
+                  />
+
+                  <p style={{ fontWeight: "bold", fontSize: 15 }}>
+                    <FormattedMessage id="forms.fields.markdown.label" />{" "}
+                  </p>
+                  <Row>
+                    <div
+                      dangerouslySetInnerHTML={{
+                        __html: marked.parse(
+                          "# 这是标题\n" +
+                            "[ **M** ] arkdown + E [ **ditor** ] = **Mditor**  \n" +
+                            "**这是加粗的文字**\n\n" +
+                            "*这是倾斜的文字*`\n\n" +
+                            "***这是斜体加粗的文字***\n\n" +
+                            "~~这是加删除线的文字~~ \n\n"
+                        ),
+                      }}
+                    ></div>
+                  </Row>
+                </ProForm.Group>
+              </ProForm>
+            </Card>
+          ),
+        },
+        {
+          label: `学生与助教选择 `,
+          key: "2",
+          children: (
+            <Card
+              title={
+                <GoBack
+                  to={`/studio/${studioname}/course/list`}
+                  title={title}
+                />
+              }
+            >
+              <ProForm<IFormData> onFinish={async (values: IFormData) => {}}>
+                <ProForm.Group>
+                  <LessonTreeShow />
+                </ProForm.Group>
+                <ProForm.Group></ProForm.Group>
+
+                <Row>
+                  <Col flex="400px">
+                    <StudentsSelect groupId={groupid} />
+                  </Col>
+                </Row>
+              </ProForm>
+            </Card>
+          ),
+        },
+      ]}
+    />
+
   );
 };
 

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

@@ -1,10 +1,344 @@
-import { useParams } from "react-router-dom";
+import { useParams, Link } from "react-router-dom";
 import { useIntl } from "react-intl";
+import React, { useState } from 'react';
+import { Space, Badge,Button, Popover, Dropdown, MenuProps, Menu, Table } from "antd";
+import { ProTable,ProList  } from "@ant-design/pro-components";
+import { PlusOutlined, SearchOutlined } from "@ant-design/icons";
 
+import CourseCreate from "../../../components/library/course/CourseCreate";
+import { get } from "../../../request";
+import { ICourseListResponse } from "../../../components/api/Course";
+import { PublicityValueEnum } from "../../../components/studio/table";
+const onMenuClick: MenuProps["onClick"] = (e) => {
+  console.log("click", e);
+};
+
+const menu = (
+  <Menu
+    onClick={onMenuClick}
+    items={[
+
+      {
+        key: "1",
+        label: "分享",
+        icon: <SearchOutlined />,
+      },
+      {
+        key: "2",
+        label: "删除",
+        icon: <SearchOutlined />,
+      },
+    ]}
+  />
+);
+interface DataItem {
+  sn: number;
+  id: string;//课程ID
+  title: string;//标题
+  subtitle: string;//副标题
+  teacher: string;//UserID
+  //course_count: number;//课程数
+  type: number;//类型-公开/内部
+  createdAt: number;//创建时间
+  //updated_at: number;//修改时间
+  //article_id: number;//文集ID
+  //course_start_at: string;//课程开始时间
+  //course_end_at: string;//课程结束时间
+  //intro_markdown: string;//简介
+  //cover_img_name: string;//封面图片文件名
+}
+
+const renderBadge = (count: number, active = false) => {
+  return (
+    <Badge
+      count={count}
+      style={{
+        marginBlockStart: -2,
+        marginInlineStart: 4,
+        color: active ? '#1890FF' : '#999',
+        backgroundColor: active ? '#E6F7FF' : '#eee',
+      }}
+    />
+  );
+};
 const Widget = () => {
   const intl = useIntl(); //i18n
   const { studioname } = useParams(); //url 参数
-  return <>{studioname}</>;
+  const courseCreate = <CourseCreate studio={studioname} />;
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>('tab1');
+  return (
+
+    
+    <>
+      <ProTable<DataItem>
+        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",
+            tip: "过长会自动收缩",
+            ellipsis: true,
+            render: (text, row, index, action) => {
+              return (
+                <Link to={`/studio/${studioname}/course/${row.id}/edit`}>
+                  {row.title}
+                </Link>
+              );
+            },
+          },
+          {
+            //副标题
+            title: intl.formatMessage({
+              id: "forms.fields.subtitle.label",
+            }),
+            dataIndex: "subtitle",
+            key: "subtitle",
+            tip: "过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            //主讲人
+            title: intl.formatMessage({
+              id: "forms.fields.teacher.label",
+            }),
+            dataIndex: "teacher",
+            key: "teacher",
+            //tip: "过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            //类型
+            title: intl.formatMessage({
+              id: "forms.fields.type.label",
+            }),
+            dataIndex: "type",
+            key: "type",
+            width: 100,
+            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: "createdAt",
+            valueType: "date",
+            sorter: (a, b) => a.createdAt - b.createdAt,
+          },
+          {
+            //操作
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (text, row, index, action) => {
+              return [
+                <Dropdown.Button key={index} type="link" overlay={menu}>
+                  <Link to={`/studio/${studioname}/course/${row.id}/edit`}>
+                    {intl.formatMessage({
+                      //编辑
+                      id: "buttons.edit",
+                    })}
+                  </Link>
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        rowSelection={{
+          // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+          // 注释该行则默认不显示下拉选项
+          selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+        }}
+        tableAlertRender={({
+          selectedRowKeys,
+          selectedRows,
+          onCleanSelected,
+        }) => (
+          <Space size={24}>
+            <span>
+              {intl.formatMessage({ id: "buttons.selected" })}
+              {selectedRowKeys.length}
+              <Button
+                type="link"
+                style={{ marginInlineStart: 8 }}
+                onClick={onCleanSelected}
+              >
+                {intl.formatMessage({
+                  id: "buttons.unselect",
+                  
+                })}
+              </Button>
+            </span>
+          </Space>
+        )}
+        tableAlertOptionRender={() => {
+          return (
+            <Space size={16}>
+              <Button type="link">
+                {intl.formatMessage({
+                  id: "buttons.delete.all",
+                })}
+              </Button>
+            </Space>
+          );
+        }}
+        //从服务端获取数据
+        request={async (params = {}, sorter, filter) => {
+          // TODO
+          console.log(params, sorter, filter);
+          let url = `/v2/course?view=studio&name=${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 : "");
+          }
+
+          /*const res = await get<ICourseListResponse>(url);
+          const items: DataItem[] = res.data.rows.map((item, id) => {
+            const date = new Date(item.created_at);
+            return {
+              sn: id + 1,
+              id: item.uid,
+              title: item.title,
+              subtitle: item.subtitle,
+              teacher: item.teacher,
+              type: item.type,
+              createdAt: date.getTime(),
+            };
+          });*/
+          
+          //const items = Array.from({ length: 23 }).map((_, i) => ({
+            const items: DataItem[] = [
+              {
+              sn: 1,
+              id: "1",
+              title: "课程"+1,
+              subtitle: "课程副标题"+1,
+              teacher: "小僧善巧",
+              type: 30,
+              createdAt: 20020202,
+              //updated_at: 123,
+              //article_id: 123,
+              //course_start_at: 123,
+              //course_end_at: 123,
+              //intro_markdown: 123,
+              //cover_img_name: 123,
+            },
+            {
+              sn: 2,
+              id: "2",
+              title: "课程"+2,
+              subtitle: "课程副标题"+2,
+              teacher: "小僧善巧",
+              type: 30,
+              createdAt: 20020202,
+            }
+          ];
+          return {
+            total: items.length,//res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        
+        toolBarRender={() => [
+          <Popover
+            content={courseCreate}
+            title="Create"
+            placement="bottomRight"
+          >
+            <Button key="button" icon={<PlusOutlined />} type="primary">
+              {intl.formatMessage({ id: "buttons.create" })}
+            </Button>
+          </Popover>,
+        ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: 'tab1',
+                label: <span>我建立的课程{renderBadge(99, activeKey === 'tab1')}</span>,
+              },
+              {
+                key: 'tab2',
+                label: <span>我参加的课程{renderBadge(99, activeKey === 'tab1')}</span>,
+              },
+              {
+                key: 'tab3',
+                label: <span>我主讲的课程{renderBadge(32, activeKey === 'tab2')}</span>,
+              },
+            ],
+            onChange(key) {
+              setActiveKey(key);
+            },
+          }
+        }}
+/*
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: 'tab1',
+                label: <span>全部实验室{renderBadge(99, activeKey === 'tab1')}</span>,
+              },
+              {
+                key: 'tab2',
+                label: <span>我创建的实验室{renderBadge(32, activeKey === 'tab2')}</span>,
+              },
+            ],
+            onChange(key) {
+              setActiveKey(key);
+            },
+          },
+          search: {
+            onSearch: (value: string) => {
+              alert(value);
+            },
+          },
+          actions: [
+            <Button type="primary" key="primary">
+              新建实验
+            </Button>,
+          ],
+        }}*/
+      />
+    </>
+  );
+
 };
 
 export default Widget;

+ 10 - 3
spring/README.md

@@ -1,11 +1,18 @@
 # SPRING
 
 - [Install VirtualBox](https://www.virtualbox.org/wiki/Downloads)
-- [Install Vagrant](https://developer.hashicorp.com/vagrant/docs/vagrantfile)
-- Usage
+- [Install Vagrant](https://developer.hashicorp.com/vagrant/downloads)
+- Usage(**RUN IN the spring FOLDER**)
 
   ```bash
-  vagrant up --provider=virtualbox
+  # start virtual machine
+  VAGRANT_EXPERIMENTAL="disks" vagrant up --provider=virtualbox
+  # list boxes
   vagrant box list
+  # ssh login to the virtual machine
   vagrant ssh
+  # shutdown the virtual machine
+  vagrant halt
+  # show virtualbox machines
+  vagrant status
   ```

+ 3 - 0
spring/Vagrantfile

@@ -10,9 +10,12 @@ Vagrant.configure("2") do |config|
   # For a complete reference, please see the online documentation at
   # https://docs.vagrantup.com.
 
+  config.vm.disk :disk, size: "120GB", primary: true
+
   # Every Vagrant development environment requires a box. You can search for
   # boxes at https://vagrantcloud.com/search.
   config.vm.box = "ubuntu/jammy64"
+  config.vm.box_version = "20221219.0.0"
 
   # Disable automatic box update checking. If you disable this, then
   # boxes will only be checked for updates when the user runs