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

Merge branch 'agile' of https://github.com/iapt-platform/mint into agile

bhikkhu-kosalla-china 1 год назад
Родитель
Сommit
40abbf969e

+ 10 - 0
dashboard/src/Router.tsx

@@ -139,6 +139,10 @@ import StudioAnalysisList from "./pages/studio/analysis/list";
 import StudioInvite from "./pages/studio/invite";
 import StudioInvite from "./pages/studio/invite";
 import StudioInviteList from "./pages/studio/invite/list";
 import StudioInviteList from "./pages/studio/invite/list";
 
 
+import StudioTag from "./pages/studio/tags";
+import StudioTagList from "./pages/studio/tags/list";
+import StudioTagShow from "./pages/studio/tags/show";
+
 import { ConfigProvider } from "antd";
 import { ConfigProvider } from "antd";
 import { useAppSelector } from "./hooks";
 import { useAppSelector } from "./hooks";
 import { currTheme } from "./reducers/theme";
 import { currTheme } from "./reducers/theme";
@@ -356,6 +360,12 @@ const Widget = () => {
           <Route path="invite" element={<StudioInvite />}>
           <Route path="invite" element={<StudioInvite />}>
             <Route path="list" element={<StudioInviteList />} />
             <Route path="list" element={<StudioInviteList />} />
           </Route>
           </Route>
+
+          <Route path="tags" element={<StudioTag />}>
+            <Route path="list" element={<StudioTagList />} />
+            <Route path=":id/list" element={<StudioTagShow />} />
+          </Route>
+
           <Route path="transfer" element={<StudioTransfer />}>
           <Route path="transfer" element={<StudioTransfer />}>
             <Route path="list" element={<StudioTransferList />} />
             <Route path="list" element={<StudioTransferList />} />
           </Route>
           </Route>

+ 68 - 0
dashboard/src/components/api/Tag.ts

@@ -1,5 +1,73 @@
+import { IStudio } from "../auth/Studio";
+import { IUser } from "../auth/User";
+
 export interface TagNode {
 export interface TagNode {
   id: string;
   id: string;
   name: string;
   name: string;
   description?: string;
   description?: string;
 }
 }
+
+export interface ITagRequest {
+  id?: string;
+  name?: string;
+  description?: string | null;
+  color?: number;
+  studio?: string;
+  created_at?: string;
+  updated_at?: string;
+}
+
+export interface ITagData {
+  id: string;
+  name: string;
+  description?: string | null;
+  color: number;
+  owner: IStudio;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface ITagResponse {
+  ok: boolean;
+  message: string;
+  data: ITagData;
+}
+
+export interface ITagResponseList {
+  ok: boolean;
+  message: string;
+  data: { rows: ITagData[]; count: number };
+}
+
+export interface ITagMapRequest {
+  id?: string;
+  table_name?: string;
+  anchor_id?: string;
+  tag_id?: string;
+  studio?: string;
+  course?: string;
+}
+
+export interface ITagMapData {
+  id: string;
+  table_name: string;
+  anchor_id: string;
+  tag_id: string;
+  title?: string;
+  editor: IUser;
+  owner: IStudio;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface ITagMapResponse {
+  ok: boolean;
+  message: string;
+  data: ITagMapData;
+}
+
+export interface ITagMapResponseList {
+  ok: boolean;
+  message: string;
+  data: { rows: ITagMapData[]; count: number };
+}

+ 1 - 0
dashboard/src/components/article/TypeCourse.tsx

@@ -162,6 +162,7 @@ const TypeCourseWidget = ({
          */
          */
         if (courseId && articleId) {
         if (courseId && articleId) {
           const ic: ITextbook = {
           const ic: ITextbook = {
+            course: json.data,
             courseId: courseId,
             courseId: courseId,
             articleId: articleId,
             articleId: articleId,
             channelId: json.data.channel_id,
             channelId: json.data.channel_id,

+ 1 - 1
dashboard/src/components/corpus/ChapterCard.tsx

@@ -4,7 +4,7 @@ import { Typography } from "antd";
 
 
 import TimeShow from "../general/TimeShow";
 import TimeShow from "../general/TimeShow";
 import TocPath from "../corpus/TocPath";
 import TocPath from "../corpus/TocPath";
-import TagArea from "../tag/TagArea";
+import TagArea from "../tag/TagAreaInChapter";
 import type { IChannelApiData } from "../api/Channel";
 import type { IChannelApiData } from "../api/Channel";
 import ChannelListItem from "../channel/ChannelListItem";
 import ChannelListItem from "../channel/ChannelListItem";
 import { IStudio } from "../auth/Studio";
 import { IStudio } from "../auth/Studio";

+ 1 - 1
dashboard/src/components/corpus/ChapterTagList.tsx

@@ -3,7 +3,7 @@ import { useState, useEffect } from "react";
 import { get } from "../../request";
 import { get } from "../../request";
 import type { ChannelFilterProps } from "../channel/ChannelList";
 import type { ChannelFilterProps } from "../channel/ChannelList";
 import { ITagData } from "./ChapterTag";
 import { ITagData } from "./ChapterTag";
-import TagArea from "../tag/TagArea";
+import TagArea from "../tag/TagAreaInChapter";
 import { Skeleton } from "antd";
 import { Skeleton } from "antd";
 
 
 interface IAppendTagData {
 interface IAppendTagData {

+ 10 - 0
dashboard/src/components/studio/LeftSider.tsx

@@ -156,6 +156,16 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
           key: "attachment",
           key: "attachment",
           disabled: user?.roles?.includes("uploader") ? false : true,
           disabled: user?.roles?.includes("uploader") ? false : true,
         },
         },
+        {
+          label: (
+            <Link to={`/studio/${studioname}/tags/list`}>
+              {intl.formatMessage({
+                id: "columns.studio.tag.title",
+              })}
+            </Link>
+          ),
+          key: "tag",
+        },
         {
         {
           label: (
           label: (
             <Link to={linkSetting}>
             <Link to={linkSetting}>

+ 0 - 0
dashboard/src/components/tag/TagArea.tsx → dashboard/src/components/tag/TagAreaInChapter.tsx


+ 76 - 0
dashboard/src/components/tag/TagCreate.tsx

@@ -0,0 +1,76 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+
+import { post } from "../../request";
+import { useRef } from "react";
+import { ITagRequest, ITagResponse } from "../api/Tag";
+
+interface IWidgetCourseCreate {
+  studio?: string;
+  onCreate?: Function;
+}
+const TagCreateWidget = ({ studio = "", onCreate }: IWidgetCourseCreate) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+
+  return (
+    <ProForm<ITagRequest>
+      formRef={formRef}
+      onFinish={async (values: ITagRequest) => {
+        console.log(values);
+        if (studio) {
+          values.studio = studio;
+          const url = `/v2/tag`;
+          console.info("CourseCreateWidget api request", url, values);
+          const res = await post<ITagRequest, ITagResponse>(url, values);
+          console.info("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);
+          }
+        } else {
+          console.error("no studio");
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "forms.fields.name.label" })}
+          rules={[
+            {
+              max: 32,
+              min: 1,
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="description"
+          label={intl.formatMessage({ id: "forms.fields.description.label" })}
+          rules={[
+            {
+              max: 256,
+            },
+          ]}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default TagCreateWidget;

+ 138 - 0
dashboard/src/components/tag/TagList.tsx

@@ -0,0 +1,138 @@
+import { ActionType, ProList } from "@ant-design/pro-components";
+import { Button, Popover, Tag } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+
+import { ITagData, ITagResponseList } from "../../components/api/Tag";
+import { getSorterUrl } from "../../utils";
+import { get } from "../../request";
+import { useRef, useState } from "react";
+import TagCreate from "./TagCreate";
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  studioName?: string;
+  onSelect?: Function;
+}
+
+const TagsList = ({ studioName, onSelect }: IWidget) => {
+  const intl = useIntl(); //i18n
+  const ref = useRef<ActionType>();
+  const [openCreate, setOpenCreate] = useState(false);
+  return (
+    <ProList<ITagData>
+      actionRef={ref}
+      toolBarRender={() => {
+        return [
+          <Popover
+            content={
+              <TagCreate
+                studio={studioName}
+                onCreate={() => {
+                  //新建课程成功后刷新
+
+                  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>,
+        ];
+      }}
+      search={{
+        filterType: "light",
+      }}
+      rowKey="name"
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let url = `/v2/tag?view=studio&name=${studioName}`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : 20);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        url += params.keyword ? "&search=" + params.keyword : "";
+
+        url += getSorterUrl(sorter);
+
+        console.info("api request", url);
+        const res = await get<ITagResponseList>(url);
+        console.info("api response", res);
+        return {
+          total: res.data.count,
+          succcess: true,
+          data: res.data.rows,
+        };
+      }}
+      pagination={{
+        pageSize: 10,
+      }}
+      options={{
+        search: true,
+      }}
+      metas={{
+        title: {
+          dataIndex: "name",
+          title: "用户",
+          search: false,
+          render(dom, entity, index, action, schema) {
+            return (
+              <Tag
+                color={"#" + entity.color.toString(16)}
+                onClick={() => {
+                  if (typeof onSelect !== "undefined") {
+                    onSelect(entity);
+                  }
+                }}
+              >
+                {entity.name}
+              </Tag>
+            );
+          },
+        },
+        subTitle: {
+          dataIndex: "description",
+          search: false,
+        },
+        actions: {
+          render: (text, row) => [
+            <Button>{"edit"}</Button>,
+            <Button danger>{"delete"}</Button>,
+          ],
+          search: false,
+        },
+        status: {
+          // 自己扩展的字段,主要用于筛选,不在列表中显示
+          title: "排序",
+          valueType: "select",
+          valueEnum: {
+            all: { text: "全部", status: "Default" },
+            open: {
+              text: "未解决",
+              status: "Error",
+            },
+            closed: {
+              text: "已解决",
+              status: "Success",
+            },
+            processing: {
+              text: "解决中",
+              status: "Processing",
+            },
+          },
+        },
+      }}
+    />
+  );
+};
+
+export default TagsList;

+ 49 - 0
dashboard/src/components/tag/TagSelect.tsx

@@ -0,0 +1,49 @@
+import { useState } from "react";
+import { Modal } from "antd";
+import TagList from "./TagList";
+import { ITagData } from "../api/Tag";
+
+interface IWidget {
+  studioName?: string;
+  trigger?: React.ReactNode;
+  onSelect?: Function;
+}
+const TagSelectWidget = ({ studioName, trigger, onSelect }: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"70%"}
+        title="标签列表"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <TagList
+          studioName={studioName}
+          onSelect={(tag: ITagData) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect(tag);
+            }
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default TagSelectWidget;

+ 79 - 0
dashboard/src/components/tag/TagSelectButton.tsx

@@ -0,0 +1,79 @@
+import { Button, message } from "antd";
+import { TagOutlined } from "@ant-design/icons";
+
+import TagSelect from "./TagSelect";
+import { ITagData, ITagMapRequest, ITagResponseList } from "../api/Tag";
+import { useAppSelector } from "../../hooks";
+import { courseInfo } from "../../reducers/current-course";
+import { currentUser } from "../../reducers/current-user";
+import { post } from "../../request";
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  resId?: string;
+  resType?: string;
+  disabled?: boolean;
+  onSelect?: Function;
+  onCreate?: Function;
+}
+
+const TagSelectButtonWidget = ({
+  resId,
+  resType,
+  disabled = false,
+  onSelect,
+  onCreate,
+}: IWidget) => {
+  const intl = useIntl();
+  const course = useAppSelector(courseInfo);
+  const user = useAppSelector(currentUser);
+
+  const studioName =
+    course?.course?.studio?.realName ?? user?.nickName ?? undefined;
+
+  return (
+    <TagSelect
+      studioName={studioName}
+      trigger={
+        <Button disabled={disabled} type="text" icon={<TagOutlined />} />
+      }
+      onSelect={(tag: ITagData) => {
+        if (typeof onSelect !== "undefined") {
+          onSelect(tag);
+        } else {
+          if (studioName || course) {
+            const data: ITagMapRequest = {
+              table_name: resType,
+              anchor_id: resId,
+              tag_id: tag.id,
+              course: course ? course.courseId : undefined,
+              studio: studioName,
+            };
+
+            const url = `/v2/tag-map`;
+            console.info("tag-map  api request", url, data);
+            post<ITagMapRequest, ITagResponseList>(url, data)
+              .then((json) => {
+                console.info("tag-map api response", json);
+                if (json.ok) {
+                  message.success(
+                    intl.formatMessage({ id: "flashes.success" })
+                  );
+                  if (typeof onCreate !== "undefined") {
+                    onCreate(json.data.rows);
+                  }
+                } else {
+                  message.error(json.message);
+                }
+              })
+              .catch((e) => console.error(e));
+          } else {
+            console.error("no studio");
+          }
+        }
+      }}
+    />
+  );
+};
+
+export default TagSelectButtonWidget;

+ 98 - 0
dashboard/src/components/tag/TagShow.tsx

@@ -0,0 +1,98 @@
+import { ActionType, ProList } from "@ant-design/pro-components";
+import { Button } from "antd";
+
+import { ITagMapData, ITagMapResponseList } from "../api/Tag";
+import { getSorterUrl } from "../../utils";
+import { get } from "../../request";
+import { useRef } from "react";
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  tagId?: string;
+  onSelect?: Function;
+}
+
+const TagsList = ({ tagId, onSelect }: IWidget) => {
+  const intl = useIntl(); //i18n
+  const ref = useRef<ActionType>();
+  const pageSize = 10;
+
+  return (
+    <ProList<ITagMapData>
+      actionRef={ref}
+      search={{
+        filterType: "light",
+      }}
+      rowKey="name"
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let url = `/v2/tag-map?view=items&tag_id=${tagId}`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : pageSize);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        url += params.keyword ? "&search=" + params.keyword : "";
+
+        url += getSorterUrl(sorter);
+
+        console.info("api request", url);
+        const res = await get<ITagMapResponseList>(url);
+        console.info("api response", res);
+        return {
+          total: res.data.count,
+          succcess: true,
+          data: res.data.rows,
+        };
+      }}
+      pagination={{
+        pageSize: pageSize,
+      }}
+      options={{
+        search: true,
+      }}
+      metas={{
+        title: {
+          dataIndex: "title",
+          title: "title",
+          search: false,
+          render(dom, entity, index, action, schema) {
+            return <>{entity.title}</>;
+          },
+        },
+        subTitle: {
+          dataIndex: "description",
+          search: false,
+        },
+        actions: {
+          render: (text, row) => [
+            <Button>{"edit"}</Button>,
+            <Button danger>{"delete"}</Button>,
+          ],
+          search: false,
+        },
+        status: {
+          // 自己扩展的字段,主要用于筛选,不在列表中显示
+          title: "排序",
+          valueType: "select",
+          valueEnum: {
+            all: { text: "全部", status: "Default" },
+            open: {
+              text: "未解决",
+              status: "Error",
+            },
+            closed: {
+              text: "已解决",
+              status: "Success",
+            },
+            processing: {
+              text: "解决中",
+              status: "Processing",
+            },
+          },
+        },
+      }}
+    />
+  );
+};
+
+export default TagsList;

+ 21 - 0
dashboard/src/components/tag/TagsArea.tsx

@@ -0,0 +1,21 @@
+import { Tag } from "antd";
+import { ITagData } from "../api/Tag";
+
+interface IWidget {
+  data?: ITagData[];
+  max?: number;
+  onTagClose?: Function;
+  onTagClick?: Function;
+}
+const TagsAreaWidget = ({ data, max = 5, onTagClose, onTagClick }: IWidget) => {
+  const tags = data?.map((item, id) => {
+    return id < max ? (
+      <Tag key={id} closable onClose={() => {}}>
+        {item.name}
+      </Tag>
+    ) : undefined;
+  });
+  return <div style={{ width: "100%", lineHeight: "2em" }}>{tags}</div>;
+};
+
+export default TagsAreaWidget;

+ 14 - 0
dashboard/src/components/template/Wbw/WbwDetail.tsx

@@ -31,6 +31,8 @@ import { courseUser } from "../../../reducers/course-user";
 import { tempSet } from "../../../reducers/setting";
 import { tempSet } from "../../../reducers/setting";
 import { PopPlacement } from "./WbwPali";
 import { PopPlacement } from "./WbwPali";
 import store from "../../../store";
 import store from "../../../store";
+import TagSelectButton from "../../tag/TagSelectButton";
+import { ITagData, ITagMapData } from "../../api/Tag";
 
 
 interface IWidget {
 interface IWidget {
   data: IWbw;
   data: IWbw;
@@ -41,6 +43,7 @@ interface IWidget {
   onSave?: Function;
   onSave?: Function;
   onAttachmentSelectOpen?: Function;
   onAttachmentSelectOpen?: Function;
   onPopTopChange?: Function;
   onPopTopChange?: Function;
+  onTagCreate?: Function;
 }
 }
 const WbwDetailWidget = ({
 const WbwDetailWidget = ({
   data,
   data,
@@ -51,6 +54,7 @@ const WbwDetailWidget = ({
   onSave,
   onSave,
   onAttachmentSelectOpen,
   onAttachmentSelectOpen,
   onPopTopChange,
   onPopTopChange,
+  onTagCreate,
 }: IWidget) => {
 }: IWidget) => {
   const intl = useIntl();
   const intl = useIntl();
   const [currWbwData, setCurrWbwData] = useState<IWbw>(
   const [currWbwData, setCurrWbwData] = useState<IWbw>(
@@ -168,6 +172,16 @@ const WbwDetailWidget = ({
                 }}
                 }}
               />
               />
             </Tooltip>
             </Tooltip>
+            <TagSelectButton
+              resType="wbw"
+              resId={data.uid}
+              disabled={true}
+              onCreate={(tags: ITagData[]) => {
+                if (typeof onTagCreate !== "undefined") {
+                  onTagCreate(tags);
+                }
+              }}
+            />
             <DiscussionButton
             <DiscussionButton
               initCount={data.hasComment ? 1 : 0}
               initCount={data.hasComment ? 1 : 0}
               hideCount
               hideCount

+ 9 - 0
dashboard/src/components/template/Wbw/WbwPali.tsx

@@ -25,6 +25,8 @@ import { IStudio } from "../../auth/Studio";
 import WbwPaliDiscussionIcon from "./WbwPaliDiscussionIcon";
 import WbwPaliDiscussionIcon from "./WbwPaliDiscussionIcon";
 import { TooltipPlacement } from "antd/lib/tooltip";
 import { TooltipPlacement } from "antd/lib/tooltip";
 import { temp } from "../../../reducers/setting";
 import { temp } from "../../../reducers/setting";
+import TagsArea from "../../tag/TagsArea";
+import { ITagData } from "../../api/Tag";
 
 
 export const PopPlacement = "setting.wbw.pop.placement";
 export const PopPlacement = "setting.wbw.pop.placement";
 
 
@@ -72,6 +74,7 @@ const WbwPaliWidget = ({
 }: IWidget) => {
 }: IWidget) => {
   const [popOpen, setPopOpen] = useState(false);
   const [popOpen, setPopOpen] = useState(false);
   const [popOnTop, setPopOnTop] = useState(false);
   const [popOnTop, setPopOnTop] = useState(false);
+  const [tags, setTags] = useState<ITagData[]>();
 
 
   const [paliColor, setPaliColor] = useState("unset");
   const [paliColor, setPaliColor] = useState("unset");
   const divShell = useRef<HTMLDivElement>(null);
   const divShell = useRef<HTMLDivElement>(null);
@@ -227,6 +230,9 @@ const WbwPaliWidget = ({
         );
         );
         */
         */
       }}
       }}
+      onTagCreate={(tags: ITagData[]) => {
+        setTags(tags);
+      }}
     />
     />
   );
   );
 
 
@@ -349,6 +355,9 @@ const WbwPaliWidget = ({
     //console.debug(PopPlacement, popPlacement);
     //console.debug(PopPlacement, popPlacement);
     return (
     return (
       <div className="pali_shell" ref={divShell}>
       <div className="pali_shell" ref={divShell}>
+        <div style={{ position: "absolute", marginTop: -24 }}>
+          <TagsArea data={tags} max={1} />
+        </div>
         <span className="pali_shell_spell">
         <span className="pali_shell_spell">
           {data.grammarId ? (
           {data.grammarId ? (
             <span
             <span

+ 1 - 0
dashboard/src/locales/en-US/course/index.ts

@@ -63,6 +63,7 @@ const items = {
   "course.timeline.all": "全部参课记录",
   "course.timeline.all": "全部参课记录",
   "course.timeline.current": "当前课程",
   "course.timeline.current": "当前课程",
   "course.channel.unbound": "Unbound",
   "course.channel.unbound": "Unbound",
+  "course.member.timeline": "timeline",
 };
 };
 
 
 export default items;
 export default items;

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

@@ -61,6 +61,7 @@ const items = {
   "course.timeline.all": "全部参课记录",
   "course.timeline.all": "全部参课记录",
   "course.timeline.current": "当前课程",
   "course.timeline.current": "当前课程",
   "course.channel.unbound": "未绑定",
   "course.channel.unbound": "未绑定",
+  "course.member.timeline": "录取记录",
 };
 };
 
 
 export default items;
 export default items;

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

@@ -40,7 +40,9 @@ const Widget = () => {
             },
             },
             {
             {
               key: "member",
               key: "member",
-              label: `成员`,
+              label: intl.formatMessage({
+                id: "auth.role.member",
+              }),
               children: (
               children: (
                 <div style={{ display: "flex" }}>
                 <div style={{ display: "flex" }}>
                   <div style={{ flex: 3 }}>
                   <div style={{ flex: 3 }}>
@@ -56,7 +58,9 @@ const Widget = () => {
                       items={[
                       items={[
                         {
                         {
                           key: "timeline",
                           key: "timeline",
-                          label: "录取记录",
+                          label: intl.formatMessage({
+                            id: "course.member.timeline",
+                          }),
                           children:
                           children:
                             courseId && selected ? (
                             courseId && selected ? (
                               <CourseMemberTimeLine
                               <CourseMemberTimeLine

+ 22 - 0
dashboard/src/pages/studio/tags/index.tsx

@@ -0,0 +1,22 @@
+import { Outlet } from "react-router-dom";
+import { Layout } from "antd";
+
+import LeftSider from "../../../components/studio/LeftSider";
+import { styleStudioContent } from "../style";
+
+const { Content } = Layout;
+
+const Widget = () => {
+  return (
+    <Layout>
+      <Layout>
+        <LeftSider selectedKeys="tag" />
+        <Content style={styleStudioContent}>
+          <Outlet />
+        </Content>
+      </Layout>
+    </Layout>
+  );
+};
+
+export default Widget;

+ 20 - 0
dashboard/src/pages/studio/tags/list.tsx

@@ -0,0 +1,20 @@
+import { useNavigate, useParams } from "react-router-dom";
+
+import TagList from "../../../components/tag/TagList";
+import { ITagData } from "../../../components/api/Tag";
+
+const Widget = () => {
+  const { studioname } = useParams();
+  const navigate = useNavigate();
+  return (
+    <TagList
+      studioName={studioname}
+      onSelect={(tag: ITagData) => {
+        const url = `/studio/${studioname}/tags/${tag.id}/list`;
+        navigate(url);
+      }}
+    />
+  );
+};
+
+export default Widget;

+ 21 - 0
dashboard/src/pages/studio/tags/show.tsx

@@ -0,0 +1,21 @@
+import { useNavigate, useParams } from "react-router-dom";
+
+import TagList from "../../../components/tag/TagList";
+import { ITagData } from "../../../components/api/Tag";
+import TagShow from "../../../components/tag/TagShow";
+
+const Widget = () => {
+  const { studioname, id } = useParams();
+  const navigate = useNavigate();
+  return (
+    <TagShow
+      tagId={id}
+      onSelect={(tag: ITagData) => {
+        const url = `/studio/${studioname}/tags/${tag.id}/list`;
+        navigate(url);
+      }}
+    />
+  );
+};
+
+export default Widget;